DEV Community

Cover image for From Repetitive Code to Clean Architecture: How the Decorator Pattern Simplified Activity Logging by 70%
Digvijay Jadhav
Digvijay Jadhav

Posted on

From Repetitive Code to Clean Architecture: How the Decorator Pattern Simplified Activity Logging by 70%

We had activity logging across our entire application - typical audit trail stuff that tracks user actions throughout the system. The initial implementation was straightforward: add logging calls directly in the service methods.

It worked perfectly. Shipped on time, no issues in production. But after a few months of adding new features, the pattern became obvious - we had the same logging boilerplate repeated across dozens of methods.

Not broken. Not urgent. Just... inefficient.

Here's what the pattern looked like:

async someServiceMethod(
  userId: string,
  data: string,
  context?: { ipAddress?: string; userAgent?: string }
) {
  try {
    const result = await performOperation(userId, data);
    *// Success logging - 12 lines every single time*
    this.activityLogService
      .logActivity({
        userId,
        actionType: "RESOURCE_ACTION",
        resourceId: result.id,
        resourceName: result.name,
        ipAddress: context?.ipAddress,
        userAgent: context?.userAgent,
        status: "SUCCESS",
      })
      .catch((err) => {
        console.error("Failed to log activity:", err);
      });
    return result;
  } catch (error) {
    *// Failure logging - another 12 lines*
    this.activityLogService
      .logActivity({
        userId,
        actionType: "RESOURCE_ACTION",
        resourceName: data,
        ipAddress: context?.ipAddress,
        userAgent: context?.userAgent,
        status: "FAILED",
        errorMessage: error instanceof Error ? error.message : "Unknown error",
      })
      .catch((err) => {
        console.error("Failed to log activity failure:", err);
      });
    throw error;
  }
}
Enter fullscreen mode Exit fullscreen mode

Multiply this by every service method that needed logging - we're talking about hundreds of lines of repetitive try-catch blocks doing essentially the same thing.

The One-Day Refactor Decision

We then thought of optimizing this, This is a perfect use case for decorators.

The decision was straightforward - we had a cross-cutting concern that was cluttering business logic. Decorators would let us declare the logging behavior and keep the methods focused on what they actually do.

Not a revolutionary insight. Just recognizing when the right tool fits the problem.

The Target Design

The goal was simple - declarative logging that doesn't clutter the business logic:

@LogActivity({
  actionType: "RESOURCE_ACTION",
  resourceType: "RESOURCE"
})
async someServiceMethod(
  userId: string,
  data: string,
  context?: { ipAddress?: string; userAgent?: string }
) {
  const result = await performOperation(userId, data);
  return result;
}
Enter fullscreen mode Exit fullscreen mode

Clean business logic, logging concern declared at the method level. That's it.

Implementation

Configuration

TypeScript decorators need to be enabled:

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}
Enter fullscreen mode Exit fullscreen mode

Interface Design

The decorator configuration needed to handle different method signatures and extract resource information from both successful results and failed attempts:

export interface LogActivityConfig {
  actionType: string;
  resourceType: string;

  paramMapping?: {
    userId?: number | string;  *// Supports both index and nested paths*
    context?: number;
  };

  extractResource?: (result: any, params: any[]) => {
    resourceId?: string;
    resourceName?: string;
    metadata?: any;
  };

  extractResourceFromParams?: (params: any[]) => {
    resourceId?: string;
    resourceName?: string;
    metadata?: any;
  };
}
Enter fullscreen mode Exit fullscreen mode

Key design decisions:

  • Flexible parameter mapping for different method signatures
  • Separate extraction functions for success/failure cases
  • Optional metadata support for additional context

Decorator Implementation

export function LogActivity(config: LogActivityConfig) {
  return function (
    target: any,
    propertyKey: string,
    descriptor: PropertyDescriptor
  ) {
    const originalMethod = descriptor.value;
    const activityLogService = ActivityLogService.getInstance();

    descriptor.value = async function (...args: any[]) {
      *// Extract userId from parameters*
      let userId: string;
      if (typeof config.paramMapping?.userId === "string") {
        const parts = config.paramMapping.userId.split(".");
        let value: any = args[0];
        for (const part of parts) {
          value = value?.[part];
        }
        userId = value;
      } else {
        const userIdIndex = config.paramMapping?.userId ?? 0;
        userId = args[userIdIndex];
      }

      const contextIndex = config.paramMapping?.context ?? args.length - 1;
      const context = args[contextIndex];

      try {
        const result = await originalMethod.apply(this, args);

        const { resourceId, resourceName, metadata } =
          config.extractResource?.(result, args) || {};

        *// Non-blocking success logging*
        activityLogService
          .createActivityLog({
            userId,
            actionType: config.actionType,
            resourceType: config.resourceType,
            resourceId,
            resourceName,
            metadata,
            ipAddress: context?.ipAddress,
            userAgent: context?.userAgent,
            status: "SUCCESS",
          })
          .catch((err) => 
            console.error(`Failed to log ${config.actionType}:`, err)
          );

        return result;

      } catch (error) {
        const { resourceId, resourceName, metadata } =
          config.extractResourceFromParams?.(args) || {};

        *// Non-blocking failure logging*
        activityLogService
          .createActivityLog({
            userId,
            actionType: config.actionType,
            resourceType: config.resourceType,
            resourceId,
            resourceName,
            metadata,
            ipAddress: context?.ipAddress,
            userAgent: context?.userAgent,
            status: "FAILED",
            errorMessage: error instanceof Error ? error.message : "Unknown error",
          })
          .catch((err) => 
            console.error(`Failed to log ${config.actionType}:`, err)
          );

        throw error;
      }
    };

    return descriptor;
  };
}

Enter fullscreen mode Exit fullscreen mode

Critical implementation details:

  • Logging operations are non-blocking (won't break main functionality)
  • Original errors are always re-thrown unchanged
  • Decorator preserves original method behavior completely

Results

Before (213 lines across a service):

export class ServiceClass {
  private activityLogService: ActivityLogService;

  constructor() {
    this.activityLogService = ActivityLogService.getInstance();
  }

  async someMethod(userId: string, data: string, context?: Context) {
    try {
      const result = await performOperation(userId, data);

      this.activityLogService
        .logActivity({
          userId,
          actionType: "ACTION_TYPE",
          resourceId: result.id,
          resourceName: result.name,
          ipAddress: context?.ipAddress,
          userAgent: context?.userAgent,
          status: "SUCCESS",
        })
        .catch((err) => console.error("Failed to log:", err));

      return result;
    } catch (error) {
      this.activityLogService
        .logActivity({
          userId,
          actionType: "ACTION_TYPE",
          resourceName: data,
          ipAddress: context?.ipAddress,
          userAgent: context?.userAgent,
          status: "FAILED",
          errorMessage: error.message,
        })
        .catch((err) => console.error("Failed to log:", err));

      throw error;
    }
  }

  *// Same pattern repeated for every method...*
}
Enter fullscreen mode Exit fullscreen mode

After (139 lines - 35% reduction):

export class ServiceClass {
  @LogActivity({
    actionType: "ACTION_TYPE",
    resourceType: "RESOURCE",
    paramMapping: { userId: 0, context: 2 },
    extractResource: (result) => ({
      resourceId: result.id,
      resourceName: result.name,
    }),
    extractResourceFromParams: (params) => ({
      resourceName: params[1],
    }),
  })
  async someMethod(userId: string, data: string, context?: Context) {
    const result = await performOperation(userId, data);
    return result;
  }

  @LogActivity({
    actionType: "ANOTHER_ACTION",
    resourceType: "RESOURCE",
    paramMapping: { userId: 0, context: 3 },
    extractResource: (result, params) => ({
      resourceId: result.id,
      resourceName: result.name,
      metadata: { additionalInfo: params[2] },
    }),
  })
  async anotherMethod(
    userId: string,
    data: string,
    info: string,
    context?: Context
  ) {
    const result = await performAnotherOperation(userId, data, info);
    return result;
  }
}
Enter fullscreen mode Exit fullscreen mode

The reduction isn't just about line count - the code is now focused on business logic with cross-cutting concerns handled declaratively.

Impact

Quantitative:

  • 70% reduction in logging-related code
  • 35% overall reduction per service
  • Zero changes to business logic behavior
  • Maintained 100% test coverage

Qualitative:

Improved Onboarding
New developers can immediately understand method intent without parsing logging infrastructure:

typescript

@LogActivity({ actionType: "RESOURCE_ACTION", resourceType: "RESOURCE" })
async someMethod(userId: string, data: string, context?: Context) {
*// Pure business logic*
}

Faster Feature Development
Adding logging to new methods: add decorator, configure parameters, done. No boilerplate to copy, no edge cases to remember.

Simplified Maintenance
Need to change logging format globally? Update the decorator. One change propagates everywhere.

Better Code Reviews
Reviewers focus on business logic. Cross-cutting concerns are declared, not mixed in with implementation.

When This Pattern Applies

Decorators solve a specific problem: you have behavior that needs to be applied consistently across multiple methods, but that behavior is orthogonal to the core business logic.

Good indicators:

  • Same try-catch pattern across multiple methods
  • Cross-cutting concerns mixed with business logic
  • Copy-pasting setup/teardown code
  • Consistent "before" and "after" logic

Other Applications

Once the logging decorator was in place, we identified similar opportunities:

Authentication & Authorization:

@RequireAuth()
@RequirePermission("resource.delete")
async deleteResource(resourceId: string, userId: string) {
  *// Just deletion logic*
}
Enter fullscreen mode Exit fullscreen mode

Rate Limiting:

@RateLimit({ maxRequests: 10, windowMs: 60000 })
async processRequest(data: RequestData, userId: string) {
  *// Just request processing*
}
Enter fullscreen mode Exit fullscreen mode

Performance Monitoring:

@MeasurePerformance({ threshold: 1000, alertOn: "slow" })
async complexOperation(params: OperationParams) {
  *// Just operation logic*
}
Enter fullscreen mode Exit fullscreen mode

Caching:

@Cache({ ttl: 300, key: (userId) => `user:${userId}:data` })
async getUserData(userId: string) {
  *// Just data retrieval*
}
Enter fullscreen mode Exit fullscreen mode

Implementation Approach

The refactor was straightforward:

  1. Built the decorator with proper TypeScript typing
  2. Applied to one service and verified behavior
  3. Rolled out incrementally across services
  4. Added documentation and examples

Total time: one day of focused work.

Key Takeaways

Pragmatism First, Optimization Second
The original implementation wasn't wrong - it worked in production without issues. The decorator refactor was an optimization made when the pattern became clear, not a premature abstraction.

Design Patterns as Refactoring Tools
Patterns are most valuable when you recognize them in existing code, not when you try to force them during initial implementation.

Incremental Adoption
Starting with one service and expanding proved less risky than a wholesale rewrite. Validate the pattern works before committing to it everywhere.

Clear Over Clever
The decorator doesn't make the code sophisticated - it makes it clear. Methods now explicitly declare their concerns rather than embedding them in implementation.

Conclusion

This refactor wasn't about applying design patterns for their own sake. It was about recognizing a specific problem - repetitive cross-cutting concerns cluttering business logic - and using the appropriate tool to solve it.

The result: less code, better maintainability, and clearer separation of concerns. Sometimes the best refactor is the one you didn't do upfront, but recognized when the need became obvious.

Top comments (2)

Collapse
 
bhuwan_purohit_006c0bc7dd profile image
Bhuwan Purohit

Great explanation

Collapse
 
neha_narayane_941b9fe55d5 profile image
neha narayane

You've done a great job putting your thoughts into words. Very well done !