Why I stopped using NestJS for DDD and what I use instead
nestjsdddhexagonal-architecturetypescriptarchitecture 12 min read

Why I stopped using NestJS for DDD and what I use instead

NestJS is productive, but its defaults can quietly turn a DDD codebase into framework-driven application code. Here is the boundary I use now: plain TypeScript in the core, NestJS at the edges.

I did not stop using NestJS because it is bad.

I stopped using NestJS as the center of my architecture.

That distinction matters. NestJS is a solid framework for HTTP APIs, dependency injection, validation pipes, guards, modules, and operational structure. The problem starts when the framework becomes the place where the domain model, use cases, persistence model, and delivery mechanism all meet.

At that point, the project may still use DDD vocabulary, but the architecture is not driven by the domain anymore. It is driven by NestJS.

I have seen this pattern on real TypeScript backends: authentication platforms, internal business tools, B2B workflows, and domain-heavy services where the team wanted Clean Architecture or hexagonal architecture, but slowly ended up with framework-shaped business logic.

The symptoms are familiar:

  • Use cases are decorated with @Injectable().
  • Domain objects import @nestjs/common, TypeORM, Prisma, or class-validator.
  • Tests need Test.createTestingModule() for simple business behavior.
  • Repository interfaces are defined by infrastructure instead of the application layer.
  • HTTP DTOs leak into use cases.
  • The service layer becomes a mix of validation, orchestration, persistence, event publishing, and presentation mapping.

None of this looks catastrophic in the first sprint. It becomes expensive after six months, when the domain starts changing faster than the framework shell around it.

This is the approach I use now:

Plain TypeScript for domain and application code. NestJS only for adapters, infrastructure, and composition.

NestJS still has a place. It just does not own the core.

The trap is not NestJS, it is convenience

NestJS makes the first version of a feature feel clean:

@Injectable()
export class CreateOrderService {
  constructor(
    @InjectRepository(OrderEntity)
    private readonly orders: Repository<OrderEntity>,
    private readonly eventEmitter: EventEmitter2,
  ) {}

  async execute(dto: CreateOrderDto): Promise<OrderEntity> {
    const order = this.orders.create({
      customerId: dto.customerId,
      amount: dto.amount,
    });

    await this.orders.save(order);
    this.eventEmitter.emit("order.created", order);

    return order;
  }
}

This is not terrible code. It is normal NestJS code.

The problem is that it mixes four different decisions:

  • How the feature is called: HTTP DTO.
  • How the business object is represented: OrderEntity.
  • How the object is persisted: TypeORM repository.
  • How side effects are published: EventEmitter2.

That coupling is convenient until one part changes.

If you want to call the same use case from a queue consumer, you now need to adapt around an HTTP-shaped DTO. If you want to change persistence, your “domain” object changes. If you want to test a business rule, you often end up bootstrapping a NestJS testing module.

The framework did not force this design. It simply made it the path of least resistance.

The test that reveals the architecture

Here is the question I use when reviewing a NestJS DDD codebase:

Can I instantiate a use case with new, pass fake dependencies directly, run it, and assert the result without importing NestJS?

If the answer is yes, the application boundary is real.

If the answer is no, the framework has leaked into the core.

This is the shape I want:

const repository = new InMemoryOrderRepository();
const eventBus = new FakeEventBus();
const useCase = new CreateOrderUseCase(repository, eventBus);

const result = await useCase.execute({
  customerId: CustomerId.fromString("customer_123"),
  amount: Money.eur(120),
});

expect(result.ok).toBe(true);
expect(eventBus.published).toHaveLength(1);

No NestJS module. No testing container. No decorators. No database. No HTTP DTO.

The test talks to application code directly.

When that is possible, the framework is an implementation detail. When it is not possible, the framework is part of the architecture.

What I keep out of the core

In DDD projects, I keep these things out of the domain and application layers:

  • @Injectable(), @Module(), @Controller()
  • HttpException, BadRequestException, NotFoundException
  • TypeORM decorators such as @Entity() and @Column()
  • Prisma generated types in use case signatures
  • HTTP DTOs and request objects
  • Kafka, RabbitMQ, BullMQ, or EventEmitter classes
  • framework validation decorators
  • environment variables and config services

That may sound strict, but the rule is simple:

If the code represents business behavior or application orchestration, it should not know which framework runs it.

This keeps the direction of dependencies honest.

domain
  depends on nothing external

application
  depends on domain and port interfaces

infrastructure
  depends on application, domain, frameworks, databases, queues

adapters
  translate HTTP, CLI, queue messages, cron jobs into application commands

The domain is not a NestJS module. The application layer is not a NestJS module. NestJS is the runtime shell.

Domain: plain TypeScript

I want domain code to be boring to instantiate.

// src/domain/order/order.ts
export class Order {
  private readonly events: DomainEvent[] = [];

  private constructor(
    public readonly id: OrderId,
    public readonly customerId: CustomerId,
    private total: Money,
  ) {}

  static create(params: {
    id: OrderId;
    customerId: CustomerId;
    total: Money;
  }): Result<Order, OrderError> {
    if (params.total.isZeroOrNegative()) {
      return err(new InvalidOrderTotal());
    }

    const order = new Order(params.id, params.customerId, params.total);
    order.events.push(new OrderCreated(order.id, order.customerId));

    return ok(order);
  }

  changeTotal(total: Money): Result<void, OrderError> {
    if (total.isZeroOrNegative()) {
      return err(new InvalidOrderTotal());
    }

    this.total = total;
    this.events.push(new OrderTotalChanged(this.id, total));

    return ok(undefined);
  }

  pullEvents(): DomainEvent[] {
    const pulled = [...this.events];
    this.events.length = 0;
    return pulled;
  }
}

There are no decorators here. No persistence annotations. No validation library. No NestJS imports.

The model enforces invariants. It records domain events. It exposes behavior in the language of the business.

This is the point of DDD: the important code should describe the business, not the framework.

Application: use cases as plain orchestrators

The application layer coordinates work. It does not contain HTTP concerns, ORM concerns, or framework wiring.

// src/application/orders/create-order.use-case.ts
export class CreateOrderUseCase {
  constructor(
    private readonly orders: OrderRepository,
    private readonly ids: IdGenerator,
    private readonly eventBus: EventBus,
  ) {}

  async execute(command: CreateOrderCommand): Promise<Result<OrderId, OrderError>> {
    const id = this.ids.nextOrderId();

    const created = Order.create({
      id,
      customerId: command.customerId,
      total: command.total,
    });

    if (!created.ok) {
      return created;
    }

    await this.orders.save(created.value);
    await this.eventBus.publish(created.value.pullEvents());

    return ok(id);
  }
}

The use case knows about:

  • the command it receives
  • domain objects
  • ports it needs
  • the result it returns

It does not know about:

  • NestJS
  • HTTP
  • TypeORM
  • Prisma
  • Kafka
  • controllers
  • environment variables

This makes the use case portable. It can be called from an HTTP controller, a queue consumer, a CLI script, a cron job, or a test.

Ports belong to the application

This is one of the most common mistakes I see: repository interfaces are placed next to the database implementation.

That reverses ownership.

The application layer should define what it needs:

// src/application/orders/order.repository.ts
export interface OrderRepository {
  save(order: Order): Promise<void>;
  findById(id: OrderId): Promise<Order | null>;
}
// src/application/shared/event-bus.ts
export interface EventBus {
  publish(events: DomainEvent[]): Promise<void>;
}

Infrastructure implements those contracts.

That difference is not cosmetic. If the repository interface is shaped by TypeORM or Prisma, your use cases inherit persistence concerns. If the application owns the interface, persistence adapts to the use case.

That is the whole point of ports and adapters.

Infrastructure: NestJS adapters implement ports

This is where @Injectable() belongs.

// src/infrastructure/persistence/prisma-order.repository.ts
@Injectable()
export class PrismaOrderRepository implements OrderRepository {
  constructor(private readonly prisma: PrismaService) {}

  async save(order: Order): Promise<void> {
    const record = OrderPersistenceMapper.toRecord(order);

    await this.prisma.order.upsert({
      where: { id: record.id },
      create: record,
      update: record,
    });
  }

  async findById(id: OrderId): Promise<Order | null> {
    const record = await this.prisma.order.findUnique({
      where: { id: id.value },
    });

    return record ? OrderPersistenceMapper.toDomain(record) : null;
  }
}

The adapter imports Prisma and NestJS. That is fine. It is infrastructure.

The important part is that the dependency points inward:

PrismaOrderRepository -> OrderRepository -> CreateOrderUseCase -> Order

The domain does not import the adapter. The use case does not import Prisma. NestJS does not define the model.

HTTP: controllers translate, they do not orchestrate

The controller should be a driving adapter. It translates an HTTP request into an application command and translates the application result into an HTTP response.

// src/adapters/http/orders.controller.ts
@Controller("orders")
export class OrdersController {
  constructor(private readonly createOrder: CreateOrderUseCase) {}

  @Post()
  async create(@Body() body: CreateOrderHttpBody): Promise<CreateOrderHttpResponse> {
    const commandResult = CreateOrderHttpMapper.toCommand(body);

    if (!commandResult.ok) {
      throw new BadRequestException(commandResult.error.message);
    }

    const result = await this.createOrder.execute(commandResult.value);

    if (!result.ok) {
      throw OrderHttpMapper.toException(result.error);
    }

    return { id: result.value.value };
  }
}

This controller is allowed to use NestJS exceptions and DTOs because it is an HTTP adapter.

What I avoid is passing CreateOrderHttpBody into the use case. The use case should receive an application command, not a transport object.

Wiring: NestJS composes the graph

The NestJS module is composition code.

// src/infrastructure/nest/orders.module.ts
@Module({
  controllers: [OrdersController],
  providers: [
    PrismaService,
    PrismaOrderRepository,
    KafkaEventBus,
    UuidGenerator,
    {
      provide: CreateOrderUseCase,
      useFactory: (orders: PrismaOrderRepository, ids: UuidGenerator, eventBus: KafkaEventBus) =>
        new CreateOrderUseCase(orders, ids, eventBus),
      inject: [PrismaOrderRepository, UuidGenerator, KafkaEventBus],
    },
  ],
})
export class OrdersModule {}

This is the right place for framework wiring.

You can also use custom provider tokens if your team prefers explicit interfaces at the DI level:

export const ORDER_REPOSITORY = Symbol("ORDER_REPOSITORY");

@Module({
  providers: [
    { provide: ORDER_REPOSITORY, useClass: PrismaOrderRepository },
    {
      provide: CreateOrderUseCase,
      useFactory: (orders: OrderRepository, eventBus: EventBus) =>
        new CreateOrderUseCase(orders, new UuidGenerator(), eventBus),
      inject: [ORDER_REPOSITORY, EVENT_BUS],
    },
  ],
})
export class OrdersModule {}

Both styles are acceptable. The key is that CreateOrderUseCase remains a plain TypeScript class.

What about validation?

NestJS validation pipes are useful for transport validation:

  • Is the JSON body structurally valid?
  • Is a required HTTP field missing?
  • Is a query parameter malformed?

Domain validation is different:

  • Is this order total allowed?
  • Can this customer perform this action?
  • Is this transition valid from the current aggregate state?

I keep those separate.

export class Money {
  private constructor(
    public readonly amount: number,
    public readonly currency: Currency,
  ) {}

  static create(amount: number, currency: Currency): Result<Money, MoneyError> {
    if (!Number.isInteger(amount)) {
      return err(new MoneyMustUseMinorUnits());
    }

    if (amount < 0) {
      return err(new MoneyCannotBeNegative());
    }

    return ok(new Money(amount, currency));
  }
}

The HTTP layer may reject an invalid JSON shape. The domain still protects itself.

That duplication is not waste. It is two different boundaries doing two different jobs.

What about transactions?

Transactions are application concerns, but transaction mechanisms are infrastructure concerns.

I usually model this with a port:

export interface TransactionManager {
  run<T>(work: () => Promise<T>): Promise<T>;
}

Then the use case can depend on the abstraction:

export class ConfirmOrderUseCase {
  constructor(
    private readonly transactions: TransactionManager,
    private readonly orders: OrderRepository,
    private readonly eventBus: EventBus,
  ) {}

  async execute(command: ConfirmOrderCommand): Promise<Result<void, OrderError>> {
    return this.transactions.run(async () => {
      const order = await this.orders.findById(command.orderId);

      if (!order) {
        return err(new OrderNotFound());
      }

      const confirmed = order.confirm();

      if (!confirmed.ok) {
        return confirmed;
      }

      await this.orders.save(order);
      await this.eventBus.publish(order.pullEvents());

      return ok(undefined);
    });
  }
}

The Prisma or TypeORM transaction implementation stays outside:

@Injectable()
export class PrismaTransactionManager implements TransactionManager {
  constructor(private readonly prisma: PrismaService) {}

  run<T>(work: () => Promise<T>): Promise<T> {
    return this.prisma.$transaction(() => work());
  }
}

In real systems, transaction context propagation can require more machinery: AsyncLocalStorage, request-scoped providers, or explicit unit-of-work objects. The principle is the same: the use case asks for transactional behavior without coupling itself to the database client.

How to migrate an existing NestJS codebase

You do not need to rewrite the application.

Start with one feature. Pick something small but meaningful, not a trivial CRUD endpoint.

Step 1: extract the use case

Move orchestration out of the NestJS service into a plain class:

export class RegisterCustomerUseCase {
  constructor(private readonly customers: CustomerRepository) {}

  async execute(command: RegisterCustomerCommand): Promise<Result<CustomerId, CustomerError>> {
    // business orchestration
  }
}

Keep the existing NestJS service if needed, but make it a wrapper temporarily.

Step 2: define the port from the use case

Do not start by designing a generic repository. Start from what the use case actually needs:

export interface CustomerRepository {
  save(customer: Customer): Promise<void>;
  findByEmail(email: Email): Promise<Customer | null>;
}

That interface belongs near the use case, not near the ORM.

Step 3: map persistence explicitly

Keep ORM entities or Prisma records separate from the domain model:

export class CustomerMapper {
  static toDomain(record: CustomerRecord): Result<Customer, CustomerError> {
    return Customer.rehydrate({
      id: CustomerId.fromString(record.id),
      email: Email.fromString(record.email),
      status: CustomerStatus.fromString(record.status),
    });
  }

  static toRecord(customer: Customer): CustomerRecord {
    return {
      id: customer.id.value,
      email: customer.email.value,
      status: customer.status.value,
    };
  }
}

Yes, mapping code is boring. It is also where you prevent database shape from becoming domain shape.

Step 4: leave NestJS at the boundary

Wire the extracted use case from the NestJS module. Let controllers call it. Keep guards, pipes, interceptors, and framework concerns where they are useful.

You are not fighting NestJS. You are giving it a smaller job.

When I still use normal NestJS services

Not every service needs full DDD boundaries.

For small CRUD modules, admin screens, or short-lived internal tools, a normal NestJS service with Prisma or TypeORM can be the right trade-off. Architecture has a cost. Mapping has a cost. Extra boundaries have a cost.

I reach for the stricter style when:

  • the domain has real invariants
  • the workflow has multiple steps and failure modes
  • persistence is not the interesting part
  • the same use case may be called from multiple transports
  • tests should focus on business behavior, not framework setup
  • the code will live long enough for boundaries to matter

The mistake is not using NestJS services.

The mistake is pretending a framework service full of persistence and HTTP details is a DDD application layer.

The shape I prefer now

On domain-heavy TypeScript backends, I default to this:

src/
  domain/
    order/
      order.ts
      order-id.ts
      order-errors.ts
      events.ts

  application/
    orders/
      create-order.use-case.ts
      create-order.command.ts
      order.repository.ts
    shared/
      event-bus.ts
      transaction-manager.ts

  adapters/
    http/
      orders.controller.ts
      order-http.mapper.ts
      create-order.body.ts

  infrastructure/
    persistence/
      prisma-order.repository.ts
      order-persistence.mapper.ts
    messaging/
      kafka-event-bus.ts
    nest/
      orders.module.ts

The framework is still there. It handles HTTP, dependency injection, modules, configuration, lifecycle, and operational integration.

It just does not define the domain.

That one change has a large effect. Tests become smaller. Use cases become portable. Domain models stop depending on persistence. Framework upgrades are less risky. The architecture becomes visible in the dependency graph, not just in folder names.

NestJS is a good tool. I still use it.

I just do not let it decide what my domain model is.

Mohammed Hamdoune is a Senior Software Architect specializing in TypeScript, DDD, and Clean Architecture. He writes about real-world engineering decisions at sidlynx.me and founded LRJI, a software architecture consulting firm at lrji.co.