Designing a 14-Service NestJS Microservices Platform
What I learned decomposing a field-service product into 14 NestJS services — service boundaries, a shared Zod contract layer, an auth gateway, and async messaging — without drowning in distributed-systems complexity.

On this page
Microservices are easy to get wrong. Split too early and you pay a distributed-systems tax for a product that a monolith would have served fine. Split along the wrong seams and every feature touches five services. When I built NextFix — a field-service platform (dispatch, technicians, jobs, invoicing) — I wanted the benefits of independent services without the usual chaos. Here is how the 14-service design actually hangs together.
Boundaries follow the domain, not the layers
Each service owns one bounded context and its own slice of PostgreSQL: auth, users, organizations, technicians, dispatch, service-requests, diagnostics, files, invoices, analytics, and notifications, all behind an api-gateway. The rule I held to: a service owns its data and nobody else reads its tables. Cross-service needs go through an API or an event — never a shared database. That single constraint is what keeps services independently deployable.
One contract layer, shared everywhere
The thing that makes a TypeScript microservices fleet pleasant is a single source of truth for shapes. Every request/response is a Zod schema in a shared contracts package, and both the producing service and every consumer import it:
import { z } from 'zod';
export const createServiceRequestSchema = z.object({
customerId: z.string().uuid(),
priority: z.enum(['low', 'normal', 'urgent']),
description: z.string().min(1).max(2000),
});
export type CreateServiceRequest = z.infer<typeof createServiceRequestSchema>;On the backend the same schema becomes a validating DTO (via nestjs-zod); on the frontend it types the API client. Change the schema and the compiler flags every place that no longer matches — across services. No drift, no hand-written duplicate interfaces.
The gateway owns identity
Auth happens once, at the edge. The gateway verifies the caller’s JWT against the identity provider’s JWKS (no shared secret to leak), resolves the user, and forwards a trusted context downstream. Services never re-implement auth — they trust the gateway and apply their own role checks:
@Roles('admin')
@Controller('admin/dispatch')
export class DispatchAdminController {
@Post('assign')
assign(@CurrentUser() user: AuthUser, @Body() body: AssignDto) {
return this.dispatch.assign(user, body);
}
}Async where it counts
Synchronous request/response is fine for reads, but anything that fans out — sending a push notification, generating an invoice PDF, recomputing analytics — goes onto a message broker (NATS in the latest iteration, queues with BullMQ for retryable jobs). The dispatch service does not wait for notifications to send; it emits an event and moves on. This is what lets a slow downstream never block the user-facing path.
What I would tell my past self
- Start with a modular monolith if you can. I only split once the domains were genuinely independent and the team needed separate deploy cadences.
- Invest in the contract layer first. It is the cheapest insurance against integration bugs and the thing that makes refactors safe.
- Make one service the boring template. Auth, logging, error shape, health checks, config — codify them once so every new service starts production-ready.
Microservices did not make the product simpler — they made it scalable to change. With clear boundaries and a shared contract layer, adding a service is a Tuesday, not a project.
Comments
Loading comments…