DEV Community

Cover image for NestJS hexagonal architecture: where DTOs, domain models, and types belong
amir taherkhani
amir taherkhani

Posted on

NestJS hexagonal architecture: where DTOs, domain models, and types belong

Start with the boundary: who sends this data, who owns its meaning, and who is allowed to change it?

In a NestJS hexagonal architecture, use transport DTOs for HTTP or message contracts, application commands and results for use cases, and domain entities or value objects where business identity and rules matter. Keep database representations inside persistence adapters. You do not need a new class for every function call.

The NestJS Skills documentation covers the broader architecture and object-design guidance. The repository includes the rules and examples behind it.

Version context:

  • NestJS 11.2.7,
  • TypeScript 5.9.3,
  • class-validator 0.15.1,
  • class-transformer 0.5.1.

The Nest links use the v11 documentation because the default documentation may describe a newer major version. Follow your project's conventions first, while checking that its validation and dependency-injection setup actually works at runtime.

Start with the hexagonal boundary

Hexagonal architecture, also called ports and adapters, separates the business core from the technologies around it. The core should be understandable and testable without starting an HTTP server or connecting to a database.

Inside that core, the domain owns business meaning and rules. An application use case coordinates one goal, such as logging in: load the needed state, ask the domain to apply its rules, and call the required external capabilities. Separating these roles does not require a large class hierarchy.

Ports define the contracts for those interactions. An incoming port exposes an operation such as Login.execute. An outgoing port describes a capability the application needs, such as UserRepository.findByEmail. The contract describes expected results and failures as well as the method signature. You do not need an interface for every helper.

Adapters connect those contracts to technology:

  1. An incoming adapter, such as a Nest HTTP controller, receives a request, validates its transport data, and invokes the application operation.

  2. The application coordinates domain behavior and calls outgoing ports when it needs stored data or another external capability.

  3. An outgoing adapter, such as a SQL repository, implements a port and translates persistence records into the promised result.

  4. The incoming adapter maps the application result back to an HTTP response.

Source dependencies point inward: adapters depend on the application's contracts, and the application can depend on the domain. The domain does not import the HTTP controller or SQL adapter.
A runtime call can still travel outward to the database through an injected port implementation.

For the User/Auth example below, an HTTP request reaches the login use case, a repository adapter loads the user, the domain checks account state, and the controller returns a mapped result. A test can call that same use case with an in-memory repository. Another adapter can later implement the same port if it preserves the contract's behavior.

This approach helps when an application has substantial business rules or needs to stay maintainable through years of changes. It also helps when several incoming adapters need the same operations or when you may replace an external provider.
For example, an order-payment use case can serve both an HTTP checkout and a queue consumer while calling a replaceable payment-provider adapter. A small CRUD service with few rules and one stable data source may not justify the extra abstractions.

Isolated tests and adapter replacement become easier, but you have more contracts, wiring, and mapping to maintain. Add those abstractions where they protect a real boundary. The hexagon does not mean six layers, six modules, or one mandatory folder layout. A Dto suffix or a folder named domain does not establish the architecture; the imports, data flow, and ownership do.

A DTO describes a data contract

A DTO carries data between parts of a system. It can describe an HTTP request, an API response, a queue message, or the input and result of an application operation. It does not have to be a decorated class.

An entity has identity and continuity across changes. A value object describes a value and its rules, such as money with a currency or a valid date range. A domain model also includes business behavior and policies. Naming a TypeScript interface User does not, by itself, give it those rules.

An application command such as LoginCommand describes a use case's input. It can be a plain type. Calling it an application DTO is also reasonable; ownership and behavior matter more than the suffix.

Location or boundary Useful default Reason to change it
HTTP, RPC, or incoming message Transport DTO plus runtime validation Use a schema and custom pipe if that fits the project
Application use case Command/result types or small classes Use a class when a runtime constructor or behavior has a purpose
Same-domain collaboration Entities, value objects, or focused values Return a projection when the caller only needs a few fields
Another module or external system Explicit public contract or message DTO Share domain values only when ownership and meaning are deliberately shared
Repository adapter Domain-facing port, persistence-facing records A simple CRUD feature may not benefit from a separate rich domain model

Two Nest modules can still belong to one domain boundary. A folder split alone does not require a DTO conversion. Across independently owned capabilities, however, importing another module's internal entity can make an unrelated change break your code.

Choose a representation for a reason

A TypeScript type or interface works well for an internal contract. It helps the compiler check callers. It disappears from the JavaScript output, so it cannot inspect JSON at runtime.

An ordinary class has a runtime constructor. It can hold behavior or provide a Nest injection token. Its declared field types still do not validate incoming values.

A decorated DTO class supports the familiar ValidationPipe plus class-validator path. A runtime schema is another option: a custom pipe can parse an unknown value and return a typed result. In either approach, the runtime check is the part that establishes trust. JSON.parse(...) as User only tells the compiler what to assume.

Nest's v11 validation guide explains why concrete classes matter for reflected DTO validation. Import a DTO class as a runtime value when Nest needs its metadata. A type-only import cannot provide that constructor.

A plain object is a runtime value and can carry an application command or a mapped response. A type or interface can describe its shape. A normal TypeScript enum also has a runtime representation, but annotating a field with it does not validate JSON. Use a shared enum or string union only when the layers intentionally share the same meanings; map transport values when they differ.

Choose the representation your contract needs. An empty class for every internal parameter adds files without automatically adding protection.

Follow one login request through the layers

This example treats User and Auth as parts of the same identity domain. The application can therefore use the domain User directly. If they were independently owned capabilities, Auth would call a narrow public authentication contract instead of importing Users' internal entity or password hash.

The request first passes a transport validator. The application then loads a user through a repository port, checks the credentials, applies the account-state rule, and requests a session. The controller maps the application result to the response.

1. Keep identity rules in the domain

The domain has no Nest or ORM imports. A disabled account cannot log in, even if its password is correct. The application supplies the password-verification function, so the entity does not import a hashing library.

export class LoginRejected extends Error {}

// Identity and account-state rules belong to this domain model.
export class User {
  constructor(
    readonly id: string,
    readonly email: string,
    private readonly active: boolean,
    private readonly passwordHash: string,
  ) {}

  assertCanLogin(): void {
    if (!this.active) throw new LoginRejected();
  }

  matchesPassword(
    plain: string,
    verify: (plain: string, hash: string) => Promise<boolean>,
  ): Promise<boolean> {
    return verify(plain, this.passwordHash);
  }
}
Enter fullscreen mode Exit fullscreen mode

For a larger identity model, construction and state changes would protect more invariants. This small example assumes the adapter loads a valid persisted user. A richer reconstitution factory can reject invalid stored states when that is a real requirement.

2. Give the application its own input and result

The use case depends on capabilities, not HTTP or SQL details. It returns the information its caller needs, with no domain entity in the result.

import { LoginRejected, User } from './domain';

export interface UserRepository {
  findByEmail(email: string): Promise<User | null>;
}
export interface PasswordVerifier {
  verify(plain: string, hash: string): Promise<boolean>;
}
export interface SessionIssuer {
  issue(userId: string): Promise<string>;
}
export type LoginCommand = Readonly<{ email: string; password: string }>;
export type LoginResult = Readonly<{
  accessToken: string;
  user: Readonly<{ id: string; email: string }>;
}>;

export class Login {
  constructor(
    private readonly users: UserRepository,
    private readonly passwords: PasswordVerifier,
    private readonly sessions: SessionIssuer,
  ) {}

  async execute(command: LoginCommand): Promise<LoginResult> {
    const user = await this.users.findByEmail(command.email);
    if (!user) throw new LoginRejected();
    const valid = await user.matchesPassword(
      command.password,
      (plain, hash) => this.passwords.verify(plain, hash),
    );
    if (!valid) throw new LoginRejected();
    user.assertCanLogin();
    return {
      accessToken: await this.sessions.issue(user.id),
      user: { id: user.id, email: user.email },
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

The input shape currently matches the request DTO. The separate name makes the application contract explicit, without another class. TypeScript is structurally typed, so these names alone do not enforce isolation. Import rules and tests can help keep transport dependencies out of the application.

The plain Login class can be registered with a Nest useFactory provider. Wire its repository, verifier, and session adapters there. Interfaces do not work as runtime injection tokens; use explicit tokens where needed. Nest's custom provider documentation covers these provider forms.

3. Translate persistence records inside the adapter

UserRow stands in for the record an ORM returns. Its column names and status representation belong to persistence. The repository port promises a domain User.

import { UserRepository } from './application';
import { User } from './domain';

// Structural stand-in for the persistence record returned by an ORM.
export type UserRow = {
  id: string;
  email: string;
  status: 'active' | 'disabled';
  password_hash: string;
};
export interface UserTable {
  findByEmail(email: string): Promise<UserRow | null>;
}
export class SqlUserRepository implements UserRepository {
  constructor(private readonly table: UserTable) {}

  async findByEmail(email: string): Promise<User | null> {
    const row = await this.table.findByEmail(email);
    return row
      ? new User(row.id, row.email, row.status === 'active', row.password_hash)
      : null;
  }
}
Enter fullscreen mode Exit fullscreen mode

An ORM entity and a domain entity have different jobs. ORM decorators, lazy relations, database defaults, and change tracking should not become hidden requirements of a framework-free domain model.

For read-only reporting, a query port can return a purpose-built projection instead of rebuilding a full entity. For a small CRUD feature with no meaningful domain behavior, a separate domain class may not be worth maintaining. That still leaves you responsible for controlling the public response.

4. Validate input and map the response explicitly

The request is a decorated class because this route uses Nest's class-based validation. The response is a type because this example constructs its JSON explicitly and does not use reflection-based response serialization.

import 'reflect-metadata';
import { Body, Controller, HttpCode, Post, UnauthorizedException, ValidationPipe } from '@nestjs/common';
import { IsEmail, IsString, MaxLength, MinLength } from 'class-validator';
import { Login, LoginResult } from './application';
import { LoginRejected } from './domain';

export class LoginRequestDto {
  @IsEmail()
  @MaxLength(254)
  email!: string;

  @IsString()
  @MinLength(1)
  @MaxLength(128)
  password!: string;
}
export const inputPipe = new ValidationPipe({
  transform: true,
  whitelist: true,
  forbidNonWhitelisted: true,
});
export type LoginResponseDto = {
  access_token: string;
  user: { id: string; email: string };
};
export function toResponse(result: LoginResult): LoginResponseDto {
  return {
    access_token: result.accessToken,
    user: { id: result.user.id, email: result.user.email },
  };
}
@Controller('auth')
export class AuthController {
  constructor(private readonly login: Login) {}

  @Post('login')
  @HttpCode(200)
  async execute(@Body(inputPipe) body: LoginRequestDto): Promise<LoginResponseDto> {
    try {
      const result = await this.login.execute({
        email: body.email,
        password: body.password,
      });
      return toResponse(result);
    } catch (error) {
      if (error instanceof LoginRejected) throw new UnauthorizedException();
      throw error;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Here, transform returns a DTO instance, whitelist enables checking against validation metadata, and forbidNonWhitelisted rejects extra properties. These options do not replace business rules or authorization. The example's length limits are illustrative input limits; use limits compatible with your real account and password policies.

The mapper names every output field, including the nested user fields. Returning { ...user } or relying on a return-type annotation would not remove extra properties at runtime. TypeScript's private keyword also does not make an emitted property safe to serialize. Use an explicit projection or a correctly configured serialization strategy, then test the actual output.

LoginRejected becomes an HTTP 401 at the transport boundary. The application itself has no HTTP exception dependency. Unexpected errors still propagate for normal error handling and logging.

This is a boundary example, not a complete authentication implementation. The runnable tests use fake password, session, and database adapters. Production authentication also needs a real password verifier and session policy, abuse controls, and review of account-enumeration and timing behavior. The early missing-user return shown here does not equalize password-verification timing. Never log the command's password or the returned token.

Handle nested DTOs deliberately

A nested TypeScript annotation does not tell a runtime validator how to build or inspect the child object. With this class-based setup, declare the nested validation and transformation together:

import 'reflect-metadata';
import { Type } from 'class-transformer';
import { IsDefined, IsString, MinLength, ValidateNested } from 'class-validator';

export class AddressDto {
  @IsString()
  @MinLength(1)
  city!: string;
}
export class ProfileRequestDto {
  @IsDefined()
  @ValidateNested()
  @Type(() => AddressDto)
  address!: AddressDto;
}
export const toProfileCommand = (dto: ProfileRequestDto) => ({
  address: { city: dto.address.city },
});
Enter fullscreen mode Exit fullscreen mode

@IsDefined() makes the child required. @ValidateNested() checks it, and @Type(() => AddressDto) identifies the nested class for transformation. The class-validator and class-transformer documentation describe those separate responsibilities.

For an array of addresses, also validate that the value is an array and validate each child. When mapping, map each element explicitly if it has a different representation or ownership. A shallow spread leaves nested object references shared.

If an address is a meaningful domain value, the application can construct an Address value object and apply its rules. If it is just a small data shape for this operation, a plain object is enough.

Does User['name'] reuse the name rules?

An indexed-access type lets a function refer to the type of a property. If User.name is a string, User['name'] is also a string. It carries no minimum length, allowed-character rule, or proof that validation ran.

In a separate module, the two function signatures look like this:

// Separate example module: this User has only a static name shape.
export type User = { name: string };
export function displayName(name: User['name']): User['name'] {
  return name;
}
displayName(''); // Compiles. No length or character rule ran.

export class UserName {
  private readonly brand = 'UserName';
  private constructor(readonly value: string) { Object.freeze(this); }

  static parse(input: unknown): UserName {
    // Example product rule, not a universal rule for people's names.
    if (typeof input !== 'string' || !/^[A-Za-z0-9_]{3,30}$/.test(input)) {
      throw new Error('Invalid user name');
    }
    return new UserName(input);
  }
}
export type DomainUser = { readonly name: UserName };
export function renameInput(name: DomainUser['name']): { name: UserName } {
  return { name };
}
const command = renameInput(UserName.parse('amir_1'));
export const nameResponse = { name: command.name.value };
Enter fullscreen mode Exit fullscreen mode

The first function accepts an empty string because the static shape permits it. The second accepts a UserName value constructed through a runtime factory. The private member prevents an ordinary object with only a value property from satisfying this class type; freezing protects this value from mutation at runtime. The example rule is for an account handle; it is intentionally not a rule for real human names.

A branded string plus a runtime parsing factory is another option. The brand gives compile-time separation; the factory enforces the rule. A type assertion can bypass either design's compile-time protection, so casts and external inputs still deserve review. TypeScript readonly and private constructors are compile-time controls, not a security boundary against arbitrary JavaScript mutation.

Once trusted code has constructed a valid value object, it can pass it between internal operations without repeating the same format validation on every call. Rules depending on current state, such as whether an account is still active, may still need checking at the operation that owns them.

Use central types for genuinely shared domain meaning. Avoid a universal User model that owns HTTP fields, database columns, auth secrets, and every module's preferences. An external payload still starts as unknown data: validate and parse it before treating it as a trusted application or domain value.

When can you reuse a domain type in a DTO?

Reuse a small stable value type when its meaning and representation really match. A public string-union status can be shared if both contracts intentionally use the same values. It still needs runtime validation on untrusted input.

Map when the representations differ. A domain Money containing bigint is not automatically JSON-ready. A date string is not a Date, and a raw string does not become a validated identifier because it has a branded TypeScript annotation. Parse those values at an appropriate boundary.

Avoid turning a domain entity into your public schema with Pick<User, ...> just to save a few declarations. That couples the API to an internal model, and a type projection does not strip fields from an object. Never reuse an ORM entity as a public response merely because its properties happen to match today.

Inside one domain, passing an entity or value object between services is often appropriate. Across a public module boundary, expose the operation and result that the consumer needs. For a queue or external API, define a serializable, versioned contract and validate received data, including vendor responses. DTOs are useful at all of these boundaries.

Common mistakes to catch in review

  • Treating type, interface, a class field annotation, or as SomeDto as a runtime validator.
  • Passing an HTTP DTO through every layer until transport decorators become application dependencies.
  • Returning domain or ORM objects directly and assuming a response type hides private fields.
  • Repeating the same shape in a new class at every call, without a different contract or useful runtime behavior.
  • Moving account-state rules into request validation, or assuming internal calls always passed through the HTTP pipe.

Validation, transformation, mapping, and database access all have costs. Measure them with realistic payloads and request rates before choosing a design for speed. Neither "DTOs are slow" nor "mapping is free" is a useful default. Also consider change cost: how many callers must change when the API, business rule, or database representation changes?

How this connects to NestJS Skills

NestJS Skills already separates entities, value objects, application operations, adapters, and DTOs. It also describes consumer-owned ports and behavioral contracts. That guidance already exists. An automated review still needs to establish how the boundaries work in the project it is reading.

The planned improvement is to make those reviews more concrete in actual project code: trace a request across the controller, application, domain, and repository; check where runtime validation happens; flag leaking internal models; and verify the proposed changes with small regression tests. The aim is to help agents create or improve cleaner code without forcing a DTO class into every layer. This improvement is still proposed and has not been implemented in the collection.

For your next review, take one endpoint and follow its input to its output. At each boundary, ask what is trusted, who owns the contract, and which test proves that only the intended data crosses it.

Top comments (0)