La promoción decía “válido hasta las 23:59 del domingo”. El cliente pagó a las 23:40. El sistema le dijo que la promoción había expirado.
Nadie calculó mal. El servidor corre en UTC, y para él ya era lunes desde hacía cuarenta minutos. La compra llegó a tiempo según el reloj del cliente y tarde según el reloj del servidor, y nadie, en ningún lugar del código ni de la reunión donde se definió la promo, había decidido nunca cuál de los dos manda.
Si trabajas con software hace más de un par de años, ya viviste alguna variante de esta escena, del lado de usuario:
- Cancelas una suscripción el 31 de enero y el próximo cobro llega el 28 de febrero, tres días antes de lo que esperabas.
- El reporte de cierre de año sale vacío el 31 de diciembre a las 21:00, porque para el sistema ya es 1 de enero.
- Un turno se corre solo, sin que nadie toque el calendario, justo el domingo del cambio de horario.
Cuatro escenas, un mismo defecto de diseño: en algún método, adentro de la lógica de negocio, alguien le preguntó la hora al sistema operativo en lugar de recibirla como dato. El tiempo es una entrada del dominio, no una propiedad del ambiente donde corre el proceso, y tratarlo como ambiente es exactamente lo que produce estas cuatro escenas.
Dos resultados distintos, los mismos argumentos
Esta serie viene mostrando causas puntuales de por qué verificar se te vuelve caro: cada mock que escribes es una dependencia que no invertiste, y qué tan crítico es tu dominio decide cuánto vale la pena pagar ese precio antes de elegir con qué testear (la criticidad del dominio). Hoy la causa es distinta, y aparece igual en un sistema de retail que en uno de salud: el reloj cableado adentro de la regla.
La tesis del día: si tu lógica puede dar dos resultados distintos con los mismos argumentos, tiene una entrada que no declaraste.
public boolean isEligible() {
LocalDateTime now = LocalDateTime.now();
return !now.isAfter(DEADLINE);
}
isEligible() no recibe un solo argumento. Y sin embargo da respuestas distintas según cuándo se lo llame y en qué máquina corra. Eso significa que tiene, como mínimo, dos entradas escondidas: el instante y la zona horaria. LocalDateTime.now() resuelve por dentro ZoneId.systemDefault(), la zona que el equipo de infraestructura le configuró al contenedor el día que lo desplegó. Nadie del lado de negocio decidió esa zona. Nadie del lado de negocio sabe siquiera que esa decisión existe.
El costo no es abstracto. Es una nota de crédito manual para un cliente que hizo todo bien. Es un ingeniero al teléfono un domingo a la noche tratando de explicar un bug que “no debería poder pasar”, porque en el ambiente de desarrollo, en horario de oficina, lejos de cualquier medianoche, nunca se reproduce. Es la reunión incómoda donde alguien de negocio pregunta por qué el sistema le dijo que no a un cliente que cumplió con lo prometido, y la respuesta técnica (“la zona horaria por defecto del contenedor”) no le sirve a nadie en esa sala.
Y no es exclusivo del vencimiento de una promoción. La misma causa reaparece en cualquier punto donde el dominio le pregunta algo al ambiente en lugar de recibirlo como dato: el azar de un sorteo de bonus, la red de un gateway de pago con un timeout hardcodeado. Cambia el nombre del método que llamas, no la forma del problema. Vuelvo a esos dos casos al final, como corolario corto.
El offset fijo que casi funciona
Apenas alguien nota que “el servidor vive en UTC” está rompiendo algo, el arreglo reflejo es fijar un offset:
ZoneOffset offsetFijo = ZoneOffset.of("-04:00"); // "ya está, ahora es explícito"
Se siente como una corrección real. Ya no depende de systemDefault(), hay una zona a la vista, el número está escrito en el código y cualquiera lo puede leer. El problema es que un offset fijo no es una zona horaria: no sabe que existe el horario de verano.
El domingo exacto en que el reloj cambia (justo cuando más importa que la hora esté bien, porque es la noche donde más compras y turnos ocurren cerca de la medianoche) ese offset fijo calcula mal el instante real. Toma el mismo instante y compáralo contra America/New_York, la zona real, y contra el offset fijo -05:00 que en teoría la representa: la zona real dice que ya es lunes, así que la compra queda fuera de plazo. El offset fijo dice que todavía es domingo, porque nunca se enteró de que esa noche el reloj adelantó una hora. Los dos cálculos parten del mismo instante y llegan a conclusiones opuestas, y solo uno de los dos está resolviendo la pregunta que negocio realmente hizo.
La otra trampa, más cara todavía, es reparar el síntoma sin tocar el diseño: forzar la zona horaria del entorno de ejecución a UTC y llamarlo “reproducible”, o interceptar la llamada al reloj del sistema con algún truco por fuera del código de producción. Nada de eso cambia que la regla de negocio sigue sin declarar el instante y la zona como datos propios. El próximo desarrollador que toque ese método va a volver a escribir LocalDateTime.now() adentro, porque nada en la firma del método le avisa que no debería.
La decisión: el reloj entra por constructor, la zona es un dato de negocio
El instante y la zona entran por constructor. java.time.Clock provee el instante (en producción, el reloj del sistema; en cualquier otro contexto, un instante fijo elegido a propósito). La zona en la que la regla tiene sentido entra como dato aparte, porque es una decisión de negocio (la zona del cliente, la zona del comercio, la zona en la que se publicó el término y condición) y no una propiedad del entorno donde corre el proceso.
public class PromotionPolicy {
private final Clock clock;
private final ZoneId ruleZone;
private final LocalDateTime deadline;
public PromotionPolicy(Clock clock, ZoneId ruleZone, LocalDateTime deadline) {
this.clock = clock;
this.ruleZone = ruleZone;
this.deadline = deadline;
}
public boolean isEligible() {
Instant now = clock.instant();
LocalDateTime nowInRuleZone = now.atZone(ruleZone).toLocalDateTime();
return !nowInRuleZone.isAfter(deadline);
}
}
El trade-off es real, no cosmético: en producción hay que armar el Clock explícitamente y decidir la ruleZone en algún lugar, una configuración o, mejor, un valor de negocio versionado junto con la promoción, en vez de confiar en que “ya viene resuelto” por el sistema operativo del contenedor. Lo que se gana a cambio: la pregunta “¿es elegible una compra del domingo a las 23:40, hora del cliente?” deja de ser una pregunta que solo se puede hacer esperando a que llegue ese domingo real. Se convierte en un argumento.
Los 4 puntos donde el reloj entra al dominio
Este es el artefacto del día: los cuatro lugares concretos donde el tiempo se cuela adentro de una regla de negocio sin declararse como entrada, con el diff que lo saca en cada uno.
1. El reloj llamado desde adentro de la regla.
- LocalDateTime now = LocalDateTime.now();
+ Instant now = clock.instant(); // el Clock llega por constructor
Mientras la regla llame al reloj por su cuenta, nadie de afuera puede decirle “evalúa esto en tal instante”. El método no tiene puerta de entrada para ese dato, así que no hay forma de pedirle una fecha específica: solo puede contestar con la hora de ahora mismo.
2. La zona horaria implícita.
- LocalDateTime now = LocalDateTime.now(); // ZoneId.systemDefault() por dentro
+ LocalDateTime nowInRuleZone = now.atZone(ruleZone).toLocalDateTime(); // ruleZone es un dato
ZoneId.systemDefault() es la zona con la que el sistema operativo del contenedor arrancó. Es una decisión de infraestructura, tomada el día del deploy, y sin embargo es la que termina decidiendo si una compra del domingo a las 23:40 cuenta o no. La zona horaria es una regla de negocio, no configuración del servidor: “23:59 del domingo” no significa nada hasta que alguien contesta de quién es esa hora, si del cliente, de la sucursal o de la casa central. Si nadie contesta, contesta el TZ del contenedor, y eso es justo el punto donde nadie quería que se decidiera algo tan importante.
3. La aritmética de calendario no decidida.
- return cycleStart.plusMonths(1); // la única regla que existe
+ public interface BillingCyclePolicy {
+ LocalDate nextBillingDate(LocalDate cycleStart);
+ }
+ // SameDayOfMonthCyclePolicy: cycleStart.plusMonths(1)
+ // FixedIntervalCyclePolicy: cycleStart.plusDays(intervalDays)
plusMonths(1) sobre el 31 de enero no se equivoca: java.time tiene que poner esa fecha en algún lado y el 28 de febrero es una respuesta razonable. El error no es la librería, es que sea la única respuesta disponible. Nadie decidió nunca si un ciclo mensual significa “mismo día del mes, recortado cuando el mes es corto” o “cada 30 días, corriendo el día del mes con el tiempo”. Son dos reglas de negocio distintas, con dos facturas distintas para el mismo cliente, y la aritmética de calendario se elige, no se hereda por default de la librería.
4. La persistencia que pierde la zona.
- record PromotionRedemptionRecord(String customerId, LocalDateTime purchasedAt) {}
+ record PromotionRedemption(String customerId, Instant purchasedAt) {}
Guardar un LocalDateTime es guardar una lectura de reloj de pared sin decir en qué zona estaba parado ese reloj cuando se tomó la lectura. El mismo valor persistido, leído después bajo dos supuestos distintos, dos zonas horarias posibles, produce dos instantes reales distintos, separados por horas. Un Instant no tiene esa ambigüedad: es un punto en la línea de tiempo global, el mismo para cualquiera que lo lea, en cualquier zona.
La misma causa, con azar y con red
El reloj no es especial. Es un caso particular de “el dominio le pregunta algo al ambiente en vez de recibirlo como dato”. Dos corolarios cortos, sin inflar el ejercicio del día:
- return ThreadLocalRandom.current().nextDouble() < BONUS_RATE;
+ public BonusRoll(RandomGenerator randomSource, double bonusRate) { ... }
+ return randomSource.nextDouble() < bonusRate;
Con el generador inyectado, se le puede pedir a la regla qué decide con un roll puntual, sin depender de la suerte del momento en que corra.
- private static final Duration TIMEOUT = Duration.ofSeconds(3);
+ public PaymentGatewayPolicy(Duration timeout) { this.timeout = timeout; }
El timeout de un gateway de pago es una decisión de negocio, cuánto vale la pena hacer esperar a un cliente antes de rendirse, igual que la zona horaria de la promoción. Cableado como constante, esa decisión quedó tomada por quien escribió esa línea, no por quien debería tomarla.
Lo que ahora se le puede preguntar al sistema
Antes, había una sola pregunta que la regla podía responder, y ni siquiera con precisión: “¿qué hora es ahora mismo, según el sistema operativo del contenedor?” El domingo a las 23:40 hora del cliente, el domingo exacto del cambio de horario, el 29 de febrero de un año bisiesto: ninguno de esos escenarios tenía una puerta de entrada al método. Sacar el reloj, la zona y la elección de calendario del cuerpo del método y ponerlos en el constructor no es una técnica, es la consecuencia de tratar al tiempo como lo que es: un dato del dominio. Una vez que es un parámetro, esos escenarios dejan de ser una espera y pasan a ser, literalmente, un argumento.
La suite completa de este ejercicio corre en 13 casos, sin fallos:
[INFO] Tests run: 13, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
Tres de esos trece prueban, precisamente, la imposibilidad de antes: que no hay forma de nombrarle a la regla original un escenario puntual, porque su firma no declara sus entradas. Los otros diez preguntan exactamente los escenarios que antes eran imposibles de pedir: la compra del domingo a las 23:40 hora del cliente, la misma compra evaluada en la zona del servidor, el domingo del cambio de horario comparado contra un offset fijo, el 31 de enero contra las dos políticas de ciclo de facturación, el mismo instante leído en dos zonas sin diferencia real, el bono y el timeout con una fuente de datos fija en lugar de una real.
| Métrica | Antes | Después |
|---|---|---|
Argumentos que declara isEligible() | 0 (todo llega por now() y por una constante sin zona) | 3 (clock, ruleZone, deadline) |
| ¿Se puede pedir el escenario “domingo 23:40, hora del cliente”? | No | Sí |
| ¿Se puede pedir el escenario “domingo del cambio de horario”? | No | Sí, y distingue zona real de offset fijo |
| Políticas de ciclo de facturación disponibles para elegir | 1, sin declararse como elección | 2, explícitas e intercambiables |
| ¿El valor persistido alcanza para reconstruir el instante real? | No, ambiguo por varias horas según quién lo lea | Sí, sin ambigüedad |
| ¿Se necesita tiempo real o suerte real para probar azar y timeout? | Sí | No |
La regla generalizable: el tiempo es un input, no un ambiente. La misma lógica, con los mismos argumentos, tiene que dar el mismo resultado siempre. Si no lo hace, hay una entrada que no declaraste, y el reloj del sistema operativo la está resolviendo por ti, en el momento menos conveniente: el borde exacto donde una promoción vence, un ciclo de facturación cae en un mes corto, o un reloj cambia de hora. Una vez que el instante entra por constructor, ya no hace falta esperar cuatro años a que vuelva un 29 de febrero para saber qué hace tu sistema ese día: es, literalmente, el argumento con el que lo llamas.
Un reloj cableado adentro de una regla es una de las causas más comunes de un síntoma que seguramente ya viviste del otro lado, en algún equipo: una prueba que a veces pasa y a veces falla, sin que nadie haya tocado el código de por medio. Por qué fallan esas pruebas y cómo se reparan, con harness y estrategia real, es terreno de Adriana Troche Robles, ingeniera QA senior. Lo desarrolla de punta a punta en flaky tests: por qué fallan y cómo se arreglan. Yo me quedo en la causa de diseño que los produce; ella, en cómo se reparan cuando ya te están costando confianza en el equipo.
Todo el código de este ejercicio, con las clases antes y después, está en el repo de los 100 días. Si la serie te está sirviendo, déjame una ⭐. Es gratis y ayuda a que más gente lo encuentre.
Si quieres recibir una lección semanal de arquitectura, producción e IA sin filtros, suscríbete a La Bitácora Sin Filtros.
Architecture Red Flags & The Modern Backend Blueprint
La guía definitiva para detectar fallos de diseño y el mapa de referencia para construir sistemas resilientes.
Recibí el War Manual en tu inbox:
Prometido: nada de spam, solo ingeniería cruda cada 15 días.
¿Necesitás ayuda con tu proyecto? Agendá una sesión 1:1 →