Most NestJS tutorials stop right where things get interesting.
You learn about modules, decorators, dependency injection, build a CRUD app, and then you ship something real and realize nobody told you how to handle a webhook event without breaking your JSON parser, or why a switch statement for event types doesn't scale.
This year I built two NestJS backend services at Resizes: an entitlements service that enforces usage limits across plans, and a billing service that handles the full Stripe subscription lifecycle. Both power the AI platform we built internally.
After a few months running them in production, here's what I actually learned. Not "what is dependency injection", you already know that (I hope so).
These are the patterns I'd copy directly into any new NestJS project.
Summary
- How to handle webhook events without breaking your JSON endpoints.
- Why a factory beats a switch when you have many event types.
- Why a repository layer on top of Prisma ORM makes testing easier.
- The ValidationPipe options most tutorials skip.
- Named exceptions that make your codebase greppable.
- Why validating env vars at startup saves you a 3am incident.
The module is your unit of ownership
A module in NestJS is not just a folder convention, it's a boundary.
In other words, a module should be something you could delete and the rest of the app wouldn't notice (until it needs to). What happens inside stays inside.
Here's what that looks like in practice:
EntitlementsModuleBillingModuleTasksModuleHealthModulePrismaModule
Each one is a self-contained folder with its controller, service, repository, interfaces, DTOs and tests. Nothing leaks out except what's explicitly exported, and the layer separation is strict: controllers route and validate, services hold business logic, repositories are the only place that touches Prisma.
One thing to watch out for: module dependencies must be uni-directional.
If BillingModule imports from EntitlementsModule, that's fine. But if EntitlementsModule then also imports from BillingModule, you've created a circular dependency and NestJS will tell you at startup with a cryptic error.
The fix is usually introducing a third, higher-level module that orchestrates both, or reconsidering whether that cross-dependency is pointing at a domain boundary problem.
NestJS does have a forwardRef() escape hatch for exactly this situation, but it's a patch, not a solution. Every time I've reached for it, the real answer was that I'd mixed two domains into what should have been separate modules.
For PrismaService, I went with @Global() to avoid re-importing it everywhere:
// prisma/prisma.module.ts
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
And the service handles its own lifecycle:
// prisma/prisma.service.ts
@Injectable()
export class PrismaService extends PrismaClient
implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(PrismaService.name)
async onModuleInit() {
await this.$connect()
this.logger.log('Database connected')
}
async onModuleDestroy() {
await this.$disconnect()
this.logger.log('Database disconnected')
}
}
One instance, clean connect/disconnect. I've seen more than one codebase with connection leaks because nobody hooked into the module lifecycle.
The Stripe webhook pattern you should steal
This is the most concrete thing I can give you.
Webhooks from Stripe (or any payment provider) have a problem: to validate the signature you need the raw body as a buffer, but NestJS's global JSON parser already consumed it by the time your controller runs.
The naive fix is enabling raw body globally. Don't do that. It breaks your regular JSON endpoints.
The correct fix is three pieces that work together.
1. Apply the raw body parser only on the webhook route, before NestJS starts:
// main.ts
app.use('/stripe/webhooks', raw({ type: 'application/json' }))
This runs before the global JSON middleware. Only that route gets a buffer body. Everything else keeps working normally.
2. Validate the signature in a middleware:
// stripe-webhook.middleware.ts
@Injectable()
export class StripeWebhookMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
const sig = req.headers['stripe-signature']
try {
req['stripeEvent'] = this.stripe.webhooks.constructEvent(
req.body,
sig,
this.configService.get('STRIPE_WEBHOOK_SECRET')
)
next()
} catch (err) {
this.logger.error(`[StripeWebhook] Invalid signature: ${err.message}`)
throw new BadRequestException('Invalid webhook signature')
}
}
}
The middleware decorates the request with the parsed stripeEvent. The controller never touches raw bytes.
3. In the controller, process and respond. Keep handlers fast:
// stripe.controller.ts
@Post('webhooks')
async handleWebhook(@Req() req: Request) {
const event = req['stripeEvent'] as Stripe.Event
await this.stripeService.handleEvent(event)
return { received: true }
}
Stripe gives you up to 30 seconds before it marks the delivery as failed and retries. The await is intentional: each individual handler is fast, just a database write and a status update, nothing close to that limit.
If your handlers were doing slower work (calling external APIs, sending emails through a third-party service), the right move is to push the event to a queue like BullMQ or a Postgres outbox table, and respond 200 immediately. At our scale, staying synchronous is simpler and predictable.
One more thing: since NestJS v8 there's a cleaner alternative to the app.use('/stripe/webhooks', raw(...)) approach. You can pass { rawBody: true } to NestFactory.create() and access req.rawBody directly in the middleware, which keeps the Express-level setup out of your bootstrap file. Both work!
Event handler factory instead of a switch
The billing service receives a dozen different Stripe event types, for example:
customer.subscription.createdcustomer.subscription.updatedinvoice.paid
The first instinct is a switch statement. Don't do that, there's a better way of handling this:
// the antipattern
switch (event.type) {
case 'customer.subscription.created':
await this.handleSubscriptionCreated(event)
break
case 'customer.subscription.updated':
await this.handleSubscriptionUpdated(event)
break
// ... 10 more cases
}
This is a maintenance trap. Every new event type means opening this file and adding a case. The class grows without bound and testing becomes painful.
Instead, I built a factory that resolves handlers by event type, all via Nest's DI:
// stripe-event-handler.factory.ts
@Injectable()
export class StripeEventHandlerFactory {
private readonly eventHandlers: Record<string, StripeEventHandler>
constructor(
private readonly subscriptionUpdatedHandler: CustomerSubscriptionUpdatedEventHandler,
private readonly subscriptionDeletedHandler: CustomerSubscriptionDeletedEventHandler,
private readonly invoicePaidHandler: InvoicePaidEventHandler,
// ...
) {
this.eventHandlers = {
'customer.subscription.updated': this.subscriptionUpdatedHandler,
'customer.subscription.deleted': this.subscriptionDeletedHandler,
'invoice.paid': this.invoicePaidHandler,
}
}
resolve(eventType: string): StripeEventHandler {
const handler = this.eventHandlers[eventType]
if (!handler) throw new NotFoundException(`No handler for event: ${eventType}`)
return handler
}
}
Each handler is its own class with a single handle method. Adding a new event type is just a new class and one line in the map. No existing file changes. Open/Closed principle in practice.
The service call ends up clean:
const handler = this.stripeEventHandlerFactory.resolve(event.type)
await handler.handle(event)
Repository pattern on top of Prisma
I know this sounds like over-engineering when Prisma already gives you a clean API. The point isn't the abstraction itself, it's the isolation.
When one of your services depends directly on Prisma, testing the service means setting up a real database or doing gymnastics with jest.mock('prisma'). When your service depends on a repository layer, you inject a mock in tests and the whole thing collapses into a flat unit test.
// entitlements.repository.ts
@Injectable()
export class EntitlementsRepository {
constructor(private readonly prisma: PrismaService) {}
async incrementUsage(userId: string, entitlementId: string): Promise<void> {
await this.prisma.entitlementUsage.update({
where: { userId_entitlementId: { userId, entitlementId } },
data: { currentUsage: { increment: 1 } },
})
}
}
The corresponding service test:
// entitlements.service.spec.ts
describe('EntitlementsService', () => {
let service: EntitlementsService
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
EntitlementsService,
{
provide: EntitlementsRepository,
useValue: {
incrementIfWithinLimit: jest.fn().mockResolvedValue(true),
},
},
],
}).compile()
service = module.get(EntitlementsService)
})
it('should increment usage when within limit', async () => {
await service.incrementUsage('user-1', 'TOKENS')
// assert on the mock, not the database
})
})
No database. No containers. Pure logic under test!
Configure your ValidationPipe properly
Most tutorials show only the basic ValidationPipe setup. These three options are the ones that actually matter in production:
app.useGlobalPipes(new ValidationPipe({
whitelist: true, // strips unknown properties before they reach your service
forbidNonWhitelisted: true, // throws 400 if the client sends unknown fields
transform: true, // auto-transforms payloads to DTO class instances
}))
whitelist is a silent safety net: if someone sends extra fields they shouldn't, they never reach your business logic. forbidNonWhitelisted makes it explicit by rejecting the request outright. Both together are the right default for any API that doesn't accept open-ended payloads.
Exceptions that tell you something
NestJS's built-in HTTP exceptions are good, but extending them is better.
Instead of throwing raw HttpException everywhere, define named exception classes:
// exceptions/entitlement-limit-exceeded.exception.ts
export class EntitlementLimitExceededException extends ForbiddenException {
constructor(userId: string, type: string) {
super(
`User ${userId} has reached the limit for entitlement: ${type}`,
'ENTITLEMENT_LIMIT_EXCEEDED'
)
}
}
// exceptions/billing-user-not-found.exception.ts
export class BillingUserNotFoundException extends NotFoundException {
constructor(userId: string) {
super(`Billing user not found: ${userId}`, 'BILLING_USER_NOT_FOUND')
}
}
Usage in the service:
if (!withinLimit) {
throw new EntitlementLimitExceededException(userId, type)
}
Two reasons this is worth the extra file. First, the second argument to super() overwrites the error field in the response body. The response ends up as:
{
"statusCode": 403,
"error": "ENTITLEMENT_LIMIT_EXCEEDED",
"message": "User xyz has reached the limit for entitlement: TOKENS"
}
Your frontend can match on error without parsing the human-readable message.
Second, when you search the codebase for EntitlementLimitExceededException, you find every place that throws it. With throw new HttpException('...') scattered around, you have nothing to grep.
Validate your config at startup
One of the most underrated NestJS patterns: validate your environment variables when the app boots, not when a request first hits the code path that needs them.
// app.module.ts
ConfigModule.forRoot({
isGlobal: true,
validationSchema: Joi.object({
DATABASE_URL: Joi.string().required(),
STRIPE_SECRET_KEY: Joi.string().required(),
STRIPE_WEBHOOK_SECRET: Joi.string().required(),
DASH_UI_URL: Joi.string().required(),
NODE_ENV: Joi.string().valid('development', 'production', 'test').default('development'),
}),
})
If any required variable is missing or has the wrong type, the app throws before it binds to port 3000. You find out in the deploy step, not at 3am when the first request hits that code path.
For CORS, same idea: no wildcards, explicit origins from env:
app.enableCors({
origin: [
configService.get('DASH_UI_URL'),
configService.get('DASH_API_SERVICE_URL'),
],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
})
Reading origins from env means you don't touch code to change them between environments.
Closing
The patterns above didn't come from reading documentation, they came from running these services under real load and fixing real bugs.
The Stripe webhook pattern specifically came from a Sunday afternoon of ghost events because I had the response timing wrong.
The factory pattern for event handlers came from thinking early about how to add new event types without touching existing code.
If there's one principle behind all of them it's this: each piece of code should have exactly one reason to change. The controller doesn't know about Prisma. The service doesn't know about HTTP. The repository doesn't know about business rules. When something breaks (and it will), you know exactly where to look.
NestJS gives you the tools to do this. Whether you use them is up to you.
If any of these patterns saved you a Sunday afternoon, I'd love to hear about it in the comments. And if you spot something technically off, even more so.
Thanks for reading!
Wences 👋


Top comments (0)