Over-engineering (abstracting a button click event into 5 layers of interfaces)
Overengineering is a common anti-pattern in frontend development, where the superficial pursuit of "high extensibility" and "low coupling" results in complex abstraction layers. A simple button click event is split into five layers of interfaces, each performing trivial forwarding, ultimately making the data flow incomprehensible even to the original author.
Layer 1: Event Listener Wrapper
Starting with the basic click event, we first create an "event listener management layer":
// First layer of abstraction: Event listener wrapper
class EventListenerWrapper {
private handlers: Map<string, (event: Event) => void> = new Map();
register(element: HTMLElement, eventType: string, handler: (event: Event) => void) {
const wrappedHandler = (e: Event) => {
console.log(`[${new Date().toISOString()}] Event ${eventType} triggered`);
handler(e);
};
this.handlers.set(`${element.id}-${eventType}`, wrappedHandler);
element.addEventListener(eventType, wrappedHandler);
}
// An unregister method should also be implemented, though no one ever uses it
}
This wrapper not only logs events but also stores all handlers in a Map. In reality, we just need to add a click event to a button, but now we're already managing the lifecycle of event handlers.
Layer 2: Business Logic Dispatcher
Click events shouldn't handle business logic directly; we need a "business logic distribution center":
// Second layer of abstraction: Business logic router
interface IActionHandler {
handleAction(payload: unknown): Promise<void>;
}
class BusinessLogicDispatcher {
private static instance: BusinessLogicDispatcher;
private handlers: Record<string, IActionHandler> = {};
private constructor() {} // Singleton pattern, mandatory
static getInstance(): BusinessLogicDispatcher {
if (!BusinessLogicDispatcher.instance) {
BusinessLogicDispatcher.instance = new BusinessLogicDispatcher();
}
return BusinessLogicDispatcher.instance;
}
registerHandler(actionType: string, handler: IActionHandler) {
this.handlers[actionType] = handler;
}
async dispatch(actionType: string, payload: unknown) {
if (!this.handlers[actionType]) {
throw new Error(`No handler registered for ${actionType}`);
}
return this.handlers[actionType].handleAction(payload);
}
}
Now, click events must first pass through the dispatcher, which then finds the actual handler. We've introduced a singleton pattern and interfaces, and the code complexity begins to grow exponentially.
Layer 3: Domain Service Abstraction
The actual business logic should reside in a "domain service," so we create:
// Third layer of abstraction: Domain service
interface IUserService {
updateUserProfile(data: unknown): Promise<void>;
}
class UserService implements IUserService {
private apiClient: ApiClient;
private logger: Logger;
private validator: Validator;
constructor() {
this.apiClient = new ApiClient('/api');
this.logger = Logger.getInstance();
this.validator = new Validator();
}
async updateUserProfile(data: unknown) {
try {
this.validator.validate(data);
const response = await this.apiClient.post('/profile', data);
this.logger.log('Profile updated', response);
} catch (error) {
this.logger.error('Update failed', error);
throw new DomainException('Failed to update profile');
}
}
}
Note that there are already four new dependencies here, each with its own initialization logic and configuration. We're getting farther and farther away from that simple click event.
Layer 4: DTO Transformation Layer
Data can't be passed directly from the event to the service layer; it needs DTO transformation:
// Fourth layer of abstraction: DTO transformer
interface IDtoTransformer<TFrom, TTo> {
transform(from: TFrom): TTo;
}
class ClickEventToProfileDtoTransformer
implements IDtoTransformer<MouseEvent, UserProfileDto> {
transform(event: MouseEvent): UserProfileDto {
const target = event.target as HTMLElement;
return {
userId: target.dataset['userId'],
lastClickPosition: {
x: event.clientX,
y: event.clientY
},
timestamp: new Date().toISOString()
};
}
}
This transformer includes the mouse click position as part of the user profile, even though the business logic doesn't need this information—but architecturally, it's "complete."
Layer 5: Response Handler
Finally, we need to handle the results returned by the service layer:
// Fifth layer of abstraction: Response handler
interface IResponseHandler<T> {
handleSuccess(response: T): void;
handleError(error: Error): void;
}
class ProfileUpdateResponseHandler implements IResponseHandler<void> {
private notificationService: NotificationService;
constructor() {
this.notificationService = new NotificationService();
}
handleSuccess() {
this.notificationService.show({
type: 'success',
message: 'Profile updated successfully',
duration: 5000
});
}
handleError(error: Error) {
this.notificationService.show({
type: 'error',
message: `Update failed: ${error.message}`,
duration: 10000
});
Sentry.captureException(error);
}
}
Final Assembly
Now, let's assemble all these layers:
// Initialize all components
const wrapper = new EventListenerWrapper();
const dispatcher = BusinessLogicDispatcher.getInstance();
const userService = new UserService();
const transformer = new ClickEventToProfileDtoTransformer();
const responseHandler = new ProfileUpdateResponseHandler();
// Register business handler
dispatcher.registerHandler('UPDATE_PROFILE', {
async handleAction(payload: MouseEvent) {
const dto = transformer.transform(payload);
try {
await userService.updateUserProfile(dto);
responseHandler.handleSuccess();
} catch (error) {
responseHandler.handleError(error as Error);
}
}
});
// Bind button click event
wrapper.register(
document.getElementById('profile-button')!,
'click',
(event) => dispatcher.dispatch('UPDATE_PROFILE', event)
);
The Beginning of a Maintenance Nightmare
Three months later, a new developer needs to modify this click logic:
- First, they can't find the business logic in the event listener.
- They trace it to the business dispatcher and discover dynamically registered handlers.
- They locate the handler implementation, only to find it requires DTO transformation.
- During debugging, they notice data is unexpectedly modified during transformation.
- Error handling is scattered across at least three different layers.
- Adding a simple cancellation logic requires modifying all interfaces.
How to Identify Overengineering
- Interface Bloat: Every simple operation requires implementing multiple interface methods.
- Indirect Calls: Tracking a call stack requires jumping through five or more files.
- Zombie Code: Numerous unused "extension points" exist.
- Configuration Fatigue: Adding new features requires modifying multiple configuration files.
- Leaky Abstractions: Lower-layer implementation details constantly leak into upper-layer interfaces.
Boundaries of Reasonable Abstraction
- Consider abstraction only when the same pattern appears at least three times.
- Keep call stacks to no more than three layers (view-service-API).
- Avoid reserving extension points for "potential" requirements.
- Ensure each abstraction can independently justify its existence.
- Refactor regularly instead of over-designing upfront.
The Cost of Overengineering
- Cognitive Load: New team members must understand the entire architecture to modify simple features.
- Debugging Difficulty: Errors can occur at any abstraction layer.
- Performance Overhead: Each abstraction layer adds extra function calls and memory usage.
- Resistance to Change: Simple requirement changes necessitate modifying multiple files.
- Testing Complexity: Each interface requires separate unit tests.
Refactoring Direction
If you're already stuck in this architecture, consider:
// Straightforward implementation
document.getElementById('profile-button')?.addEventListener('click', async () => {
try {
const response = await fetch('/api/profile', {
method: 'POST',
body: JSON.stringify({ userId: '123' })
});
showNotification('Profile updated');
} catch (error) {
showNotification('Update failed');
console.error(error);
}
});
This version might not be "enterprise-grade," but it:
- Is highly readable
- Easy to modify
- Has a clear debugging path
- No hidden dependencies
- Immediately understandable to newcomers
本站部分内容来自互联网,一切版权均归源网站或源作者所有。
如果侵犯了你的权益请来信告知我们删除。邮箱:cc@cccx.cn