Arquitectura hexagonal en la práctica
Cómo implementar la arquitectura hexagonal (puertos y adaptadores) en una aplicación real: fronteras claras, inversión de dependencias y dominio testeable.

La arquitectura hexagonal, también llamada puertos y adaptadores, resuelve un problema muy concreto: mantener tu lógica de negocio independiente de la infraestructura. Tu código de dominio no debería saber si está hablando con PostgreSQL o con DynamoDB, si las peticiones llegan por HTTP o desde una cola de mensajes, o si los correos se envían con SendGrid o con un espía de pruebas. Cuando las dependencias de infraestructura se filtran en la lógica de dominio, cada cambio de base de datos o de servicio externo obliga a modificar código al que esos detalles deberían darle igual.
La idea central es simple. Tu aplicación tiene un dentro (la lógica de negocio) y un fuera (la infraestructura). El dentro define puertos — interfaces que describen lo que necesita. El fuera aporta adaptadores — implementaciones que satisfacen esas interfaces.
La estructura
Una aplicación hexagonal tiene tres capas. El dominio central contiene las reglas de negocio y las entidades. Los puertos son interfaces que definen cómo interactúa el dominio con el mundo exterior. Los adaptadores implementan esos puertos con infraestructura real.
src/
domain/ # Pure business logic, no imports from adapters
entities/
services/
ports/ # Interfaces the domain exposes and requires
adapters/
driven/ # Infrastructure adapters (DB, email, etc.)
postgres/
redis/
sendgrid/
driving/ # Entry point adapters (HTTP, CLI, queue consumers)
http/
cli/
queue/
config/ # Wiring — connects adapters to ports
Definir el dominio y los puertos
Empieza por el dominio. Define las entidades y los puertos (interfaces) que el dominio necesita. El dominio nunca importa nada de los adaptadores: solo conoce sus propios tipos y sus puertos.
// domain/entities/order.ts
export interface Order {
id: string;
customerId: string;
items: OrderItem[];
status: OrderStatus;
total: number;
createdAt: Date;
}
export interface OrderItem {
productId: string;
quantity: number;
unitPrice: number;
}
export type OrderStatus =
| 'pending'
| 'confirmed'
| 'shipped'
| 'delivered'
| 'cancelled';
// domain/ports/order-repository.ts — driven port (outgoing)
export interface OrderRepository {
findById(id: string): Promise<Order | null>;
findByCustomer(customerId: string): Promise<Order[]>;
save(order: Order): Promise<void>;
updateStatus(id: string, status: OrderStatus): Promise<void>;
}
// domain/ports/payment-gateway.ts — driven port (outgoing)
export interface PaymentGateway {
charge(customerId: string, amount: number): Promise<PaymentResult>;
refund(paymentId: string, amount: number): Promise<RefundResult>;
}
// domain/ports/notification-service.ts — driven port (outgoing)
export interface NotificationService {
sendOrderConfirmation(order: Order): Promise<void>;
sendShippingUpdate(order: Order, trackingId: string): Promise<void>;
}Implementar los servicios de dominio
Los servicios de dominio contienen la lógica de negocio. Dependen únicamente de puertos, nunca de implementaciones concretas. Aquí es donde viven las reglas.
// domain/services/order-service.ts
import type { Order, OrderItem } from '../entities/order';
import type { OrderRepository } from '../ports/order-repository';
import type { PaymentGateway } from '../ports/payment-gateway';
import type { NotificationService } from '../ports/notification-service';
export class OrderService {
constructor(
private readonly orders: OrderRepository,
private readonly payments: PaymentGateway,
private readonly notifications: NotificationService
) {}
async createOrder(
customerId: string,
items: OrderItem[]
): Promise<Order> {
// Business rule: orders must have at least one item
if (items.length === 0) {
throw new Error('Order must contain at least one item');
}
// Business rule: calculate total from items
const total = items.reduce(
(sum, item) => sum + item.quantity * item.unitPrice,
0
);
// Business rule: minimum order value
if (total < 1) {
throw new Error('Order total must be at least $1.00');
}
const order: Order = {
id: crypto.randomUUID(),
customerId,
items,
status: 'pending',
total,
createdAt: new Date(),
};
// Charge the customer
const payment = await this.payments.charge(customerId, total);
if (!payment.success) {
throw new Error(`Payment failed: ${payment.error}`);
}
// Persist the order
order.status = 'confirmed';
await this.orders.save(order);
// Notify the customer (non-critical — don't throw on failure)
await this.notifications
.sendOrderConfirmation(order)
.catch(() => {}); // Log but don't fail the order
return order;
}
async cancelOrder(orderId: string): Promise<void> {
const order = await this.orders.findById(orderId);
if (!order) throw new Error('Order not found');
// Business rule: only pending/confirmed orders can be cancelled
if (order.status !== 'pending' && order.status !== 'confirmed') {
throw new Error(
`Cannot cancel order in '${order.status}' status`
);
}
await this.payments.refund(orderId, order.total);
await this.orders.updateStatus(orderId, 'cancelled');
}
}Construir los adaptadores
Los adaptadores implementan las interfaces de los puertos con infraestructura real. Cada adaptador vive en su propio directorio y se puede sustituir de forma independiente.
// adapters/driven/postgres/postgres-order-repository.ts
import type { Order, OrderRepository, OrderStatus } from '../../../domain';
import type { Pool } from 'pg';
export class PostgresOrderRepository implements OrderRepository {
constructor(private readonly pool: Pool) {}
async findById(id: string): Promise<Order | null> {
const result = await this.pool.query(
`SELECT o.*, json_agg(oi.*) as items
FROM orders o
LEFT JOIN order_items oi ON oi.order_id = o.id
WHERE o.id = $1
GROUP BY o.id`,
[id]
);
if (result.rows.length === 0) return null;
return this.toDomain(result.rows[0]);
}
async findByCustomer(customerId: string): Promise<Order[]> {
const result = await this.pool.query(
`SELECT o.*, json_agg(oi.*) as items
FROM orders o
LEFT JOIN order_items oi ON oi.order_id = o.id
WHERE o.customer_id = $1
GROUP BY o.id
ORDER BY o.created_at DESC`,
[customerId]
);
return result.rows.map((row) => this.toDomain(row));
}
async save(order: Order): Promise<void> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
await client.query(
`INSERT INTO orders (id, customer_id, status, total, created_at)
VALUES ($1, $2, $3, $4, $5)`,
[order.id, order.customerId, order.status, order.total, order.createdAt]
);
for (const item of order.items) {
await client.query(
`INSERT INTO order_items (order_id, product_id, quantity, unit_price)
VALUES ($1, $2, $3, $4)`,
[order.id, item.productId, item.quantity, item.unitPrice]
);
}
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
async updateStatus(id: string, status: OrderStatus): Promise<void> {
await this.pool.query(
'UPDATE orders SET status = $1 WHERE id = $2',
[status, id]
);
}
private toDomain(row: Record<string, unknown>): Order {
return {
id: row.id as string,
customerId: row.customer_id as string,
items: (row.items as Array<Record<string, unknown>>).map((i) => ({
productId: i.product_id as string,
quantity: i.quantity as number,
unitPrice: i.unit_price as number,
})),
status: row.status as OrderStatus,
total: Number(row.total),
createdAt: new Date(row.created_at as string),
};
}
}Probar sin infraestructura
La verdadera recompensa de la arquitectura hexagonal son las pruebas. Los servicios de dominio se pueden probar por completo con dobles en memoria: sin base de datos, sin APIs externas y sin contenedores Docker.
// ❌ Without hexagonal architecture — tests need real infrastructure
describe('OrderService', () => {
beforeAll(async () => {
await startPostgresContainer(); // Slow
await runMigrations(); // Brittle
await seedTestData(); // Complex setup
});
// Tests are slow, flaky, and hard to maintain
});
// ✅ With hexagonal architecture — tests use in-memory fakes
describe('OrderService', () => {
let orderService: OrderService;
let fakeOrders: InMemoryOrderRepository;
let fakePayments: FakePaymentGateway;
let fakeNotifications: FakeNotificationService;
beforeEach(() => {
fakeOrders = new InMemoryOrderRepository();
fakePayments = new FakePaymentGateway();
fakeNotifications = new FakeNotificationService();
orderService = new OrderService(
fakeOrders,
fakePayments,
fakeNotifications
);
});
it('creates an order and charges the customer', async () => {
const items = [{ productId: 'p1', quantity: 2, unitPrice: 25 }];
const order = await orderService.createOrder('customer-1', items);
expect(order.status).toBe('confirmed');
expect(order.total).toBe(50);
expect(fakePayments.charges).toHaveLength(1);
expect(fakePayments.charges[0].amount).toBe(50);
});
it('rejects order with no items', async () => {
await expect(
orderService.createOrder('customer-1', [])
).rejects.toThrow('Order must contain at least one item');
});
it('does not save order if payment fails', async () => {
fakePayments.shouldFail = true;
await expect(
orderService.createOrder('customer-1', [
{ productId: 'p1', quantity: 1, unitPrice: 10 },
])
).rejects.toThrow('Payment failed');
expect(fakeOrders.all()).toHaveLength(0);
});
});Ensamblarlo todo
La capa de configuración conecta los adaptadores con los puertos. Es el único sitio que conoce las implementaciones concretas. La inyección de dependencias ocurre en el punto de entrada de la aplicación.
// config/container.ts — wiring adapters to ports
import { Pool } from 'pg';
import { OrderService } from '../domain/services/order-service';
import { PostgresOrderRepository } from '../adapters/driven/postgres/postgres-order-repository';
import { StripePaymentGateway } from '../adapters/driven/stripe/stripe-payment-gateway';
import { SendGridNotificationService } from '../adapters/driven/sendgrid/sendgrid-notification-service';
export function createContainer(config: AppConfig) {
const pgPool = new Pool({ connectionString: config.databaseUrl });
const orderRepository = new PostgresOrderRepository(pgPool);
const paymentGateway = new StripePaymentGateway(config.stripeKey);
const notifications = new SendGridNotificationService(config.sendgridKey);
const orderService = new OrderService(
orderRepository,
paymentGateway,
notifications
);
return { orderService, pgPool };
}Puntos clave
- El código de dominio no importa nada de los adaptadores — la lógica de negocio depende solo de las interfaces de los puertos, nunca de la infraestructura
- Los puertos son interfaces, los adaptadores son implementaciones — cambia PostgreSQL por DynamoDB escribiendo un adaptador nuevo, no tocando el código de dominio
- Las pruebas son el beneficio principal — los servicios de dominio se pueden probar por completo con dobles en memoria, sin necesidad de contenedores
- El ensamblaje ocurre en el punto de entrada — la capa de configuración es el único sitio que conoce las clases concretas de los adaptadores
- No abstraigas de más — si tu aplicación tiene una sola base de datos y siempre la va a tener, la arquitectura hexagonal añade complejidad sin aportar nada; úsala cuando importen la flexibilidad de infraestructura o la testeabilidad
- Empieza por los puertos de las dependencias externas — las bases de datos, las pasarelas de pago, los servicios de correo y las colas de mensajes son las fronteras que más se benefician de este patrón


