← Volver al blog
NestJS Arquitectura Hexagonal DDD CQRS TypeScript neverthrow

Arquitectura Hexagonal en NestJS: guía completa con CQRS y neverthrow

10 de marzo de 2026

La arquitectura hexagonal (también conocida como Ports & Adapters) es uno de los patrones más poderosos para construir sistemas backend mantenibles, testeables y agnósticos al framework. En este artículo construimos un módulo completo desde cero — sin atajos.

El problema que resuelve

En una arquitectura en capas tradicional, la lógica de negocio termina acoplada al framework, a la base de datos o al protocolo HTTP. Cambiar de Express a NestJS, de PostgreSQL a DynamoDB, o de REST a gRPC se convierte en una cirugía mayor.

La arquitectura hexagonal invierte esa dependencia: el dominio no sabe nada del mundo exterior. El mundo exterior se adapta al dominio, no al revés.

┌─────────────────────────────────────────┐
│           INFRAESTRUCTURA               │
│  (HTTP, DB, mensajería, servicios ext.) │
│                                         │
│   ┌─────────────────────────────────┐   │
│   │         APLICACIÓN              │   │
│   │  (casos de uso, CQRS handlers)  │   │
│   │                                 │   │
│   │   ┌─────────────────────────┐   │   │
│   │   │        DOMINIO          │   │   │
│   │   │  (entidades, VOs, reglas│   │   │
│   │   │   de negocio, puertos)  │   │   │
│   │   └─────────────────────────┘   │   │
│   └─────────────────────────────────┘   │
└─────────────────────────────────────────┘

Las dependencias siempre apuntan hacia adentro. El dominio no importa nada de las capas externas.

Estructura del proyecto

Usaremos el registro de usuarios como dominio de ejemplo. La estructura de carpetas refleja las capas:

src/
└── user/
    ├── application/
    │   ├── commands/
    │   │   ├── register-user.command.ts
    │   │   └── register-user.handler.ts
    │   └── queries/
    │       ├── get-user.query.ts
    │       └── get-user.handler.ts
    ├── domain/
    │   ├── entities/
    │   │   └── user.entity.ts
    │   ├── value-objects/
    │   │   ├── user-id.vo.ts
    │   │   ├── email.vo.ts
    │   │   └── user-name.vo.ts
    │   ├── errors/
    │   │   └── user.errors.ts
    │   └── ports/
    │       └── user.repository.ts
    ├── infrastructure/
    │   ├── persistence/
    │   │   ├── typeorm/
    │   │   │   ├── user.model.ts
    │   │   │   └── typeorm-user.repository.ts
    │   │   └── user.mapper.ts
    │   └── http/
    │       ├── user.controller.ts
    │       └── dtos/
    │           └── register-user.dto.ts
    └── user.module.ts

Capa de Dominio

El dominio es el corazón del sistema. No depende de nada externo — ni de NestJS, ni de TypeORM, ni de ningún paquete de infraestructura.

Value Objects

Un Value Object (VO) encapsula un valor con sus reglas de validación. No tiene identidad propia — dos VOs con el mismo valor son iguales.

// domain/value-objects/email.vo.ts
import { err, ok, Result } from 'neverthrow'
import { InvalidEmailError } from '../errors/user.errors'

export class Email {
  private constructor(private readonly value: string) {}

  static create(raw: string): Result<Email, InvalidEmailError> {
    const trimmed = raw.trim().toLowerCase()
    const isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)

    if (!isValid) {
      return err(new InvalidEmailError(raw))
    }

    return ok(new Email(trimmed))
  }

  getValue(): string {
    return this.value
  }

  equals(other: Email): boolean {
    return this.value === other.value
  }
}
// domain/value-objects/user-id.vo.ts
import { randomUUID } from 'crypto'

export class UserId {
  private constructor(private readonly value: string) {}

  static generate(): UserId {
    return new UserId(randomUUID())
  }

  static fromString(id: string): UserId {
    return new UserId(id)
  }

  getValue(): string {
    return this.value
  }

  equals(other: UserId): boolean {
    return this.value === other.value
  }
}
// domain/value-objects/user-name.vo.ts
import { err, ok, Result } from 'neverthrow'
import { InvalidUserNameError } from '../errors/user.errors'

export class UserName {
  private constructor(private readonly value: string) {}

  static create(raw: string): Result<UserName, InvalidUserNameError> {
    const trimmed = raw.trim()

    if (trimmed.length < 2 || trimmed.length > 100) {
      return err(new InvalidUserNameError(raw))
    }

    return ok(new UserName(trimmed))
  }

  getValue(): string {
    return this.value
  }
}

El patrón es siempre el mismo: constructor privado, factory method estático que retorna Result<VO, Error>. Nunca se puede crear un VO inválido — la validación está en el tipo.

Errores de dominio

Los errores de dominio son clases tipadas, no strings ni códigos mágicos. Esto permite manejarlos con exhaustividad en TypeScript.

// domain/errors/user.errors.ts
export class UserAlreadyExistsError {
  readonly _tag = 'UserAlreadyExistsError' as const
  readonly message: string

  constructor(email: string) {
    this.message = `El usuario con email "${email}" ya existe.`
  }
}

export class UserNotFoundError {
  readonly _tag = 'UserNotFoundError' as const
  readonly message: string

  constructor(id: string) {
    this.message = `Usuario con id "${id}" no encontrado.`
  }
}

export class InvalidEmailError {
  readonly _tag = 'InvalidEmailError' as const
  readonly message: string

  constructor(email: string) {
    this.message = `El email "${email}" no es válido.`
  }
}

export class InvalidUserNameError {
  readonly _tag = 'InvalidUserNameError' as const
  readonly message: string

  constructor(name: string) {
    this.message = `El nombre "${name}" no es válido. Debe tener entre 2 y 100 caracteres.`
  }
}

export type UserDomainError =
  | UserAlreadyExistsError
  | UserNotFoundError
  | InvalidEmailError
  | InvalidUserNameError

Entidad de dominio

La entidad tiene identidad (un ID único) y encapsula el estado y el comportamiento del negocio.

// domain/entities/user.entity.ts
import { UserId } from '../value-objects/user-id.vo'
import { Email } from '../value-objects/email.vo'
import { UserName } from '../value-objects/user-name.vo'

export class User {
  private constructor(
    private readonly id: UserId,
    private readonly email: Email,
    private readonly name: UserName,
    private readonly createdAt: Date,
  ) {}

  static create(params: {
    id: UserId
    email: Email
    name: UserName
    createdAt: Date
  }): User {
    return new User(params.id, params.email, params.name, params.createdAt)
  }

  getId(): UserId {
    return this.id
  }

  getEmail(): Email {
    return this.email
  }

  getName(): UserName {
    return this.name
  }

  getCreatedAt(): Date {
    return this.createdAt
  }
}

Puerto del repositorio

El puerto es una interfaz que define qué puede hacer el repositorio, sin decir cómo. Vive en el dominio.

// domain/ports/user.repository.ts
import { Result } from 'neverthrow'
import { User } from '../entities/user.entity'
import { UserId } from '../value-objects/user-id.vo'
import { Email } from '../value-objects/email.vo'
import { UserNotFoundError } from '../errors/user.errors'

export const USER_REPOSITORY = Symbol('USER_REPOSITORY')

export interface UserRepository {
  save(user: User): Promise<void>
  findById(id: UserId): Promise<Result<User, UserNotFoundError>>
  findByEmail(email: Email): Promise<User | null>
  exists(email: Email): Promise<boolean>
}

El símbolo USER_REPOSITORY es el token de inyección de dependencias. El dominio lo define, la infraestructura lo implementa.


Capa de Aplicación

La capa de aplicación orquesta los casos de uso. Conoce el dominio pero no sabe nada de HTTP, bases de datos ni frameworks.

CQRS: Commands y Queries

CQRS (Command Query Responsibility Segregation) separa las operaciones de escritura (Commands) de las de lectura (Queries). Cada operación tiene su propio handler.

Command — RegisterUser:

// application/commands/register-user.command.ts
export class RegisterUserCommand {
  constructor(
    public readonly name: string,
    public readonly email: string,
  ) {}
}
// application/commands/register-user.handler.ts
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'
import { Inject } from '@nestjs/common'
import { Result, err, ok } from 'neverthrow'
import { RegisterUserCommand } from './register-user.command'
import { UserRepository, USER_REPOSITORY } from '../../domain/ports/user.repository'
import { User } from '../../domain/entities/user.entity'
import { UserId } from '../../domain/value-objects/user-id.vo'
import { Email } from '../../domain/value-objects/email.vo'
import { UserName } from '../../domain/value-objects/user-name.vo'
import { UserAlreadyExistsError, UserDomainError } from '../../domain/errors/user.errors'

@CommandHandler(RegisterUserCommand)
export class RegisterUserHandler implements ICommandHandler<RegisterUserCommand> {
  constructor(
    @Inject(USER_REPOSITORY)
    private readonly userRepository: UserRepository,
  ) {}

  async execute(
    command: RegisterUserCommand,
  ): Promise<Result<{ id: string }, UserDomainError>> {
    // 1. Crear y validar Value Objects
    const emailResult = Email.create(command.email)
    if (emailResult.isErr()) return err(emailResult.error)

    const nameResult = UserName.create(command.name)
    if (nameResult.isErr()) return err(nameResult.error)

    const email = emailResult.value
    const name = nameResult.value

    // 2. Verificar que no exista
    const alreadyExists = await this.userRepository.exists(email)
    if (alreadyExists) {
      return err(new UserAlreadyExistsError(command.email))
    }

    // 3. Crear la entidad
    const user = User.create({
      id: UserId.generate(),
      email,
      name,
      createdAt: new Date(),
    })

    // 4. Persistir
    await this.userRepository.save(user)

    return ok({ id: user.getId().getValue() })
  }
}

Query — GetUser:

// application/queries/get-user.query.ts
export class GetUserQuery {
  constructor(public readonly userId: string) {}
}
// application/queries/get-user.handler.ts
import { QueryHandler, IQueryHandler } from '@nestjs/cqrs'
import { Inject } from '@nestjs/common'
import { Result } from 'neverthrow'
import { GetUserQuery } from './get-user.query'
import { UserRepository, USER_REPOSITORY } from '../../domain/ports/user.repository'
import { UserId } from '../../domain/value-objects/user-id.vo'
import { UserNotFoundError } from '../../domain/errors/user.errors'

type GetUserResult = {
  id: string
  name: string
  email: string
  createdAt: Date
}

@QueryHandler(GetUserQuery)
export class GetUserHandler implements IQueryHandler<GetUserQuery> {
  constructor(
    @Inject(USER_REPOSITORY)
    private readonly userRepository: UserRepository,
  ) {}

  async execute(
    query: GetUserQuery,
  ): Promise<Result<GetUserResult, UserNotFoundError>> {
    const userId = UserId.fromString(query.userId)
    const result = await this.userRepository.findById(userId)

    return result.map((user) => ({
      id: user.getId().getValue(),
      name: user.getName().getValue(),
      email: user.getEmail().getValue(),
      createdAt: user.getCreatedAt(),
    }))
  }
}

El handler de query mapea la entidad de dominio a un DTO de respuesta plano. El dominio nunca sale “crudo” hacia afuera.


Capa de Infraestructura

La infraestructura implementa los puertos definidos en el dominio. Aquí viven TypeORM, los controllers HTTP, los mappers y todo lo que toca el mundo exterior.

Modelo de persistencia

El modelo de persistencia es independiente de la entidad de dominio. TypeORM no debe contaminar el dominio.

// infrastructure/persistence/typeorm/user.model.ts
import { Column, CreateDateColumn, Entity, PrimaryColumn } from 'typeorm'

@Entity('users')
export class UserModel {
  @PrimaryColumn('uuid')
  id: string

  @Column({ unique: true })
  email: string

  @Column()
  name: string

  @CreateDateColumn()
  createdAt: Date
}

Mapper

El mapper traduce entre el modelo de persistencia y la entidad de dominio. Es el puente entre las dos representaciones.

// infrastructure/persistence/user.mapper.ts
import { User } from '../../domain/entities/user.entity'
import { UserId } from '../../domain/value-objects/user-id.vo'
import { Email } from '../../domain/value-objects/email.vo'
import { UserName } from '../../domain/value-objects/user-name.vo'
import { UserModel } from './typeorm/user.model'

export class UserMapper {
  static toDomain(model: UserModel): User {
    const id = UserId.fromString(model.id)
    // En el mapper asumimos que los datos en DB son válidos
    // (fueron validados al guardar), por eso usamos los VOs directamente
    const email = Email.create(model.email)
    const name = UserName.create(model.name)

    if (email.isErr() || name.isErr()) {
      throw new Error(`Datos corruptos en DB para usuario ${model.id}`)
    }

    return User.create({
      id,
      email: email.value,
      name: name.value,
      createdAt: model.createdAt,
    })
  }

  static toPersistence(user: User): Partial<UserModel> {
    return {
      id: user.getId().getValue(),
      email: user.getEmail().getValue(),
      name: user.getName().getValue(),
      createdAt: user.getCreatedAt(),
    }
  }
}

Implementación del repositorio (Adaptador)

El adaptador implementa el puerto del dominio usando TypeORM.

// infrastructure/persistence/typeorm/typeorm-user.repository.ts
import { Injectable } from '@nestjs/common'
import { InjectRepository } from '@nestjs/typeorm'
import { Repository } from 'typeorm'
import { Result, ok, err } from 'neverthrow'
import { UserRepository } from '../../../domain/ports/user.repository'
import { User } from '../../../domain/entities/user.entity'
import { UserId } from '../../../domain/value-objects/user-id.vo'
import { Email } from '../../../domain/value-objects/email.vo'
import { UserNotFoundError } from '../../../domain/errors/user.errors'
import { UserModel } from './user.model'
import { UserMapper } from '../user.mapper'

@Injectable()
export class TypeOrmUserRepository implements UserRepository {
  constructor(
    @InjectRepository(UserModel)
    private readonly repo: Repository<UserModel>,
  ) {}

  async save(user: User): Promise<void> {
    const model = UserMapper.toPersistence(user)
    await this.repo.save(model)
  }

  async findById(id: UserId): Promise<Result<User, UserNotFoundError>> {
    const model = await this.repo.findOne({
      where: { id: id.getValue() },
    })

    if (!model) {
      return err(new UserNotFoundError(id.getValue()))
    }

    return ok(UserMapper.toDomain(model))
  }

  async findByEmail(email: Email): Promise<User | null> {
    const model = await this.repo.findOne({
      where: { email: email.getValue() },
    })

    return model ? UserMapper.toDomain(model) : null
  }

  async exists(email: Email): Promise<boolean> {
    const count = await this.repo.count({
      where: { email: email.getValue() },
    })
    return count > 0
  }
}

DTO de entrada

// infrastructure/http/dtos/register-user.dto.ts
import { IsEmail, IsString, MinLength, MaxLength } from 'class-validator'

export class RegisterUserDto {
  @IsString()
  @MinLength(2)
  @MaxLength(100)
  name: string

  @IsEmail()
  email: string
}

Controller (Adaptador HTTP)

El controller es un adaptador de entrada. Traduce HTTP → Command/Query y Result → HTTP response.

// infrastructure/http/user.controller.ts
import {
  Body,
  Controller,
  Get,
  HttpCode,
  HttpStatus,
  NotFoundException,
  Param,
  Post,
  UnprocessableEntityException,
} from '@nestjs/common'
import { CommandBus, QueryBus } from '@nestjs/cqrs'
import { RegisterUserDto } from './dtos/register-user.dto'
import { RegisterUserCommand } from '../../application/commands/register-user.command'
import { GetUserQuery } from '../../application/queries/get-user.query'
import { UserAlreadyExistsError, UserNotFoundError } from '../../domain/errors/user.errors'

@Controller('users')
export class UserController {
  constructor(
    private readonly commandBus: CommandBus,
    private readonly queryBus: QueryBus,
  ) {}

  @Post()
  @HttpCode(HttpStatus.CREATED)
  async register(@Body() dto: RegisterUserDto) {
    const result = await this.commandBus.execute(
      new RegisterUserCommand(dto.name, dto.email),
    )

    if (result.isErr()) {
      const error = result.error
      if (error instanceof UserAlreadyExistsError) {
        throw new UnprocessableEntityException(error.message)
      }
      throw new UnprocessableEntityException(error.message)
    }

    return { id: result.value.id }
  }

  @Get(':id')
  async findOne(@Param('id') id: string) {
    const result = await this.queryBus.execute(new GetUserQuery(id))

    if (result.isErr()) {
      const error = result.error
      if (error instanceof UserNotFoundError) {
        throw new NotFoundException(error.message)
      }
      throw new NotFoundException(error.message)
    }

    return result.value
  }
}

El controller no tiene lógica de negocio. Solo traduce entre el mundo HTTP y los casos de uso.


Registro del módulo

Todo se conecta en el módulo. Aquí es donde el token del puerto se asocia a la implementación concreta.

// user.module.ts
import { Module } from '@nestjs/common'
import { CqrsModule } from '@nestjs/cqrs'
import { TypeOrmModule } from '@nestjs/typeorm'
import { UserController } from './infrastructure/http/user.controller'
import { RegisterUserHandler } from './application/commands/register-user.handler'
import { GetUserHandler } from './application/queries/get-user.handler'
import { TypeOrmUserRepository } from './infrastructure/persistence/typeorm/typeorm-user.repository'
import { UserModel } from './infrastructure/persistence/typeorm/user.model'
import { USER_REPOSITORY } from './domain/ports/user.repository'

const CommandHandlers = [RegisterUserHandler]
const QueryHandlers = [GetUserHandler]

@Module({
  imports: [CqrsModule, TypeOrmModule.forFeature([UserModel])],
  controllers: [UserController],
  providers: [
    ...CommandHandlers,
    ...QueryHandlers,
    {
      provide: USER_REPOSITORY,
      useClass: TypeOrmUserRepository,
    },
  ],
})
export class UserModule {}

El dominio define USER_REPOSITORY como símbolo. El módulo lo resuelve con TypeOrmUserRepository. Si mañana cambias a DynamoDB, solo cambias el useClass — el dominio y la aplicación no se tocan.


El flujo completo

Cuando llega un POST /users:

HTTP Request


UserController          ← Adaptador de entrada (infraestructura)
    │ new RegisterUserCommand(name, email)

RegisterUserHandler     ← Caso de uso (aplicación)
    │ Email.create() → Result<Email, InvalidEmailError>
    │ UserName.create() → Result<UserName, InvalidUserNameError>
    │ userRepository.exists(email) → boolean
    │ User.create(...) → User
    │ userRepository.save(user) → void

TypeOrmUserRepository   ← Adaptador de salida (infraestructura)
    │ UserMapper.toPersistence(user)

PostgreSQL

Los errores fluyen en sentido contrario como Result<T, E>, sin excepciones no controladas.


Por qué neverthrow

neverthrow hace que los errores sean parte del tipo de retorno. El compilador te obliga a manejarlos.

// Sin neverthrow — el error puede escapar silenciosamente
const user = await userRepository.findById(id) // puede lanzar o retornar null

// Con neverthrow — el error es explícito en el tipo
const result = await userRepository.findById(id) // Result<User, UserNotFoundError>

if (result.isErr()) {
  // TypeScript sabe que result.error es UserNotFoundError
  throw new NotFoundException(result.error.message)
}

// TypeScript sabe que result.value es User
const user = result.value

También puedes encadenar transformaciones sin anidar if:

const response = (await userRepository.findById(userId))
  .map((user) => ({
    id: user.getId().getValue(),
    email: user.getEmail().getValue(),
  }))
  .mapErr((error) => new NotFoundException(error.message))

Conclusión

La arquitectura hexagonal no es complejidad por complejidad. Es una inversión: pagas un poco más al inicio y ganas:

En el próximo artículo veremos cómo escribir unit tests para los handlers usando mocks manuales del repositorio — sin @nestjs/testing, sin levantar el módulo completo.