Evita efectos secundarios: No todas las sorpresas son buenas

04-08-2026

Para mí, la arquitectura y el desarrollo de software no va solo de hacer que las cosas funcionen. Va de diseñar un código que nuestra mente pueda procesar sin volverse loca y en el que podamos confiar de verdad a lo largo del tiempo.

Para que veas a lo que me refiero, imagínate este caso de uso para procesar órdenes:

 

public class OrderProcessor( IOrderRegisterRepository orderRegister, IInventoryReserverRepository inventoryReserver, IPaymentChargerClient paymentCharger ): IOrderProcessor { public Either<Error, Receipt> Process(OrderRegistrationInfo orderInfo) { return await from orderToBeRegistered in OrderToBeRegistered.Create( orderInfo.CustomerId, orderInfo.Total, orderInfo.Product, orderInfo.Quantity ).ToAsync() from registeredOrder in orderRegister.Register(orderToBeRegistered).ToAsync() from reservation in inventoryReserver.ReserveStock(registeredOrder).ToAsync() from receipt in paymentCharger.Charge(reservation).ToAsync() select receipt; } }

Si lees OrderProcessor por encima, tu cabeza registra una secuencia súper limpia de cuatro pasos:

  1. Validar la orden.
  2. Registrar la orden en la base de datos.
  3. Reservar el stock.
  4. Cobrar la reserva.

Como son solo cuatro elementos, tu cerebro los procesa de un vistazo porque no pasa del límite biológico de nuestra memoria a corto plazo (que suele estar en unos siete elementos a la vez). Nuestra mente funciona de forma innata con el principio WYSIATI (“Lo que ves es todo lo que hay”). Ves el flujo, asumes que ReserveStock simplemente bloquea unas unidades en el almacén y sigues leyendo tan tranquilo con la carga cognitiva bajo control.

Pero el peligro se huele…

El código nos está vendiendo una falsa sensación de seguridad. En cuanto abres el archivo de InventoryReserverRepository, te encuentras con esto:

 

public class InventoryReserverEntityFrameworkRepository( OrdersDbContext context, IFinancingCreatorRepository financingCreator ) : IInventoryReserverRepository { public async Task<Either<Error, Reservation>> ReserveStock(RegisteredOrder order) { // SORPRESA: Busca al cliente, chequea crédito y encima genera una financiación var customer = await context.Customers.FirstOrDefaultAsync(c => c.Id == order.CustomerId()); if (customer == null) { return Error.New($"El cliente con ID '{order.CustomerId()}' no existe."); } if (customer.NotHaveSufficientCreditFor(order)) { // Composición anidada dañina await financingCreator.CreatePreApprovedLoan(customer, order); } // SORPRESA: Buscamos el producto y comprobamos si existe ademas de si queda stock var product = await context.Products.FirstOrDefaultAsync(p => p.Id == order.ProductId()); if (product == null) { return Error.New("No se ha encontrado el producto, contacte con soporte."); } if (product.Stock < order.Quantity()) { return Error.New("No hay stock suficiente para realizar la reserva en el almacén."); } product.DecreaseStock(order.Quantity); var reservationEntity = new ReservationEntity(order.GetId(), product.Id, order.GetQuantity()); await context.Reservations.AddAsync(reservationEntity); await context.SaveChangesAsync(); return Reservation.Create(order.GetId(), product.Id, order.GetQuantity()); } }

La trampa de esconder lógica bajo la alfombra

En el momento en que descubres que ReserveStock no era una simple gestión de existencias, sino que escondía efectos secundarios masivos, se te rompe por completo el modelo mental.

Este diseño es una violación directa del Principio de Menor Sorpresa. Este principio nos dice algo muy básico pero sagrado: el código debe comportarse exactamente como cabe esperar al leerlo. Cada componente debe ser una caja negra predecible. Si alguien invoca un método que dice “reservar stock”, jamás debería asumir el riesgo de que, por debajo, se esté creando un préstamo financiero real y guardándose en la base de datos. Es una sorpresa de las que no gustan, que destruye la confianza en el sistema.

Aquí es donde destruimos el principio de CQS (Command Query Separation) y la responsabilidad única. CQS nos dice que un método debe hacer una cosa: o bien devuelve información (Query) o bien muta el estado del sistema (Command), pero jamás las dos cosas a la vez. Al esconder un flujo de negocio entero (la financiación, la existencia del producto y ver si queda stock) dentro de un comando de infraestructura que supuestamente solo iba a restar stock, hemos creado una composición anidada peligrosa.

¿Por qué es peligrosa?

Porque borra información esencial de la vista. Si ahora tienes que tocar algo en OrderProcessor, ya no puedes ver la reserva como un paso inocente. Tu cabeza está obligada a recordar toda la telaraña oculta:

  • Los 4 pasos principales.
  • La consulta al cliente secreta.
  • La consulta a ver si existe el producto y tiene suficiente stock.
  • La lógica del crédito y la escritura oculta del préstamo.

El código ha dejado de caber en tu cabeza.

Para que CQS y el diseño funcionen de verdad, cada pieza debe tener una única responsabilidad. Tienen que ser cajas negras predecibles. Si un repositorio se llama InventoryReserverRepository, su único contrato con el mundo debe ser reservar stock. No puede decidir sobre finanzas ni reglas de clientes. Cuando el código miente en sus contratos, perdemos la confianza en el sistema, nos toca investigar las tripas de cada método que invocamos y acabamos programando a la defensiva.

Sacando las verdades a la luz

A mí me resultó muy útil darle una vuelta a esto y sacar todo ese comportamiento oculto a la superficie del caso de uso.

De esta forma, nuestro caso de uso es 100% honesto y las piezas de infraestructura recuperan su único propósito.

Mira cómo cambia:

 

public class OrderProcessor( IOrderRegisterRepository orderRegister, ICustomerFinderRepository customerFinder, IFinancingCreatorClient financingCreator, IInventoryCheckerRepository inventoryChecker, IInventoryReserverRepository inventoryReserver, IPaymentChargerClient paymentCharger ): OrderProcessor { public async Task<Either<Error, Receipt>> Process(OrderRegistrationInfo orderInfo) { return await from orderToBeRegistered in OrderToBeRegistered.Create(orderInfo.CustomerId, orderInfo.Total).ToAsync() from registeredOrder in orderRegister.Register(orderToBeRegistered).ToAsync() from customer in customerFinder.FindById(registeredOrder.GetCustomerId()).ToAsync() from _ in EnsureSufficientFunds(customer, registeredOrder).ToAsync() from __ in inventoryChecker.CheckStockAvailability(registeredOrder).ToAsync() from reservation in inventoryReserver.ReserveStock(registeredOrder).ToAsync() from receipt in paymentCharger.Charge(reservation).ToAsync() select receipt; } private async Task<Either<Error, Unit>> EnsureSufficientFunds(Customer customer, RegisteredOrder registeredOrder) { if (customer.HasSufficientCreditFor(registeredOrder)) { return Unit.Default; } return await financingCreator.CreatePreApprovedLoan(customer, registeredOrder); } }

El comportamiento esperado en un repositorio

Para ver el contraste, mira cómo debería comportarse ahora nuestro repositorio de stock modificado. Ahora maneja la responsabilidad que le toca y comportándose como una auténtica caja negra que hace una sola cosa:

 

public class InventoryReserverEntityFrameworkRepository( OrdersDbContext context ) : IInventoryReserverRepository { public async Task<Either<Error, Reservation>> ReserveStock(RegisteredOrder order) { await context.Products .Where(p => p.Id == order.GetProductId()) .ExecuteUpdateAsync(s => s.SetProperty(p => p.Stock, p => p.Stock - order.GetQuantity())); return Unit.Default() } }

Lo que ganamos con este cambio

Hacer este refactor paso a paso te da unas ventajas brutales en el día a día:

  1. Fuera sorpresas: Cualquiera que lea el método Process ve al instante la historia real de la transacción. Sabe perfectamente que se busca al cliente, se aseguran los fondos (pudiendo financiar si hace falta) y luego se reserva. No hay secretos bajo la alfombra.
  2. Respetamos CQS y eliminamos la composición anidada: Al sacar la lógica financiera del repositorio de stock, evitamos mutar estados financieros de forma encubierta dentro de una operación de almacén. Las consultas y los comandos vuelven a estar orquestados al mismo nivel, visibles en el caso de uso. Cada pieza hace su trabajo y tu modelo mental se mantiene intacto.
  3. Abstracción de la buena: Al empaquetar la verificación de saldo y la lógica del préstamo en el método privado EnsureSufficientFunds, eliminamos el ruido visual del flujo principal pero manteniendo la semántica. Tu cerebro lo procesa como un único bloque conceptual sin saturarse.

Conclusión: Código honesto = código sostenible

Escribir código sostenible va mucho más allá de que pasen los tests o compile el proyecto, se trata de construir un sistema que sea predecible.

Al final, el buen diseño es el que no te miente, protege tu tiempo y, sobre todo, respeta los límites de nuestra propia mente.

Referencias

Artículos relacionados