Express.js
Lógica de negocio y casos de uso
Explica cómo separar reglas y casos de uso del transporte HTTP, coordinar dependencias y conservar invariantes sin acoplar el dominio a Express.js.
- Última actualización
- Actualizada
- Nivel
- Aplicación
Express.js
Explica cómo separar reglas y casos de uso del transporte HTTP, coordinar dependencias y conservar invariantes sin acoplar el dominio a Express.js.
Un caso de uso representa una operación de la aplicación. Coordina reglas, permisos sobre recursos, persistencia y efectos sin depender de Express.
La lógica de negocio responde preguntas como:
Estas decisiones no pertenecen al objeto request ni a un status code.
handler HTTP
↓ input explícito
caso de uso
├─ reglas
├─ repositories
├─ servicios externos
└─ transacción
↓ resultado o error de aplicación
handler HTTPCuando el negocio vive en handlers:
El caso de uso crea una frontera alrededor de una intención.
Una función es suficiente:
export function makeCreateOrder(deps: Dependencies) {
return async function createOrder(command: CreateOrderCommand) {
// operación
};
}Una clase puede ser útil cuando representa dependencias y un contrato estable:
class CreateOrder {
constructor(
private readonly orders: OrderRepository,
private readonly inventory: InventoryRepository,
private readonly transactions: TransactionManager,
) {}
async execute(command: CreateOrderCommand): Promise<Order> {
// operación
}
}No existe ventaja automática por usar clases.
type CreateOrderCommand = {
actor: Actor;
branchId: string;
items: Array<{ productId: string; quantity: number }>;
idempotencyKey?: string;
};El input ya pasó validación estructural. El caso de uso todavía valida reglas dependientes de estado.
items es un array no vacío
→ validación de boundary
los productos existen y tienen stock
→ regla de negocio con persistencia
el actor puede crear pedidos en la branch
→ autorización contextualUna regla puede reforzarse en varios niveles. Una constraint de base de datos no elimina la validación del caso de uso; ambas ofrecen garantías diferentes.
Representa capacidades, no tecnologías cuando sea útil:
type OrderRepository = {
create(input: NewOrder, tx: Transaction): Promise<Order>;
};
type PaymentGateway = {
authorize(input: PaymentRequest): Promise<PaymentAuthorization>;
};No crees interfaces para cada clase por dogma. Extrae contratos cuando existe sustitución, test o boundary real.
Crear una orden puede requerir:
La transacción debe envolver la unidad de consistencia, no necesariamente toda la request.
return transactions.run(async (tx) => {
await inventory.reserve(command.items, command.branchId, tx);
const order = await orders.create(newOrder, tx);
await outbox.add({ type: 'OrderCreated', orderId: order.id }, tx);
return order;
});Enviar email dentro de la transacción puede mantener locks mientras una red externa responde. Un outbox permite confirmar datos y publicar después.
No todo efecto necesita cola; el trade-off depende de garantía, latencia y complejidad.
class EmptyOrderError extends Error {}
class InsufficientStockError extends Error {}
class BranchAccessDeniedError extends Error {}Estos errores describen el problema sin importar HTTP. El boundary decide si mapean a 403, 409 o 422.
Simplifican propagación y encajan con Express 5, pero pueden ocultar errores esperados si todo es Error.
type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E };Hace resultados esperados explícitos, pero añade branching. Ambos estilos pueden ser correctos si son consistentes.
async execute(command: CreateOrderCommand): Promise<Order> {
if (command.items.length === 0) {
throw new EmptyOrderError();
}
await policies.assertCanCreateOrder(command.actor, command.branchId);
return this.transactions.run(async (tx) => {
const products = await this.inventory.findForUpdate(
command.branchId,
command.items.map((item) => item.productId),
tx,
);
const reservation = reserveItems(products, command.items);
await this.inventory.saveReservation(reservation, tx);
const order = await this.orders.create(
buildOrder(command, reservation),
tx,
);
await this.outbox.add(orderCreatedEvent(order), tx);
return order;
});
}El caso de uso coordina; funciones de dominio calculan; repositories persisten.
La transacción pudo confirmar aunque el cliente no recibiera respuesta. Una idempotency key permite recuperar el mismo resultado.
La validación previa no basta. Usa locks, updates condicionales o constraints dentro de la transacción.
Necesitas retry, outbox o estado pendiente; no puedes revertir una base de datos ya confirmada de forma mágica.
Al estar centralizada, todos los adaptadores usan la misma política.
class OrderService {
getById(id) {
return repository.getById(id);
}
}Si no añade decisión, contrato o coordinación, solo aumenta navegación. El repository puede inyectarse directamente en un handler simple, aunque operaciones importantes suelen justificar un caso de uso.
Pruebas unitarias deben cubrir:
Las garantías de transacción requieren también pruebas de integración con la base real.
Separar casos de uso mejora claridad y testabilidad, pero una capa por cada CRUD trivial puede ser ceremonia. Extrae cuando existe regla, coordinación, política o boundary transaccional.
Validación de entradas no confiables distingue estructura, semántica, negocio y normalización.