When an application is split into layers — core/ with the business logic and adapters/ with the infrastructure — a question arises: who puts them together? Who says "this port is served by this adapter"? That is done by the Composition Root — a dedicated assembly point that knows about all the layers at once and connects them at startup. In NestJS this is the app/ folder.
What a Composition Root is
Picture a construction kit: the individual parts sit in boxes, and assembly happens strictly in one place — at the table with the instructions. In hexagonal architecture the "table" is the Composition Root.
Without a dedicated place for assembly, each layer starts to "know" about the others: core/ imports adapters, adapters drag AppModule along — the boundary blurs. The Composition Root solves this: it alone depends on everything, while everyone else depends only on their own layer.
What lives in app/
The app/ folder is the entry point and nothing else. Its contents are strictly limited:
src/app/
main.ts # application launch
app.module.ts # root module
config/
config.schema.ts # environment variable schema
config.module.ts # config wiring
order.module.ts # wiring of the Order domain
product.module.ts # wiring of the Product domain
Dockerfile
docker-compose.yml
Business logic and controllers do not go here. The controller lives in adapters/in/http/, the logic in core/<bc>/usecases/. If a @Controller shows up in app/, that is a signal that something has gone wrong.
main.ts: the launch point
main.ts is the only place where the application is created:
// src/app/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks();
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
NestFactory.create and enableShutdownHooks are called only here. Moving application creation into core/ or into an adapter would break layer isolation. A service has a single entry point.
AppModule assembles the feature modules
AppModule knows nothing about the details of the business logic — it simply assembles the feature modules:
// src/app/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from './config/config.module';
import { OrderModule } from './order.module';
import { ProductModule } from './product.module';
@Module({
imports: [
ConfigModule,
OrderModule,
ProductModule,
],
})
export class AppModule {}
Each feature module is responsible for the wiring of one domain: it binds the ports from core/<bc>/port/out/ to concrete adapters.
Wiring ports through Symbol tokens
This is the central mechanic of assembly. In TypeScript, interfaces do not exist at runtime — they are erased during compilation. So the NestJS DI container cannot "find" an interface by its name.
The solution: each outbound port carries a Symbol token alongside it — a unique key for the DI container:
// core/order/port/out/order-repository.ts
export const ORDER_REPOSITORY = Symbol('OrderRepository');
export interface OrderRepository {
findById(id: OrderId): Promise<Order | null>;
findByIdForUpdate(id: OrderId): Promise<Order>;
save(order: Order): Promise<void>;
}
The ORDER_REPOSITORY token is the "name" by which NestJS finds the right implementation. The feature module binds the token to a concrete adapter:
// src/app/order.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ORDER_REPOSITORY } from '../../core/order/port/out/order-repository';
import { TX_RUNNER } from '../../core/shared/port/out/transaction-runner';
import { CLOCK } from '../../core/shared/port/out/clock';
import { PgOrderRepository } from '../../adapters/out/persistence/pg-order.repository';
import { PgTransactionRunner } from '../../adapters/out/persistence/pg-transaction.runner';
import { SystemClock } from '../../adapters/out/clock/system.clock';
import { CreateOrderHandler } from '../../core/order/usecases/create-order.handler';
import { OrderEntity } from '../../adapters/out/persistence/order.entity';
@Module({
imports: [TypeOrmModule.forFeature([OrderEntity])],
providers: [
{ provide: ORDER_REPOSITORY, useClass: PgOrderRepository },
{ provide: TX_RUNNER, useClass: PgTransactionRunner },
{ provide: CLOCK, useClass: SystemClock },
{
provide: CreateOrderHandler,
useFactory: (orders, tx, clock) => new CreateOrderHandler(orders, tx, clock),
inject: [ORDER_REPOSITORY, TX_RUNNER, CLOCK],
},
],
exports: [CreateOrderHandler],
})
export class OrderModule {}
Note: CreateOrderHandler is a plain class, without @Injectable. Its dependencies are passed explicitly by useFactory. That is how core/ stays independent of NestJS.
An important detail: if a token is declared in core/ but there is no corresponding provider in the feature module, NestJS throws an exception at application startup, not on the first request. The configuration error is caught right at npm run start, not an hour after deployment.
Typed config
Instead of process.env.DB_HOST directly — a single schema with validation at startup:
// src/app/config/config.schema.ts
import * as Joi from 'joi';
export const configSchema = Joi.object({
PORT: Joi.number().default(3000),
DB_HOST: Joi.string().required(),
DB_PORT: Joi.number().default(5432),
DB_NAME: Joi.string().required(),
DB_USER: Joi.string().required(),
DB_PASSWORD: Joi.string().required(),
});
// src/app/config/config.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule as NestConfigModule } from '@nestjs/config';
import { configSchema } from './config.schema';
@Module({
imports: [
NestConfigModule.forRoot({
isGlobal: true,
validationSchema: configSchema,
validationOptions: { abortEarly: false },
}),
],
exports: [NestConfigModule],
})
export class ConfigModule {}
abortEarly: false — NestJS collects all validation errors and reports them at once, instead of stopping at the first missing variable. If a deployment forgot DB_PASSWORD and SBER_API_KEY, you will see both errors at once.
Adapters read config through ConfigService, not directly from the environment — this confines reading variables to a single level.
Graceful shutdown
enableShutdownHooks() in main.ts is not just a nicety. When stopping a pod, Kubernetes first sends SIGTERM, and only 30 seconds later SIGKILL. In that interval NestJS calls onApplicationShutdown on all providers.
An adapter that needs to finish its work — a TypeORM connection or a Kafka consumer — implements this interface:
// adapters/out/kafka/product-event.publisher.ts
import { OnApplicationShutdown } from '@nestjs/common';
export class ProductEventPublisher implements OnApplicationShutdown {
async onApplicationShutdown(_signal: string) {
await this.producer.disconnect();
}
}
Without enableShutdownHooks() the pod is killed hard: in-flight requests are cut off, Kafka offsets are not committed.
The rule "nobody depends on app/"
This is the central constraint of the Composition Root: core/ and adapters/ know nothing about app/. Only app/ knows about them — not the other way around.
In projects with dependency-cruiser this is verified automatically:
// .dependency-cruiser.cjs
{ name: 'nobody-depends-on-app', severity: 'error',
from: { path: '^src/(core|adapters)' },
to: { path: '^src/app' } },
If someone imported AppModule or the config schema from core/, CI fails. This guards against the gradual blurring of boundaries.
Common mistakes
Business logic in app/. It seems convenient to write a small handler right in AppModule. Then it gets called from other places — and the boundary is erased. Logic goes into core/<bc>/usecases/.
@Injectable on a handler in core/. This makes core/ dependent on NestJS. Handlers are plain classes; their dependencies are passed by useFactory in the feature module.
process.env directly in an adapter. Environment variables are read only by ConfigService through the typed schema. Direct reads scatter the config "entry points" all over the code.
enableShutdownHooks() not called. The application terminates hard. Under high load this means data loss.
In short
app/is the single assembly point. It holdsmain.ts,AppModule, config, and feature modules. Nothing more.- TypeScript interfaces are erased at runtime, so ports carry Symbol tokens — keys for the NestJS DI container.
- An unbound token is caught at startup, not on the first request — a configuration error is visible immediately.
- Handlers in
core/are plain classes without@Injectable. Their dependencies are passed byuseFactoryin the feature module. - Config is read through
ConfigServicewith a Joi schema,abortEarly: false— all missing variables are shown at once. enableShutdownHooks()gives adapters time to finish their work cleanly when the container stops.core/andadapters/do not import fromapp/— this violation is checked by dependency-cruiser in CI.
What to read next
- Core layer in Hexagonal Architecture: Node/NestJS — what is allowed in
core/, why plain classes instead of@Injectable. - Ports and Symbol tokens — port interfaces, domain types in signatures.
- Adapters: inbound — controller, mapper DTO → command.
- Adapters: outbound — binding the adapter to a token, mapper domain ↔ DTO.