Project Structure
The monorepo is the backbone of this architecture. By co-locating all services in a single repository while enforcing strict module boundaries, we get the best of both worlds: the discoverability and refactoring power of a monolith, and the deployment and ownership isolation of microservices.
This guide explains how the repository is organized, why each boundary exists, and β most importantly β what should never cross those boundaries.
High-Level Structure
Section titled βHigh-Level StructureβThe repository is divided into two top-level directories: apps/ for deployable services, and libs/ for shared infrastructure.
.βββ apps/ # Deployable services (Bounded Contexts)β βββ auth/ # Authentication serviceβ βββ iam/ # Identity & Access Managementβ βββ data-owner-bc/ # Example: Data Owner Bounded Contextβ βββ data-consumer-bc/ # Example: Data Consumer Bounded Contextβ βββ admin-bc/ # System administration Bounded Contextβ βββ master-data/ # Reference / master data serviceβ βββ storage/ # Object storage service (MinIO)β βββ docs-gateway/ # Aggregated API documentation gatewayββββ libs/ # Shared libraries (infrastructure only) βββ common/ # Guards, decorators, utilities βββ config/ # Environment configuration module βββ database/ # Database connection moduleThe core rule:
apps/holds business logic.libs/holds infrastructure. If you find domain-specific code inlibs/, it belongs in an app.
Standard Service Structure
Section titled βStandard Service StructureβEvery service β from auth to the smallest bounded context β follows this identical layout:
apps/auth/βββ docs/ # Service-specific documentationβ βββ auth.mdβββ jest.config.js # Jest configurationβββ src/β βββ auth.controller.ts # HTTP & TCP controllersβ βββ auth.module.ts # NestJS module definitionβ βββ auth.service.ts # Business logicβ βββ dto/β β βββ login.dto.tsβ βββ main.ts # Bootstrap entry pointβββ test/β βββ e2e/β β βββ auth.e2e-spec.tsβ βββ jest-e2e.jsonβ βββ mocks/β β βββ mock-auth.tsβ βββ unit/β βββ auth.controller.spec.tsβββ tsconfig.app.jsonThis standardization means any engineer can navigate any service without a map.
Bounded Context Structure
Section titled βBounded Context StructureβBounded contexts with multiple domain modules (most production services) extend the standard layout with a modules/ directory:
apps/data-owner-bc/βββ docs/β βββ overview.mdβ βββ checklist.mdβββ src/β βββ data-owner-bc.module.ts # Root moduleβ βββ main.tsβ βββ modules/ # Domain modulesβ β βββ resource/ # "Resource" domain (e.g. Patient, Order)β β β βββ controllers/β β β β βββ resources.controller.tsβ β β βββ services/β β β β βββ resources.service.tsβ β β βββ dto/β β β β βββ create-resource.dto.tsβ β β β βββ update-resource.dto.tsβ β β βββ entities/β β β β βββ resource.entity.tsβ β β βββ enum/β β β βββ interface/β β β βββ resource.module.tsβ β ββ β βββ sub-resource/ # Related domain moduleβ β βββ controllers/β β βββ services/β β βββ dto/β β βββ entities/β β βββ sub-resource.module.tsβ ββ βββ typeorm-cli.tsββββ test/ βββ e2e/ βββ jest-e2e.json βββ mocks/ βββ unit/Shared Library Structure
Section titled βShared Library Structureβlibs/ contains only cross-cutting technical infrastructure β never domain logic:
libs/βββ common/β βββ src/β βββ common.module.tsβ βββ decorators/β β βββ current-user.decorator.tsβ β βββ public.decorator.tsβ β βββ resource-type.decorator.tsβ βββ dto/β β βββ query-params.dto.ts # Generic pagination/filter DTOβ βββ enum/β β βββ app-microservice.enum.ts # Service identifiers & message commandsβ β βββ app-databases.enum.ts # Database connection identifiersβ βββ guards/β β βββ auth.guard.tsβ β βββ permissions.guard.tsβ βββ interfaces/β βββ middlewares/β βββ modules/β β βββ redis/β β βββ logs/β βββ pipes/β βββ services/β β βββ microservice-client.service.tsβ βββ utils/β βββ bootstrap.util.tsβ βββ http-exception/β βββ http-success/ββββ config/β βββ src/β βββ config.module.ts # Env validation with Joiβ βββ config.service.tsββββ database/ βββ src/ βββ database.module.ts # Primary/replica replication setup βββ database.service.ts βββ migrations/The Data Owner / Data Consumer Pattern
Section titled βThe Data Owner / Data Consumer PatternβThis is the most critical architectural decision in the codebase. Understanding it is non-negotiable.
Why Canβt the Consumer Access the Ownerβs Database?
Section titled βWhy Canβt the Consumer Access the Ownerβs Database?βImagine data-consumer-bc needs resource data owned by data-owner-bc. The naive solution is to give it direct database access. The correct solution is to force it through an API call. Hereβs why:
-
Single Source of Truth:
data-owner-bcis the sole authority over its domain data. If consumers could modify the same tables, data integrity becomes impossible to guarantee. -
Encapsulation: A bounded contextβs database is its internal implementation detail β like a private method in a class. Allowing direct access is the distributed systems equivalent of breaking encapsulation.
-
Separation of Concerns: The consumer BCβs job is its own domain logic. The ownerβs job is maintaining its data. Mixing these responsibilities creates tight coupling that makes both services harder to change independently.
How It Works in Practice
Section titled βHow It Works in PracticeβsequenceDiagram
participant Consumer as data-consumer-bc
participant Client as Microservice Client
participant Owner as data-owner-bc Controller
participant Service as data-owner-bc Service
participant DB as Database
Consumer->>Client: Need resource data
Client->>Owner: TCP Message (cmd: GetResourceById)
Owner->>Service: findById(resourceId)
Service->>DB: SELECT * FROM resources
DB-->>Service: Resource row
Service-->>Owner: Resource entity
Owner-->>Client: Resource response
Client-->>Consumer: Typed resource data
The Owner: Exposing Data via MessagePattern
Section titled βThe Owner: Exposing Data via MessagePatternβ@ResourceType('resources')@ApiTags('Resources')@Controller('resources')export class ResourcesController extends BaseControllerOperations< Resource, CreateResourceDTO, UpdateResourceDTO, ResourcesService> { constructor(private readonly resourcesService: ResourcesService) { super(resourcesService); }
// HTTP endpoints β inherited from BaseControllerOperations
// TCP message handler β exposes data to other services @MessagePattern({ cmd: AppMicroservice.DataOwnerBc.cmd.GetResourceById }) async handleGetResourceById(@Payload() resourceId: string): Promise<Resource> { return this.resourcesService.findById(resourceId); }}The Consumer: Requesting Data via TCP Client
Section titled βThe Consumer: Requesting Data via TCP Clientβ@Module({ imports: [ CommonModule, // Provides the pre-registered microservice client DatabaseModule.registerAsync(AppDatabases.APP_CORE), TypeOrmModule.forFeature([Appointment], AppDatabases.APP_CORE), ], controllers: [AppointmentController], providers: [AppointmentService],})export class AppointmentModule {}@Injectable()export class AppointmentService extends BaseServiceOperations< Appointment, CreateAppointmentDTO, UpdateAppointmentDTO> { constructor( logger: LogsService, private readonly configService: ConfigService, @InjectRepository(Appointment, AppDatabases.APP_CORE) private readonly appointmentRepository: Repository<Appointment>, @Inject(AppMicroservice.DataOwnerBc.name) private readonly ownerBcClient: ClientProxy, private readonly microserviceClient: MicroserviceClientService, ) { super(appointmentRepository, { logging: { logger, serviceName: configService.get('CONSUMER_BC_SERVICE_NAME'), serviceVersion: configService.get('CONSUMER_BC_SERVICE_VERSION'), }, }); }
async createAppointmentForResource( resourceId: string, dto: CreateAppointmentDTO, ): Promise<Appointment> { // Fetch resource data from the owner BC via TCP const resource = await this.microserviceClient.sendWithContext<IResourceData>( this.logger, this.ownerBcClient, { cmd: AppMicroservice.DataOwnerBc.cmd.GetResourceById }, { id: resourceId }, null, );
if (!resource) { throw new NotFoundException('Resource not found'); }
const appointment = this.appointmentRepository.create({ ...dto, resource_id: resource.id, resource_reference: resource.reference_number, });
return this.appointmentRepository.save(appointment); }}CommonModule: Centralizing Microservice Client Registration
Section titled βCommonModule: Centralizing Microservice Client RegistrationβAll TCP client registrations live in CommonModule, making them available globally via injection:
@Global()@Module({ imports: [ ConfigModule, RedisModule, JwtModule, ClientsModule.registerAsync([ { name: AppMicroservice.Auth.name, useFactory: (config: ConfigService) => ({ transport: Transport.TCP, options: { host: 'localhost', port: config.get<number>('AUTH_MICROSERVICE_PORT', 5000), }, }), inject: [ConfigService], }, { name: AppMicroservice.DataOwnerBc.name, useFactory: (config: ConfigService) => ({ transport: Transport.TCP, options: { host: 'localhost', port: config.get<number>('OWNER_BC_MICROSERVICE_PORT', 4000), }, }), inject: [ConfigService], }, // ... other services ]), ], providers: [AuthGuard, PermissionsGuard], exports: [AuthGuard, PermissionsGuard, ClientsModule, RedisModule, ConfigModule],})export class CommonModule {}What Belongs in libs/ vs. apps/
Section titled βWhat Belongs in libs/ vs. apps/βThis question comes up constantly. The answer is always the same: ask whether itβs infrastructure or business logic.
β
Belongs in libs/
Section titled ββ
Belongs in libs/βThese are technical cross-cutting concerns with no domain knowledge:
| Category | Examples |
|---|---|
| Auth Guards | AuthGuard, PermissionsGuard |
| Decorators | @Public(), @CurrentUser(), @ResourceType() |
| Interceptors | TransformInterceptor (response formatting) |
| Exception Filters | AllExceptionsFilter |
| Infrastructure Modules | DatabaseModule, ConfigModule, RedisModule |
| Utility Functions | bootstrapApplication(), formatters |
| Generic DTOs | QueryParamsDTO, FileUploadDTO |
| System Enums | AppMicroservice, AppDatabases |
| Shared Services | MicroserviceClientService, LogsService |
β Stays in apps/
Section titled ββ Stays in apps/βThese carry domain knowledge and must stay local to their bounded context:
| Category | Why |
|---|---|
| Entities | Tied to a specific database and BC ownership β never share |
| Domain DTOs | Each BC defines its own API contract |
| Domain Enums | Represent business rules owned by a specific BC |
| Business Services | Domain logic belongs to the owning BC |
| Domain Interfaces | Shape of data specific to one BCβs needs |
The Rule of Second Use
Section titled βThe Rule of Second Useβ1. Always start local β create everything in the owning BC first.
2. Only promote to libs/ when: - It is purely technical infrastructure (no business logic) - It is actively used by 2+ services - It has zero domain coupling
3. For cross-service data β never share entities. Use a local interface typed to the data you need:
// data-consumer-bc: local interface for data received from owner interface IResourceData { id: string; reference_number: string; first_name: string; last_name: string; }
// Consumed via TCP call β never via a shared entity file const resource = await this.microserviceClient .sendWithContext<IResourceData>(...);Required Directory Checklist
Section titled βRequired Directory ChecklistβEvery service that is added to apps/ must have:
apps/<service-name>/βββ docs/ # β
Required β service documentationβββ src/ # β
Required β source codeβββ test/β βββ e2e/ # β
Required β end-to-end testsβ βββ unit/ # Optional but strongly encouragedβββ jest.config.js # β
Required β Jest configurationAnd every domain module within a bounded context must have:
src/modules/<domain>/βββ controllers/ # β
Requiredβββ services/ # β
Requiredβββ dto/ # β
Requiredβββ entities/ # β
Requiredβββ enum/ # Optionalβββ interface/ # Optionalβββ <domain>.module.ts # β
RequiredSummary
Section titled βSummaryβThe project structure enforces five guarantees:
- Clear ownership: Every piece of data has exactly one authoritative service
- Enforced boundaries: Cross-service access is always explicit β via TCP, never via shared repositories
- Predictable layout: Any engineer can find any file without asking
- Safe sharing:
libs/contains only code that is genuinely infrastructure - Independent deployability: Each bounded context can be scaled, deployed, or replaced without touching others