Auto-Inject Audit Fields In NestJS: Best Practices
Hey guys! Have you ever found yourself repeatedly adding createdBy and updatedBy fields in your NestJS services? It's a common issue, and it can be quite error-prone and tedious. In this article, we'll dive into how to automate this process, making your code cleaner and more maintainable. We'll explore two main approaches: Prisma middleware and a decorator-based approach with interceptors. So, let's get started!
The Problem: Manual Audit Field Injection
Imagine you're working on a large NestJS project with multiple services. Each service interacts with your database, and you need to track who created and last updated each record. The naive approach is to manually add createdBy and updatedBy fields every time you create or update an entity. This leads to a few problems:
- Repetitive Code: You're essentially writing the same code over and over again across different services. This violates the DRY (Don't Repeat Yourself) principle, making your codebase harder to maintain.
- Error-Prone: Manually adding these fields increases the risk of making mistakes. You might forget to add the fields in one place, or you might accidentally use the wrong user ID.
- Inconsistency: Different developers might implement the logic slightly differently, leading to inconsistencies in how audit fields are handled across the application.
To illustrate this, consider the following example of manually injecting audit fields:
async create(dto: CreateDto, user: User) {
return this.prisma.entity.create({
data: {
...dto,
createdBy: user.id,
updatedBy: user.id,
},
});
}
async update(id: number, dto: UpdateDto, user: User) {
return this.prisma.entity.update({
where: { id },
data: {
...dto,
updatedBy: user.id,
},
});
}
See how we're manually setting createdBy and updatedBy in both the create and update methods? This is exactly the kind of repetition we want to avoid.
Solution 1: Prisma Middleware
One elegant solution is to use Prisma middleware. Prisma middleware allows you to intercept Prisma Client queries before they are executed. This gives us a perfect opportunity to automatically inject our audit fields.
Here's how you can implement it:
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
constructor(
private configService: ConfigService,
@Inject('REQUEST') private request: Request,
) {
super();
}
async onModuleInit() {
await this.$connect();
this.$use(async (params, next) => {
const userId = (this.request as any).user?.id;
if (params.action === 'create') {
params.args.data = {
...params.args.data,
createdBy: userId || 1, // Use a default user ID if not available
updatedBy: userId || 1,
};
}
if (params.action === 'update' || params.action === 'upsert') {
params.args.data = {
...params.args.data,
updatedBy: userId || 1,
};
}
return next(params);
});
}
}
Let's break down this code:
PrismaService: We create aPrismaServicethat extendsPrismaClientand implementsOnModuleInit. This allows us to hook into the NestJS lifecycle and connect to the database.constructor: We injectConfigServiceandREQUEST. TheREQUESTinjection is crucial as it allows us to access the request object and, consequently, the user information.$use: This is where the magic happens. We use Prisma's$usemiddleware to intercept queries. This middleware function will be executed before any Prisma query is sent to the database.userId: We extract the user ID from the request object. We assume that the user information is available on therequest.userproperty, which is a common pattern in authentication setups.- Conditional Logic: We check the
params.actionto determine the type of operation (create, update, or upsert). Based on the action, we modify theparams.args.dataobject to include thecreatedByandupdatedByfields. - Default User ID: We use
userId || 1to provide a default user ID if the user is not authenticated. This is useful for system operations or background tasks. next(params): Finally, we callnext(params)to pass the modified parameters to the next middleware or the Prisma Client itself.
With this middleware in place, you no longer need to manually add createdBy and updatedBy fields in your services. Prisma will automatically handle it for you!
Advantages of Prisma Middleware
- Centralized Logic: The audit field injection logic is centralized in one place, making it easy to maintain and update.
- Automatic Injection: You don't have to remember to add the fields in each service method. Prisma middleware takes care of it automatically.
- Reduced Boilerplate: It significantly reduces the amount of repetitive code in your services.
Disadvantages of Prisma Middleware
- Limited Flexibility: While powerful, middleware can sometimes be less flexible for complex scenarios where you need fine-grained control over how audit fields are injected.
- Request Dependency: It directly depends on the
REQUESTobject, which might not be available in all contexts (e.g., background tasks without an HTTP request).
Solution 2: Decorator + Interceptor Approach
For those seeking more flexibility, a decorator-based approach combined with NestJS interceptors can be a great alternative. This approach allows you to inject audit information at the controller level and then access it within your services.
Here's how it works:
- Create an Interceptor: An interceptor is a class that intercepts requests before they reach your route handlers. We'll use an interceptor to extract the user ID and add it to the request context.
- Create a Decorator (Optional): You can create a custom decorator to easily access the audit context within your services. This is optional but can improve code readability.
Let's look at the code:
// AuditInterceptor
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
@Injectable()
export class AuditInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const userId = request.user?.id;
// Inject userId into request context for services to access
request.auditContext = { userId };
return next.handle();
}
}
// Usage in services
import { Injectable, Req } from '@nestjs/common';
import { Request } from 'express';
@Injectable()
export class MyService {
constructor(private prisma: PrismaService) {}
async create(dto: CreateDto, @Req() req: Request) {
const userId = req.auditContext.userId;
return this.prisma.entity.create({
data: {
...dto,
createdBy: userId,
updatedBy: userId,
},
});
}
}
Let's break down this approach:
AuditInterceptor: This interceptor extracts the user ID from the request and adds it to therequest.auditContext. This is where we store our audit-related information.intercept: Theinterceptmethod is the heart of the interceptor. It receives the execution context and a call handler. We extract the request object from the context and get the user ID.request.auditContext: We add anauditContextproperty to the request object and store the user ID there. This is how we'll pass the audit information to our services.- Service Usage: In our service, we inject the
Requestobject using the@Req()decorator. We can then access theauditContextproperty and retrieve the user ID. - Manual Injection (Still): Notice that we're still manually injecting the
createdByandupdatedByfields in the service. However, we've simplified the process by centralizing the user ID retrieval in the interceptor.
Advantages of Decorator + Interceptor
- Flexibility: This approach offers more flexibility than Prisma middleware. You can easily customize the interceptor to handle different scenarios or add more audit information to the context.
- Control at the Controller Level: You have fine-grained control over which routes or controllers apply the audit logic.
- Testability: Interceptors are easily testable, allowing you to ensure that your audit logic works correctly.
Disadvantages of Decorator + Interceptor
- More Boilerplate: This approach requires more code than Prisma middleware.
- Manual Injection (Partial): While the user ID retrieval is centralized, you still need to manually inject the audit fields in your services.
Choosing the Right Approach
So, which approach should you choose? It depends on your specific needs and preferences.
- Prisma Middleware: If you want a simple, centralized solution that automatically injects audit fields, Prisma middleware is a great choice. It's especially well-suited for applications where you primarily use Prisma for data access.
- Decorator + Interceptor: If you need more flexibility and control over how audit fields are injected, the decorator + interceptor approach is a better option. This approach is well-suited for applications with complex audit requirements or those that use multiple data access methods.
Here's a quick comparison table:
| Feature | Prisma Middleware | Decorator + Interceptor |
|---|---|---|
| Complexity | Simpler | More Complex |
| Flexibility | Less Flexible | More Flexible |
| Centralization | Highly Centralized | Partially Centralized |
| Boilerplate | Less Boilerplate | More Boilerplate |
| Testability | Requires Integration Testing | Easily Unit Testable |
| Request Dependency | Direct Dependency on REQUEST |
Can be decoupled from REQUEST with some effort |
| Manual Injection | Fully Automated | Partial (User ID Retrieval Centralized) |
Action Items
Now that we've explored two different approaches, let's outline the action items you should take to implement audit field auto-injection in your NestJS application:
- Choose an Approach: Based on your needs, decide whether to use Prisma middleware or the decorator + interceptor approach.
- Implement Audit Field Auto-Injection: Write the code for your chosen approach. This involves creating the Prisma middleware or the interceptor and potentially a decorator.
- Remove Manual
createdBy/updatedBy: Go through your services and remove any manual injection ofcreatedByandupdatedByfields. - Test Thoroughly: Test your implementation with both authenticated and system operations to ensure that the audit fields are being injected correctly in all scenarios.
Conclusion
Automating audit field injection is a crucial step in building maintainable and robust NestJS applications. By using Prisma middleware or a decorator + interceptor approach, you can significantly reduce boilerplate code, minimize errors, and ensure consistency in your audit trails. So, guys, go ahead and implement these techniques in your projects and enjoy the benefits of cleaner, more efficient code! Remember to choose the approach that best fits your project's needs and always prioritize thorough testing. Happy coding!