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({...
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;
}
}
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;
}
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
}
}
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;
};
}
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;
};
}
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...*
}
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;
}
}
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*
}
Rate Limiting:
@RateLimit({ maxRequests: 10, windowMs: 60000 })
async processRequest(data: RequestData, userId: string) {
*// Just request processing*
}
Performance Monitoring:
@MeasurePerformance({ threshold: 1000, alertOn: "slow" })
async complexOperation(params: OperationParams) {
*// Just operation logic*
}
Caching:
@Cache({ ttl: 300, key: (userId) => `user:${userId}:data` })
async getUserData(userId: string) {
*// Just data retrieval*
}
Implementation Approach
The refactor was straightforward:
- Built the decorator with proper TypeScript typing
- Applied to one service and verified behavior
- Rolled out incrementally across services
- 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.