Skip to content

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.

The repository is divided into two top-level directories: apps/ for deployable services, and libs/ for shared infrastructure.

Terminal window
.
β”œβ”€β”€ 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 module

The core rule: apps/ holds business logic. libs/ holds infrastructure. If you find domain-specific code in libs/, it belongs in an app.


Every service β€” from auth to the smallest bounded context β€” follows this identical layout:

Terminal window
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.json

This standardization means any engineer can navigate any service without a map.


Bounded contexts with multiple domain modules (most production services) extend the standard layout with a modules/ directory:

Terminal window
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/

libs/ contains only cross-cutting technical infrastructure β€” never domain logic:

Terminal window
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/

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:

  1. Single Source of Truth: data-owner-bc is the sole authority over its domain data. If consumers could modify the same tables, data integrity becomes impossible to guarantee.

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

  3. 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.

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
apps/data-owner-bc/src/modules/resource/controllers/resources.controller.ts
@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);
}
}
apps/data-consumer-bc/src/modules/appointment/appointment.module.ts
@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 {}
apps/data-consumer-bc/src/modules/appointment/services/appointment.service.ts
@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:

libs/common/src/common.module.ts
@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 {}

This question comes up constantly. The answer is always the same: ask whether it’s infrastructure or business logic.

These are technical cross-cutting concerns with no domain knowledge:

CategoryExamples
Auth GuardsAuthGuard, PermissionsGuard
Decorators@Public(), @CurrentUser(), @ResourceType()
InterceptorsTransformInterceptor (response formatting)
Exception FiltersAllExceptionsFilter
Infrastructure ModulesDatabaseModule, ConfigModule, RedisModule
Utility FunctionsbootstrapApplication(), formatters
Generic DTOsQueryParamsDTO, FileUploadDTO
System EnumsAppMicroservice, AppDatabases
Shared ServicesMicroserviceClientService, LogsService

These carry domain knowledge and must stay local to their bounded context:

CategoryWhy
EntitiesTied to a specific database and BC ownership β€” never share
Domain DTOsEach BC defines its own API contract
Domain EnumsRepresent business rules owned by a specific BC
Business ServicesDomain logic belongs to the owning BC
Domain InterfacesShape of data specific to one BC’s needs
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>(...);

Every service that is added to apps/ must have:

Terminal window
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 configuration

And every domain module within a bounded context must have:

Terminal window
src/modules/<domain>/
β”œβ”€β”€ controllers/ # βœ… Required
β”œβ”€β”€ services/ # βœ… Required
β”œβ”€β”€ dto/ # βœ… Required
β”œβ”€β”€ entities/ # βœ… Required
β”œβ”€β”€ enum/ # Optional
β”œβ”€β”€ interface/ # Optional
└── <domain>.module.ts # βœ… Required

The project structure enforces five guarantees:

  1. Clear ownership: Every piece of data has exactly one authoritative service
  2. Enforced boundaries: Cross-service access is always explicit β€” via TCP, never via shared repositories
  3. Predictable layout: Any engineer can find any file without asking
  4. Safe sharing: libs/ contains only code that is genuinely infrastructure
  5. Independent deployability: Each bounded context can be scaled, deployed, or replaced without touching others