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:
- Testabilidad: puedes testear el dominio y los handlers sin levantar la app ni la DB.
- Reemplazabilidad: cambiar TypeORM por Prisma, o REST por gRPC, no toca el dominio.
- Claridad: cada archivo tiene una responsabilidad única y un lugar obvio en la estructura.
- Seguridad de tipos: los errores son parte del contrato, no sorpresas en runtime.
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.