Auto-Inject Audit Fields In NestJS: Best Practices

by Admin 51 views
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:

  1. PrismaService: We create a PrismaService that extends PrismaClient and implements OnModuleInit. This allows us to hook into the NestJS lifecycle and connect to the database.
  2. constructor: We inject ConfigService and REQUEST. The REQUEST injection is crucial as it allows us to access the request object and, consequently, the user information.
  3. $use: This is where the magic happens. We use Prisma's $use middleware to intercept queries. This middleware function will be executed before any Prisma query is sent to the database.
  4. userId: We extract the user ID from the request object. We assume that the user information is available on the request.user property, which is a common pattern in authentication setups.
  5. Conditional Logic: We check the params.action to determine the type of operation (create, update, or upsert). Based on the action, we modify the params.args.data object to include the createdBy and updatedBy fields.
  6. Default User ID: We use userId || 1 to provide a default user ID if the user is not authenticated. This is useful for system operations or background tasks.
  7. next(params): Finally, we call next(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 REQUEST object, 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:

  1. 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.
  2. 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:

  1. AuditInterceptor: This interceptor extracts the user ID from the request and adds it to the request.auditContext. This is where we store our audit-related information.
  2. intercept: The intercept method 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.
  3. request.auditContext: We add an auditContext property to the request object and store the user ID there. This is how we'll pass the audit information to our services.
  4. Service Usage: In our service, we inject the Request object using the @Req() decorator. We can then access the auditContext property and retrieve the user ID.
  5. Manual Injection (Still): Notice that we're still manually injecting the createdBy and updatedBy fields 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:

  1. Choose an Approach: Based on your needs, decide whether to use Prisma middleware or the decorator + interceptor approach.
  2. 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.
  3. Remove Manual createdBy/updatedBy: Go through your services and remove any manual injection of createdBy and updatedBy fields.
  4. 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!