← Back to the section

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 holds main.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 by useFactory in the feature module.
  • Config is read through ConfigService with 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/ and adapters/ do not import from app/ — this violation is checked by dependency-cruiser in CI.