阿里云主机折上折
  • 微信号
Current Site:Index > Over-engineering (abstracting a button click event into 5 layers of interfaces)

Over-engineering (abstracting a button click event into 5 layers of interfaces)

Author:Chuan Chen 阅读数:26760人阅读 分类: 前端综合

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:

  1. First, they can't find the business logic in the event listener.
  2. They trace it to the business dispatcher and discover dynamically registered handlers.
  3. They locate the handler implementation, only to find it requires DTO transformation.
  4. During debugging, they notice data is unexpectedly modified during transformation.
  5. Error handling is scattered across at least three different layers.
  6. Adding a simple cancellation logic requires modifying all interfaces.

How to Identify Overengineering

  1. Interface Bloat: Every simple operation requires implementing multiple interface methods.
  2. Indirect Calls: Tracking a call stack requires jumping through five or more files.
  3. Zombie Code: Numerous unused "extension points" exist.
  4. Configuration Fatigue: Adding new features requires modifying multiple configuration files.
  5. Leaky Abstractions: Lower-layer implementation details constantly leak into upper-layer interfaces.

Boundaries of Reasonable Abstraction

  1. Consider abstraction only when the same pattern appears at least three times.
  2. Keep call stacks to no more than three layers (view-service-API).
  3. Avoid reserving extension points for "potential" requirements.
  4. Ensure each abstraction can independently justify its existence.
  5. Refactor regularly instead of over-designing upfront.

The Cost of Overengineering

  1. Cognitive Load: New team members must understand the entire architecture to modify simple features.
  2. Debugging Difficulty: Errors can occur at any abstraction layer.
  3. Performance Overhead: Each abstraction layer adds extra function calls and memory usage.
  4. Resistance to Change: Simple requirement changes necessitate modifying multiple files.
  5. 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

Front End Chuan

Front End Chuan, Chen Chuan's Code Teahouse 🍵, specializing in exorcising all kinds of stubborn bugs 💻. Daily serving baldness-warning-level development insights 🛠️, with a bonus of one-liners that'll make you laugh for ten years 🐟. Occasionally drops pixel-perfect romance brewed in a coffee cup ☕.