Hace poco, una discusión sobre IA en la entrega de software. Alguien lo resumió muy bien: montas el harness, le dices a la IA lo que quieres, la automatización hace el resto, listo. En lo mecánico incluso es cierto. Lo que se quedó grabado fue la palabra «listo».
Porque un servicio está desplegado, el pipeline está verde, el health check devuelve 200, y para muchos equipos eso significa exactamente «listo». Pero solo significa que el servicio arranca y responde, no que haga lo correcto bajo carga, bajo ataque, con entradas erróneas y bajo las reglas del derecho fiscal, la protección de datos y el derecho contractual. Entre «corre en el clúster» y «preparado para producción» hay trabajo, y ese trabajo no se vuelve más rápido porque un asistente de IA escriba el código. Más bien se vuelve más difícil, porque ya nadie conoce qué suposiciones lleva dentro el código generado.
Este artículo recorre el camino con un único ejemplo: un servicio de pedidos que llamamos orders-api. Spring Boot, PostgreSQL, Kubernetes, un checkout con pago, stock, factura y cancelación. Empezamos en «desplegado en verde» y avanzamos por rondas hacia la preparación para producción, guiados por una evaluación de riesgos en el sentido de arc42. Al final está la tesis que sostiene todo el artículo.
Contenido
- El deploy verde que no demuestra nada
- Tests verdes que solo reflejan el código
- La preparación para producción es un proceso, no un estado
- Ronda 1: la corrección funcional
- Ronda 2: la corrección legal como riesgo
- Ronda 3: seguridad y autenticación
- Ronda 4: madurez operativa
- Qué te quita un harness y qué no
- FAQ
- Conclusión
- Fuentes
El deploy verde que no demuestra nada
El orders-api está recién desplegado. El pipeline estaba verde, los contenedores corren, GET /actuator/health devuelve 200. Un smoke test crea un pedido, lo paga, recibe un número de pedido de vuelta. Todo funciona, y justo aquí termina el trabajo de muchos equipos.
Un deploy verde demuestra que el servicio arranca, alcanza sus dependencias y responde a una petición feliz. Eso es necesario y no dice nada sobre el resto. No dice nada sobre lo que pasa con una segunda petición de checkout idéntica. Nada sobre si otro cliente puede leer un pedido ajeno. Nada sobre el impuesto de un envío a Francia, nada sobre el comportamiento ante una migración de la tabla en vivo, nada sobre la factura que, tres sistemas más allá, tiene que cuadrar con la contabilidad.
La preparación para producción es la suma de exactamente estas preguntas. Son incómodas, porque ninguna de ellas aparece en el camino feliz que un deploy comprueba.
Tests verdes que solo reflejan el código
El reflejo habitual al trabajar con asistencia de IA agrava el problema en lugar de resolverlo: primero se genera el código, luego se generan tests para exactamente ese código. Los tests están verdes, la cobertura sube, y aun así no se ha comprobado nada que cuente. Porque un test derivado del código solo puede confirmar que el código hace lo que hace. No contiene ningún conocimiento sobre el dominio que no estuviera ya en el código.
Un pequeño ejemplo lo hace tangible. El orders-api calcula el total del pedido. El código generado calcula con double y redondea con Math.round.
public record OrderItem(String sku, double unitPrice, int quantity) {}
public record OrderTotals(double net, double tax, double gross) {}@Service
public class CheckoutService {
private static final double TAX_RATE = 0.19;
public OrderTotals calculateTotals(List<OrderItem> items) {
double net = 0.0;
for (OrderItem item : items) {
net += item.unitPrice() * item.quantity();
}
double tax = Math.round(net * TAX_RATE * 100) / 100.0;
double gross = Math.round((net + tax) * 100) / 100.0;
return new OrderTotals(net, tax, gross);
}
}El código parece limpio, compila y entrega resultados correctos para los importes de demo habituales. El test generado a continuación lo confirma con cifras redondas y, en el segundo caso, con la misma fórmula que el código productivo y una tolerancia de un céntimo.
class CheckoutServiceTest {
private final CheckoutService checkoutService = new CheckoutService();
@Test
void shouldCalculateTotalsWhenSingleItemInCart() {
OrderTotals totals = checkoutService.calculateTotals(
List.of(new OrderItem("SKU-1", 10.00, 1)));
assertEquals(10.00, totals.net(), 0.01);
assertEquals(1.90, totals.tax(), 0.01);
assertEquals(11.90, totals.gross(), 0.01);
}
@Test
void shouldCalculateTotalsWhenMultipleItemsInCart() {
OrderTotals totals = checkoutService.calculateTotals(
List.of(new OrderItem("SKU-1", 19.99, 3)));
double expectedNet = 19.99 * 3;
double expectedTax = expectedNet * 0.19;
assertEquals(expectedNet, totals.net(), 0.01);
assertEquals(expectedTax, totals.tax(), 0.01);
}
}Ambos tests están verdes. Demuestran exclusivamente que el código hace lo que el código hace. El segundo test es la forma pura del patrón: la expectativa y la implementación son el mismo cálculo, un error en la fórmula está en ambos lados del assertEquals. La tolerancia de un céntimo se traga además justo la clase de errores que importa cuando se trata de dinero.
Un test funcional nace al revés. La regla dice: el impuesto se redondea de forma comercial, HALF_UP a dos decimales. Un importe neto de 4,50 euros da 0,855 euros de impuesto, redondeado comercialmente 0,86 euros. La implementación con double entrega 0,85, porque el producto queda internamente justo por debajo de 0,855 y se redondea hacia abajo. Un único valor elegido desde el dominio lo destapa.
@Test
void shouldRoundTaxHalfUpWhenTaxLandsOnHalfCent() {
OrderTotals totals = checkoutService.calculateTotals(
List.of(new OrderItem("SKU-1", 4.50, 1)));
assertEquals(0.86, totals.tax());
}Este test está rojo. Se diferencia del test generado en dos puntos: el valor de entrada viene de la regla de negocio, no de la implementación, y la aserción es exacta, sin tolerancia. Aún más fuerte es la variante basada en propiedades con jqwik, que usa un oráculo independiente sobre BigDecimal.
class CheckoutTaxProperties {
@Property
void shouldMatchHalfUpTaxWhenPriceIsAnyCentAmount(
@ForAll @IntRange(min = 1, max = 500_000) int unitPriceInCents,
@ForAll @IntRange(min = 1, max = 20) int quantity) {
double unitPrice = unitPriceInCents / 100.0;
OrderTotals totals = new CheckoutService().calculateTotals(
List.of(new OrderItem("SKU-1", unitPrice, quantity)));
BigDecimal net = BigDecimal.valueOf(unitPriceInCents)
.multiply(BigDecimal.valueOf(quantity))
.movePointLeft(2);
BigDecimal expectedTax = net.multiply(new BigDecimal("0.19"))
.setScale(2, RoundingMode.HALF_UP);
assertEquals(expectedTax.doubleValue(), totals.tax());
}
}La diferencia decisiva frente al test espejo: el oráculo calcula por un camino distinto al del código productivo. Solo entonces el test comprueba el dominio en lugar de las suposiciones de la implementación. La versión corregida calcula de principio a fin con BigDecimal y redondea en un punto definido.
public record OrderItem(String sku, BigDecimal unitPrice, int quantity) {}
public record OrderTotals(BigDecimal net, BigDecimal tax, BigDecimal gross) {}@Service
public class CheckoutService {
private static final BigDecimal TAX_RATE = new BigDecimal("0.19");
public OrderTotals calculateTotals(List<OrderItem> items) {
BigDecimal net = items.stream()
.map(item -> item.unitPrice()
.multiply(BigDecimal.valueOf(item.quantity())))
.reduce(BigDecimal.ZERO, BigDecimal::add)
.setScale(2, RoundingMode.HALF_UP);
BigDecimal tax = net.multiply(TAX_RATE)
.setScale(2, RoundingMode.HALF_UP);
return new OrderTotals(net, tax, net.add(tax));
}
}En la base de datos le corresponde NUMERIC(12,2), en la API un string o un importe en unidades mínimas como long, nunca un float JSON. El orden es la verdadera lección: quien genera primero el código y luego tests sobre ese código obtiene tests con las mismas fórmulas, las mismas suposiciones y las mismas lagunas. La cobertura sube, el valor probatorio no. Los tests funcionales nacen de la regla y necesitan un oráculo que calcule con independencia del camino de producción.
La preparación para producción es un proceso, no un estado
La preparación para producción no se despacha en una sola pasada. Nace en rondas, y lo que toca en cada ronda no lo decide una lista fija, sino una evaluación de riesgos. arc42 ofrece para ello dos herramientas útiles. El capítulo 10 recoge los escenarios de calidad, es decir, requisitos concretos con la forma «bajo esta condición el sistema reacciona así». El capítulo 11 lleva los riesgos y la deuda técnica como una lista abierta y evaluada.
Una ronda transcurre siempre igual: evaluar el mayor riesgo de la lista, endurecerlo, después volver a evaluar. Justo eso convierte «la madurez es un proceso» en algo más que una frase. Un harness, es decir, el camino de entrega preparado, puede preparar estas rondas mecánicamente. El orden según el riesgo no te lo puede quitar. Un escenario de calidad es barato de escribir y obliga a la precisión. «Un cliente empresarial de la UE con número de IVA intracomunitario válido recibe una factura con la mención de Reverse Charge» es comprobable. «Tratar el impuesto correctamente» no lo es.
Las cuatro rondas siguientes ordenan los riesgos del orders-api por impacto: corrección funcional, corrección legal, seguridad, madurez operativa. El orden no está grabado en piedra, resulta de la evaluación. Una pérdida de datos en la migración pesa más que un dashboard que falta, así que va primero.
Ronda 1: la corrección funcional
Arriba del todo en la lista de riesgos: el servicio recibe dinero y stock, y ambos tienen que cuadrar también cuando las peticiones se repiten o se solapan. El redondeo del dinero está resuelto con BigDecimal. Quedan tres reglas que el código generado suele pasar por alto.
Idempotencia en el checkout
Un doble clic o un reintento de red del cliente no puede crear un segundo pedido ni un segundo pago. Para ello el cliente envía una clave de idempotencia generada por él mismo, y la misma clave devuelve el mismo pedido.
@Service
public class CheckoutService {
private final OrderRepository orders;
@Transactional
public Order checkout(CheckoutCommand command, String idempotencyKey) {
return orders.findByIdempotencyKey(idempotencyKey)
.orElseGet(() -> orders.save(Order.place(command, idempotencyKey)));
}
}Para que esto aguante, sobre idempotency_key hay un unique constraint. Si no, dos peticiones paralelas con la misma clave entran ambas en la rama del orElseGet, y solo el constraint impide el duplicado de forma fiable.
Sin stock negativo bajo concurrencia
Dos checkouts simultáneos sobre el último artículo disponible leen ambos el stock 1, pasan ambos la comprobación y descuentan ambos. Un check-then-act en el código de aplicación se ve correcto en cualquier test secuencial y solo se rompe bajo carga. La solución robusta está en la base de datos.
UPDATE stock
SET available = available - :quantity
WHERE sku = :sku
AND available >= :quantity;Si la sentencia modifica cero filas, no había stock suficiente y el checkout se rechaza. La comprobación y el descuento son un único paso atómico, no dos.
Transiciones de estado como máquina de estados
Un setter público o un updateStatus(orderId, status) genérico permite cualquier transición, también SHIPPED de vuelta a NEW. Solo debe estar permitido un grafo definido. La regla pertenece al agregado, no al controller.
public enum OrderStatus {
NEW, PENDING_PAYMENT, PAID, SHIPPED, CANCELLED, REFUNDED;
private static final Map<OrderStatus, Set<OrderStatus>> ALLOWED = Map.of(
NEW, Set.of(PENDING_PAYMENT),
PENDING_PAYMENT, Set.of(PAID, CANCELLED),
PAID, Set.of(SHIPPED, REFUNDED),
SHIPPED, Set.of(REFUNDED));
boolean canTransitionTo(OrderStatus target) {
return ALLOWED.getOrDefault(this, Set.of()).contains(target);
}
}Cada uno de estos casos tiene un test que nace de la regla, no del código: dos peticiones con la misma clave de idempotencia producen un solo pedido, dos descuentos paralelos nunca dejan el stock por debajo de cero, una transición prohibida lanza una excepción en lugar de sobrescribir en silencio.
Ronda 2: la corrección legal como riesgo
Tras la reevaluación, el siguiente riesgo está arriba, y es caro: un impuesto equivocado, un comprobante que no cuadra, un deber de borrado incumplido o un contrato que no se puede demostrar. Estas reglas están fuera del código, en el derecho fiscal, el derecho mercantil y la protección de datos, y por eso el punto ciego del código generado es aquí el más grande.
Una advertencia por delante, y es el verdadero núcleo de esta ronda: lo que sigue son reglas de dominio y de riesgo para el modelado. Esto no es asesoramiento fiscal ni legal. La certeza de que un caso concreto es realmente correcto no viene del código ni de este artículo, sino de la colaboración con una asesoría fiscal y un abogado. Ese paso es exactamente el que convierte «creemos que cuadra» en una base sólida. Después, el código implementa lo que allí se aclaró.
El impuesto es dominio, no constante
Aquí también vale: esto no es asesoramiento fiscal ni legal. «19 por ciento sobre todo» es solo una de muchas ramas, casualmente la más frecuente en el negocio B2C alemán. El tipo resulta del tipo de cliente, el país de entrega, el estado del número de IVA intracomunitario y la clase fiscal del producto. B2C nacional lleva el tipo general o el reducido, B2C transfronterizo dentro de la UE por encima del umbral lleva el tipo del país de destino a través del procedimiento One-Stop-Shop (OSS), en B2B dentro de la UE con número de IVA intracomunitario válido aplica el procedimiento de Reverse Charge con cero por ciento, mención obligatoria en la factura y declaración en el estado recapitulativo (Zusammenfassende Meldung), y una exportación a un tercer país está exenta. La verificación del número de IVA intracomunitario mediante el procedimiento de confirmación VIES es aquí una precondición de negocio con estado propio, no una comprobación de formato.
public enum CustomerType { CONSUMER, BUSINESS }
public enum VatIdStatus { NOT_PROVIDED, VALID, INVALID, UNVERIFIABLE }
public enum ProductTaxClass { STANDARD, REDUCED }
public record TaxDecision(BigDecimal rate, boolean reverseCharge, String invoiceNote) {
static TaxDecision domestic(BigDecimal rate) {
return new TaxDecision(rate, false, null);
}
static TaxDecision reverseChargeToRecipient() {
return new TaxDecision(BigDecimal.ZERO, true,
"Steuerschuldnerschaft des Leistungsempfängers");
}
static TaxDecision taxFreeExport() {
return new TaxDecision(BigDecimal.ZERO, false, "Steuerfreie Ausfuhrlieferung");
}
}public class TaxDecisionResolver {
private final DestinationRateCatalog destinationRates;
private final EuVatArea euVatArea;
public TaxDecision resolve(CustomerType customerType,
String deliveryCountry,
VatIdStatus vatIdStatus,
ProductTaxClass taxClass) {
if (!euVatArea.contains(deliveryCountry)) {
return TaxDecision.taxFreeExport();
}
if (euVatArea.isDomestic(deliveryCountry)) {
return TaxDecision.domestic(destinationRates.rateFor(deliveryCountry, taxClass));
}
if (customerType == CustomerType.BUSINESS && vatIdStatus == VatIdStatus.VALID) {
return TaxDecision.reverseChargeToRecipient();
}
if (customerType == CustomerType.BUSINESS && vatIdStatus == VatIdStatus.UNVERIFIABLE) {
throw new VatIdVerificationPendingException(deliveryCountry);
}
return TaxDecision.domestic(destinationRates.rateFor(deliveryCountry, taxClass));
}
}Los textos de las menciones en el código son las formulaciones alemanas exigidas por ley para la factura: «Steuerschuldnerschaft des Leistungsempfängers» significa que la deuda tributaria pasa al destinatario de la prestación, «Steuerfreie Ausfuhrlieferung» designa una entrega de exportación exenta. El test funcional viene de la regla fiscal y está rojo frente a una implementación que devuelve un 19 por ciento fijo.
@Test
void shouldApplyReverseChargeWhenEuBusinessCustomerHasValidVatId() {
TaxDecision decision = resolver.resolve(
CustomerType.BUSINESS, "FR", VatIdStatus.VALID, ProductTaxClass.STANDARD);
assertThat(decision.rate()).isEqualByComparingTo(BigDecimal.ZERO);
assertThat(decision.reverseCharge()).isTrue();
assertThat(decision.invoiceNote())
.isEqualTo("Steuerschuldnerschaft des Leistungsempfängers");
}La cosa se pone delicada en los bordes que un test espejo nunca ve: si VIES no está disponible en el momento del pedido, el estado no es ni válido ni inválido, y el comportamiento de fallback es una decisión de negocio. Un cliente empresarial nacional con número de IVA alemán no recibe Reverse Charge, sino el 19 por ciento. El tipo impositivo pertenece a la posición, no al pedido, porque un carrito puede mezclar tipo general y reducido. Y el tipo tiene que congelarse en el momento determinante, no volver a consultarse en cada cálculo.
La factura tiene que cuadrar con la contabilidad
Una factura no es un PDF bonito al final, sino un comprobante jurídicamente vinculante cuyas cifras tienen que reencontrarse con exactitud en el sistema contable. El sistema no comprueba con benevolencia «más o menos igual», contabiliza posición por posición. Un descuento porcentual sobre el total del pedido, repartido entre las posiciones, se desvía por el redondeo por posición un céntimo del total redondeado, y la conciliación se rompe en cada pedido afectado. Un cupón no es una simple reducción de importe, sino que pertenece al comprobante como posición propia con su referencia fiscal correcta. Si el cobro se desvía del importe de la factura, por sobrepago, pago parcial o comisiones retenidas, eso necesita un estado propio, si no el pedido queda permanentemente como «pagado, pero sin aclarar» entre la tienda y la contabilidad.
Borrar y aun así conservar
Un pedido se compone casi por completo de datos personales, así que el Reglamento General de Protección de Datos aplica al núcleo del modelo de datos, no a un módulo marginal. El conflicto más interesante está en el borrado: frente al derecho de supresión del artículo 17 está el deber legal de conservación de facturas según el artículo 147 de la Ley Tributaria alemana (AO) y el artículo 257 del Código de Comercio alemán (HGB), según el tipo de comprobante de ocho a diez años. El artículo 17, apartado 3, exceptúa exactamente este caso del deber de supresión. La solución no es el hard delete, sino la anonimización sobre los datos operativos con conservación separada de los datos de comprobante relevantes a efectos fiscales.
UPDATE customer_account
SET first_name = 'entfernt',
last_name = 'entfernt',
email = 'geloescht+' || id || '@anonym.invalid',
phone = NULL,
marketing_optin = FALSE,
anonymized_at = now()
WHERE id = :customerId;
UPDATE order_delivery_address
SET recipient_name = 'entfernt',
street = 'entfernt',
city = 'entfernt'
WHERE customer_id = :customerId;El endpoint de borrado generado lo convierte en un deleteById con ON DELETE CASCADE, cumple el artículo 17 y viola con ello el deber de conservación. El test espejo comprueba que el registro falta después, y confirma justo el camino equivocado. Al lado acechan dos trampas más: los datos personales también están en logs, colas de correo e índices de búsqueda, un derecho de acceso según el artículo 15 servido solo desde la tabla principal es incompleto, y un hash sin sal del correo electrónico es reversible, así que no es anonimización, sino seudonimización.
Hacer demostrables los términos y condiciones
Con el clic en el botón de compra nace un contrato, y en caso de disputa tiene que poder demostrarse a qué condiciones dio su consentimiento el cliente y cuándo. Un boolean termsAccepted no responde a eso, porque las condiciones cambian. El contrato solo es demostrable si el pedido registra qué versión de las condiciones fue aceptada y cuándo, y si cada versión está archivada de forma inmutable.
public record TermsAcceptance(String termsVersion, Instant acceptedAt) {
public TermsAcceptance {
Objects.requireNonNull(termsVersion);
Objects.requireNonNull(acceptedAt);
}
}
public class Order {
private final OrderId id;
private final TermsAcceptance termsAcceptance;
public static Order place(Cart cart, CustomerId customerId,
TermsAcceptance termsAcceptance,
TermsCatalog termsCatalog) {
if (!termsCatalog.isPublishedVersion(termsAcceptance.termsVersion())) {
throw new UnknownTermsVersionException(termsAcceptance.termsVersion());
}
return new Order(OrderId.generate(), cart, customerId, termsAcceptance);
}
}El consentimiento es una invariante del agregado de pedido, no una validación de formulario. Si la casilla solo se comprueba en el frontend, una llamada directa a la API crea el pedido de todas formas. Y la confirmación del pedido transporta las condiciones en la versión del momento de la compra, no un enlace a la versión vigente en cada momento.
Ronda 3: seguridad y autenticación
Las propiedades de seguridad son requisitos negativos: se trata de peticiones que el camino feliz nunca hace. Por eso un deploy verde y un pipeline verde no dicen nada sobre ellas. En arc42, este clúster va anclado por partida doble, como escenario de calidad en el capítulo 10 y como riesgo evaluado en el capítulo 11.
La autenticación en Spring Security es en gran medida configuración y suele ser el problema menor: clientes con un access token validado, administradores con rol propio en el token, máquinas mediante client credentials. El riesgo está un nivel más abajo, en la autorización a nivel de objeto.
Un cliente solo puede ver sus propios pedidos
Si falta esta comprobación, cualquier usuario autenticado puede consultar pedidos ajenos con una order id cualquiera. OWASP lo lista como Broken Object Level Authorization, el clásico IDOR, desde hace años en el puesto 1 del API Security Top 10. No es un ataque exótico, sino una URL con otro número dentro. Así se ve el endpoint que entrega el CRUD generado.
@RestController
@RequestMapping("/orders")
class OrderController {
private final OrderRepository orderRepository;
OrderController(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@GetMapping("/{id}")
ResponseEntity<OrderResponse> getOrder(@PathVariable UUID id) {
return orderRepository.findById(id)
.map(OrderResponse::from)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
}Su test espejo crea un usuario, registra su pedido, lo consulta con su token y está verde. El caso «segundo usuario, id ajena» nunca aparece ahí, porque nunca apareció en el código.
@Test
void shouldReturnOrderWhenOwnerRequestsOwnOrder() throws Exception {
Order order = persistedOrderOf("customer-anna");
mockMvc.perform(get("/orders/{id}", order.getId())
.with(jwt().jwt(token -> token.subject("customer-anna"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(order.getId().toString()));
}El test que formula el requisito de verdad está rojo frente a este controller. Es el test más valioso del artículo, porque es el único que falla contra el código generado.
@Test
void shouldReturnNotFoundWhenCallerRequestsForeignOrder() throws Exception {
Order order = persistedOrderOf("customer-anna");
mockMvc.perform(get("/orders/{id}", order.getId())
.with(jwt().jwt(token -> token.subject("customer-bruno"))))
.andExpect(status().isNotFound());
}La corrección robusta lleva la ownership a la propia consulta, para que no se pueda olvidar, y responde a una id ajena con 404, para que la existencia de pedidos ajenos no se filtre.
@Service
class OrderQueryService {
private final OrderRepository orderRepository;
OrderQueryService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
OrderResponse loadOwnOrder(UUID orderId, String customerId) {
return orderRepository.findByIdAndCustomerId(orderId, customerId)
.map(OrderResponse::from)
.orElseThrow(() -> new OrderNotFoundException(orderId));
}
}No confiar a ciegas en el webhook
El webhook de pago es un endpoint accesible desde fuera que modifica el estado del dinero. Quien confía en el payload acepta un POST casero con "status": "PAID" como confirmación de pago. Son obligatorias dos comprobaciones: la firma sobre el cuerpo de la petición mediante HMAC con el secreto compartido, comparada en tiempo constante, y una protección contra replay mediante la event id, que deduplica los eventos ya procesados. Sin la comprobación de la event id, un payment.succeeded firmado válidamente y entregado de nuevo días después salta el estado de REFUNDED de vuelta a PAID. El test espejo postea un payload sin firmar y comprueba que el estado después es PAID, con lo que documenta literalmente la vulnerabilidad.
Lo que el cliente nunca puede establecer
Campos como status, grossTotal y customerId se determinan en el servidor. Los DTO de entrada son proyecciones explícitas que solo contienen los campos que el cliente puede aportar legítimamente.
record CreateOrderRequest(List<OrderItemRequest> items, ShippingAddressRequest shippingAddress) {}Si el controller enlaza en su lugar la entity o un DTO espejado por completo, un cliente simplemente envía "grossTotal": 0.01 y "status": "PAID". El test espejo incluso confirma que todos los campos llegan, y con ello defiende el bug en cada endurecimiento posterior.
Dos puntos más pertenecen a la lista de riesgos: las ids numéricas consecutivas hacen enumerable el inventario, las ids no adivinables como UUID o ULID lo desactivan estructuralmente, y sin rate limit el checkout permite spam de pedidos que bloquea reservas. Y los datos personales o las referencias de pago no pintan nada en logs ni en mensajes de error. Un exception handler que escribe ex.getMessage() en el cuerpo de la respuesta convierte un mensaje de error con referencia de pago en una fuga de datos que el test espejo fija como propiedad garantizada.
Ronda 4: madurez operativa
La última ronda hace el servicio operable: cambios sin caída, comportamiento limpio en los reinicios, visibilidad en operación y una disponibilidad definida.
Migraciones sin downtime
El error más caro en un cambio de esquema sobre una tabla en vivo es una migración que rompe la versión antigua del código mientras se está desplegando. Durante un despliegue rolling, los pods antiguos y los nuevos corren en paralelo durante un breve tiempo, ambos contra el mismo esquema. El patrón para esto se llama expand and contract, y cada paso es retrocompatible por sí mismo. Supongamos que orders recibe una nueva columna para el importe bruto en unidades mínimas. Primero la migración aditiva.
ALTER TABLE orders ADD COLUMN gross_total_minor BIGINT;Esta migración no rompe ningún pod antiguo, porque una columna adicional y anulable no molesta a las consultas existentes. El código nuevo escribe ambos campos y lee con preferencia el nuevo. Cuando los datos están rellenados y el código antiguo está completamente sustituido, una migración posterior hace la columna obligatoria y elimina la antigua.
ALTER TABLE orders ALTER COLUMN gross_total_minor SET NOT NULL;
ALTER TABLE orders DROP COLUMN gross_total;El contrato de rollback queda así claro: cada migración individual es compatible con la versión de código inmediatamente anterior, así que el código puede revertirse sin que el esquema se rompa. Esa es exactamente la diferencia entre «la migración terminó» y «la migración es segura».
Comportamiento limpio en los reinicios
Kubernetes necesita dos señales separadas. La liveness probe dice si el proceso sigue vivo o hay que reiniciarlo. La readiness probe dice si puede aceptar tráfico en este momento. Quien confunde ambas mata un pod que solo está ocupado un momento, o envía peticiones a uno que todavía está arrancando.
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
periodSeconds: 5A esto pertenece un apagado limpio, para que un pod termine las peticiones en curso antes de desaparecer.
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30sVisibilidad en lugar de vuelo a ciegas
Preparación para producción significa conocer el comportamiento bajo carga. Son útiles las señales RED, es decir, rate, errors y duration por endpoint, logs estructurados con una id de correlación como clave y tracing distribuido a través de las fronteras hacia el proveedor de pagos y la base de datos. De un escenario de calidad sale un objetivo medible, un Service Level Objective. Un ejemplo: el 99,5 por ciento de las peticiones de checkout durante 30 días se responde en menos de 500 milisegundos. Eso es comprobable, y avisa de cuándo la cosa se aprieta antes de que los clientes lo noten.
Disponibilidad definida
El SLO es la base para alarmas que cuelgan de un problema real de los usuarios y no de una cifra de CPU cualquiera. Una alarma sobre la tasa de consumo del presupuesto de error del SLO salta cuando el presupuesto se gasta demasiado rápido. A esto pertenece un runbook que dice qué hacer en caso de alarma. Solo con esta preparación el servicio no está solo desplegado, sino que es operable.
Qué te quita un harness y qué no
Volvamos a la imagen del principio. Un harness, es decir, el camino de entrega preparado con pipeline, convención de migraciones, probes, gestión de secretos, equipamiento base de observabilidad y un endpoint estándar seguro, es exactamente el lugar correcto para invertir. Te quita la mecánica que se repite en cada servicio, y ninguna de las cuatro rondas se encarece por ello. Eso es ahorro real, y meter energía ahí es correcto.
Lo que el harness no te quita es la evaluación. Qué suposición es arriesgada en tu inventario, qué caso fiscal aplica, qué escenario de calidad cuenta y qué riesgo hay que endurecer primero, eso sigue siendo trabajo de pensar. Un harness acelera la mecánica, no la madurez. Ejecuta decisiones que antes alguien tiene que haber entendido, y el código generado solo hace efectivas esas suposiciones más rápido, tanto las correctas como las equivocadas.
Con esto se cierra el círculo hacia el principio. «Montar el harness y listo» es correcto en la mecánica y se queda corto en la palabra «listo». El harness te lleva de forma fiable hasta «corre en el clúster». Las cuatro rondas hasta «preparado para producción» las recorres tú.
FAQ
¿No es un CI verde una buena señal? Sí, es necesario. Demuestra que el servicio compila, arranca y responde al camino feliz. Solo que no es suficiente, porque la preparación para producción depende de los casos que el camino feliz nunca toca.
¿Por qué los tests generados por IA no encuentran estos errores? Porque se derivan del código. Un test que refleja la implementación comprueba que el código hace lo que hace. Pero los errores de dominio están en las suposiciones del código, y esas suposiciones están en ambos lados de la comparación. Un test con valor probatorio necesita un oráculo que calcule con independencia del camino de producción.
¿Basta una cobertura de tests alta como prueba de preparación para producción? No. La cobertura mide qué líneas se ejecutaron, no qué requisitos se comprobaron. Se puede llegar al 100 por ciento de cobertura con tests espejo y aun así no cubrir ni un solo caso funcional o de seguridad.
¿Cuál es la diferencia entre liveness probe y readiness probe? La liveness probe decide si el proceso tiene que reiniciarse. La readiness probe decide si debe recibir tráfico en este momento. Un pod puede estar vivo y aun así no estar listo, por ejemplo mientras arranca o cuando una dependencia falta un momento.
¿Cómo cumplo a la vez el derecho de supresión y el deber de conservación? Mediante anonimización en lugar de hard delete. La referencia personal en los datos operativos se elimina, los datos de comprobante relevantes a efectos fiscales se guardan por separado y solo se borran al vencer su plazo. El caso concreto hay que aclararlo con la asesoría fiscal y el abogado. Aquí también vale: esto no es asesoramiento fiscal ni legal.
¿Qué es BOLA o IDOR y por qué es tan frecuente? Broken Object Level Authorization significa que una comprobación de ownership ausente permite el acceso a objetos ajenos. Es frecuente porque el patrón CRUD habitual no conoce el contexto del llamante y un endpoint sin comprobación de ownership está igual de verde en el test que uno con ella.
¿Necesita un proyecto pequeño arc42? No el documento completo. Dos capítulos bastan como herramienta: una lista corta de escenarios de calidad y una lista abierta de riesgos. Ambas cuestan poco y hacen que las rondas hacia la preparación para producción sean siquiera planificables.
Conclusión
La preparación para producción no es un estado que un deploy verde establezca, sino un proceso por rondas guiado por el riesgo. El camino hasta allí es el trabajo de escribir siquiera los escenarios de calidad y los riesgos, antes de que nadie, persona o modelo, genere el código correspondiente. Quien genera primero el código y luego tests sobre ese código obtiene un círculo cerrado en el que el requisito nunca aparece. El siguiente paso es pequeño y eficaz: toma el servicio en el que estás trabajando ahora y escribe cinco escenarios de calidad que tu camino feliz no cubre. Los primeros cinco tests rojos marcan la frontera entre «corre en el clúster» y «preparado para producción».
Fuentes
- arc42, plantilla para documentación de arquitectura, capítulo 10 (requisitos de calidad) y capítulo 11 (riesgos y deuda técnica), arc42.org
- OWASP API Security Top 10, en particular API1 Broken Object Level Authorization, owasp.org
- Reglamento (UE) 2016/679 (Reglamento General de Protección de Datos), artículos 15, 17 y 20
- Artículo 147 de la Ley Tributaria alemana (AO) y artículo 257 del Código de Comercio alemán (HGB) (plazos de conservación)
- Derecho del IVA sobre One-Stop-Shop, procedimiento de Reverse Charge y estado recapitulativo (Zusammenfassende Meldung), Oficina Central Federal de Impuestos de Alemania
Todos los ejemplos de código son propios y sirven al ejemplo continuo orders-api. El artículo es un relato de experiencia práctica y no constituye asesoramiento fiscal ni legal. Para el caso concreto hay que contar con una asesoría fiscal y un abogado.