Arquitectura hexagonal: puertos y adaptadores en la práctica
Arquitectura hexagonal en TypeScript: los puertos definen contratos, los adaptadores gestionan la infraestructura y la inversión mantiene el dominio testeable.

La arquitectura hexagonal—también llamada puertos y adaptadores—resuelve el problema de la lógica de negocio enredada con frameworks, bases de datos y manejadores HTTP. Cuando tu lógica de dominio importa Express, consulta la base de datos directamente y formatea respuestas HTTP, no puedes probar las reglas de negocio sin levantar toda la pila de infraestructura.
El hexágono aísla el núcleo de dominio detrás de puertos (interfaces) a los que se conectan los adaptadores (implementaciones) para llegar al mundo exterior. El resultado: lógica de negocio testeable con simples pruebas unitarias, infraestructura intercambiable sin tocar el código de dominio y una base de código donde la arquitectura revela la intención.
El concepto central: puertos y adaptadores
Los puertos son interfaces definidas por el dominio. Los puertos primarios (de entrada) definen qué puede hacer la aplicación. Los puertos secundarios (de salida) definen qué necesita la aplicación del mundo exterior.
// ❌ Domain logic coupled to infrastructure
class OrderService {
async createOrder(req: express.Request) {
const items = req.body.items; // Coupled to Express
const user = await db.query( // Coupled to database
"SELECT * FROM users WHERE id = $1",
[req.user.id]
);
// Business logic mixed with infrastructure
const order = { userId: user.id, items, total: 0 };
for (const item of items) {
const product = await db.query(
"SELECT price FROM products WHERE id = $1",
[item.productId]
);
order.total += product.price * item.quantity;
}
await db.query("INSERT INTO orders...", [order]);
return res.json(order); // Coupled to HTTP response
}
}// ✅ Domain core with ports
// ---- DOMAIN TYPES ----
interface OrderItem {
productId: string;
quantity: number;
}
interface Order {
id: string;
userId: string;
items: OrderItem[];
total: number;
status: "pending" | "confirmed" | "shipped";
createdAt: Date;
}
// ---- DRIVING PORT (input) ----
// What the application can do — defined by domain needs
interface OrderUseCase {
createOrder(
userId: string,
items: OrderItem[]
): Promise<Order>;
getOrder(orderId: string): Promise<Order | null>;
confirmOrder(orderId: string): Promise<Order>;
}
// ---- DRIVEN PORTS (output) ----
// What the domain needs from infrastructure
interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: string): Promise<Order | null>;
findByUserId(userId: string): Promise<Order[]>;
}
interface ProductCatalog {
getPrice(productId: string): Promise<number>;
checkAvailability(
productId: string,
quantity: number
): Promise<boolean>;
}
interface PaymentGateway {
charge(
userId: string,
amount: number
): Promise<{ transactionId: string }>;
}
interface EventPublisher {
publish(event: DomainEvent): Promise<void>;
}
type DomainEvent =
| { type: "order.created"; data: Order }
| { type: "order.confirmed"; data: Order };El servicio de aplicación (el núcleo del hexágono)
El servicio de aplicación implementa el puerto primario usando únicamente interfaces de puertos secundarios. Contiene lógica de negocio pura, con cero dependencias de infraestructura.
class OrderApplicationService implements OrderUseCase {
constructor(
private orderRepo: OrderRepository,
private catalog: ProductCatalog,
private payment: PaymentGateway,
private events: EventPublisher
) {}
async createOrder(
userId: string,
items: OrderItem[]
): Promise<Order> {
// Business rule: validate availability
for (const item of items) {
const available =
await this.catalog.checkAvailability(
item.productId,
item.quantity
);
if (!available) {
throw new DomainError(
`Product ${item.productId} not available ` +
`in quantity ${item.quantity}`
);
}
}
// Business rule: calculate total
let total = 0;
for (const item of items) {
const price = await this.catalog.getPrice(
item.productId
);
total += price * item.quantity;
}
// Business rule: minimum order value
if (total < 10) {
throw new DomainError(
"Minimum order value is $10"
);
}
const order: Order = {
id: generateId(),
userId,
items,
total,
status: "pending",
createdAt: new Date(),
};
await this.orderRepo.save(order);
await this.events.publish({
type: "order.created",
data: order,
});
return order;
}
async confirmOrder(orderId: string): Promise<Order> {
const order = await this.orderRepo.findById(orderId);
if (!order) {
throw new DomainError(`Order ${orderId} not found`);
}
if (order.status !== "pending") {
throw new DomainError(
`Order ${orderId} cannot be confirmed ` +
`from status ${order.status}`
);
}
// Business rule: charge payment
await this.payment.charge(order.userId, order.total);
const confirmed: Order = {
...order,
status: "confirmed",
};
await this.orderRepo.save(confirmed);
await this.events.publish({
type: "order.confirmed",
data: confirmed,
});
return confirmed;
}
async getOrder(orderId: string): Promise<Order | null> {
return this.orderRepo.findById(orderId);
}
}
class DomainError extends Error {
constructor(message: string) {
super(message);
this.name = "DomainError";
}
}Fíjate en lo que no está: nada de SQL, nada de HTTP, nada de Express, ningún SDK de broker de mensajes. El servicio de aplicación es TypeScript puro, sin más sentencias import que las de los tipos de dominio y las interfaces de los puertos.
Adaptadores: conectar con el mundo real
Los adaptadores implementan las interfaces de los puertos, traduciendo entre el dominio y los sistemas externos.
// ---- OUTPUT ADAPTER: PostgreSQL ----
class PostgresOrderRepository implements OrderRepository {
constructor(private pool: Pool) {}
async save(order: Order): Promise<void> {
await this.pool.query(
`INSERT INTO orders (id, user_id, items, total, status, created_at)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (id) DO UPDATE SET
status = EXCLUDED.status,
total = EXCLUDED.total`,
[
order.id,
order.userId,
JSON.stringify(order.items),
order.total,
order.status,
order.createdAt,
]
);
}
async findById(id: string): Promise<Order | null> {
const result = await this.pool.query(
"SELECT * FROM orders WHERE id = $1",
[id]
);
if (result.rows.length === 0) return null;
return this.toDomain(result.rows[0]);
}
async findByUserId(userId: string): Promise<Order[]> {
const result = await this.pool.query(
"SELECT * FROM orders WHERE user_id = $1",
[userId]
);
return result.rows.map(this.toDomain);
}
private toDomain(row: any): Order {
return {
id: row.id,
userId: row.user_id,
items: row.items,
total: parseFloat(row.total),
status: row.status,
createdAt: new Date(row.created_at),
};
}
}
// ---- INPUT ADAPTER: Express HTTP ----
class OrderHttpAdapter {
constructor(private orderUseCase: OrderUseCase) {}
createRoutes(): express.Router {
const router = express.Router();
router.post("/orders", async (req, res) => {
try {
const order = await this.orderUseCase.createOrder(
req.user!.id,
req.body.items
);
res.status(201).json(order);
} catch (error) {
if (error instanceof DomainError) {
res.status(400).json({
error: error.message,
});
} else {
res.status(500).json({
error: "Internal server error",
});
}
}
});
router.post(
"/orders/:id/confirm",
async (req, res) => {
try {
const order =
await this.orderUseCase.confirmOrder(
req.params.id
);
res.json(order);
} catch (error) {
if (error instanceof DomainError) {
res.status(400).json({
error: error.message,
});
} else {
res.status(500).json({
error: "Internal server error",
});
}
}
}
);
return router;
}
}Probar sin infraestructura
La mayor recompensa: la lógica de dominio se puede probar con mocks sencillos, sin base de datos, sin servidor HTTP y sin contenedores Docker.
describe("OrderApplicationService", () => {
let service: OrderApplicationService;
let mockRepo: jest.Mocked<OrderRepository>;
let mockCatalog: jest.Mocked<ProductCatalog>;
let mockPayment: jest.Mocked<PaymentGateway>;
let mockEvents: jest.Mocked<EventPublisher>;
beforeEach(() => {
mockRepo = {
save: jest.fn(),
findById: jest.fn(),
findByUserId: jest.fn(),
};
mockCatalog = {
getPrice: jest.fn().mockResolvedValue(25),
checkAvailability: jest.fn().mockResolvedValue(true),
};
mockPayment = {
charge: jest
.fn()
.mockResolvedValue({ transactionId: "tx-123" }),
};
mockEvents = { publish: jest.fn() };
service = new OrderApplicationService(
mockRepo,
mockCatalog,
mockPayment,
mockEvents
);
});
it("creates order with calculated total", async () => {
mockCatalog.getPrice.mockResolvedValue(15);
const order = await service.createOrder("user-1", [
{ productId: "prod-1", quantity: 2 },
]);
expect(order.total).toBe(30);
expect(order.status).toBe("pending");
expect(mockRepo.save).toHaveBeenCalledWith(
expect.objectContaining({ total: 30 })
);
});
it("rejects orders below minimum value", async () => {
mockCatalog.getPrice.mockResolvedValue(3);
await expect(
service.createOrder("user-1", [
{ productId: "prod-1", quantity: 1 },
])
).rejects.toThrow("Minimum order value is $10");
});
it("rejects unavailable products", async () => {
mockCatalog.checkAvailability.mockResolvedValue(false);
await expect(
service.createOrder("user-1", [
{ productId: "prod-1", quantity: 100 },
])
).rejects.toThrow("not available");
});
it("publishes event on order creation", async () => {
await service.createOrder("user-1", [
{ productId: "prod-1", quantity: 1 },
]);
expect(mockEvents.publish).toHaveBeenCalledWith(
expect.objectContaining({ type: "order.created" })
);
});
});Estas pruebas se ejecutan en milisegundos. Sin configurar bases de datos, sin test containers, sin problemas de integración inestables. Las reglas de negocio se verifican de forma aislada.
Puntos clave
Los puertos son interfaces definidas por el dominio que crean una frontera entre la lógica de negocio y la infraestructura—los puertos primarios definen qué hace la aplicación y los secundarios definen qué necesita. El servicio de aplicación implementa los puertos primarios usando solo interfaces de puertos secundarios, y contiene lógica de negocio pura sin ningún import de framework—nada de SQL, nada de HTTP, ningún SDK de broker de mensajes. Los adaptadores implementan las interfaces de los puertos para conectar el dominio con la infraestructura real: los adaptadores de entrada traducen las peticiones externas en llamadas de dominio y los de salida traducen las necesidades del dominio en operaciones de infraestructura. La inversión de dependencias significa que el dominio define las interfaces y la infraestructura las implementa, no al revés—el dominio nunca depende de los adaptadores. Probar el núcleo de dominio solo requiere mocks sencillos de las interfaces de los puertos, se ejecuta en milisegundos sin bases de datos, servidores HTTP ni contenedores, y las reglas de negocio quedan verificadas en completo aislamiento de la infraestructura.


