Zum Inhalt springen
Architektur

Von deployt zu produktionsreif: die vier Runden, die ein Service noch braucht

Neulich eine Diskussion über KI in der Auslieferung. Jemand brachte es schön auf den Punkt: Harness ziehen, der KI sagen was man will, den Rest macht die Automatisierung, fertig. An der Mechanik stimmt das sogar. Hängen blieb das Wort „fertig“.

Denn ein Service ist deployt, die Pipeline ist grün, der Health-Check liefert 200, und für viele Teams heißt genau das „fertig“. Es heißt aber nur, dass der Service startet und antwortet, nicht, dass er unter Last, unter Angriff, unter fehlerhaften Eingaben und unter den Regeln von Steuer, Datenschutz und Vertragsrecht das Richtige tut. Zwischen „läuft im Cluster“ und „produktionsreif“ liegt Arbeit, und diese Arbeit wird nicht schneller, wenn ein KI-Assistent den Code schreibt. Sie wird eher schwerer, weil niemand mehr kennt, welche Annahmen im generierten Code stecken.

Dieser Beitrag spielt den Weg an einem einzigen Beispiel durch: einem Bestell-Service, den wir orders-api nennen. Spring Boot, PostgreSQL, Kubernetes, ein Checkout mit Zahlung, Bestand, Rechnung und Stornierung. Wir starten bei „grün deployt“ und arbeiten uns in Runden zur Produktionsreife vor, geführt von einer Risikobewertung im Sinne von arc42. Am Ende steht die These, die den ganzen Artikel trägt.

Inhalt

Der grüne Deploy, der nichts beweist

Der orders-api ist frisch deployt. Die Pipeline war grün, die Container laufen, GET /actuator/health gibt 200. Ein Smoke-Test legt eine Bestellung an, bezahlt sie, bekommt eine Bestellnummer zurück. Alles funktioniert, und genau hier hört die Arbeit vieler Teams auf.

Ein grüner Deploy beweist, dass der Service startet, seine Abhängigkeiten erreicht und eine glückliche Anfrage beantwortet. Das ist notwendig und sagt nichts über den Rest aus. Er sagt nichts darüber, was bei einem zweiten, identischen Checkout-Request passiert. Nichts darüber, ob ein anderer Kunde eine fremde Bestellung lesen kann. Nichts über die Steuer auf eine Lieferung nach Frankreich, nichts über das Verhalten bei einer Migration der Live-Tabelle, nichts über die Rechnung, die drei Systeme weiter mit der Buchhaltung abgeglichen werden muss.

Produktionsreife ist die Summe genau dieser Fragen. Sie sind unbequem, weil keine davon im glücklichen Pfad auftaucht, den ein Deploy prüft.

Grüne Tests, die den Code nur spiegeln

Der verbreitete Reflex im Umgang mit KI-Assistenz verschärft das Problem, statt es zu lösen: Erst wird der Code generiert, dann werden Tests für genau diesen Code generiert. Die Tests sind grün, die Abdeckung steigt, und trotzdem ist nichts geprüft, was zählt. Denn ein Test, der aus dem Code abgeleitet wurde, kann nur bestätigen, dass der Code tut, was er tut. Er enthält kein Wissen über die Fachlichkeit, das nicht schon im Code stand.

Ein kleines Beispiel macht das greifbar. Der orders-api berechnet die Bestellsumme. Der generierte Code rechnet mit double und rundet mit 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);
    }
}

Der Code sieht sauber aus, kompiliert und liefert für die üblichen Demo-Beträge korrekte Ergebnisse. Der danach generierte Test bestätigt das mit glatten Zahlen und, im zweiten Fall, mit derselben Formel wie der Produktivcode und einer Toleranz von einem Cent.

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);
    }
}

Beide Tests sind grün. Sie beweisen ausschließlich, dass der Code tut, was der Code tut. Der zweite Test ist die Reinform des Musters: Erwartung und Implementierung sind dieselbe Rechnung, ein Fehler in der Formel steht auf beiden Seiten des assertEquals. Die Toleranz von einem Cent verschluckt zusätzlich genau die Fehlerklasse, um die es bei Geld geht.

Ein fachlicher Test entsteht andersherum. Die Regel lautet: Steuer wird kaufmännisch gerundet, HALF_UP auf zwei Nachkommastellen. Ein Nettobetrag von 4,50 Euro ergibt 0,855 Euro Steuer, kaufmännisch gerundet 0,86 Euro. Die double-Implementierung liefert 0,85, weil das Produkt intern knapp unter 0,855 liegt und abgerundet wird. Ein einziger fachlich gewählter Wert deckt das auf.

@Test
void shouldRoundTaxHalfUpWhenTaxLandsOnHalfCent() {
    OrderTotals totals = checkoutService.calculateTotals(
            List.of(new OrderItem("SKU-1", 4.50, 1)));

    assertEquals(0.86, totals.tax());
}

Dieser Test ist rot. Er unterscheidet sich in zwei Punkten vom generierten Test: Der Eingabewert kommt aus der Fachregel, nicht aus der Implementierung, und die Assertion ist exakt, ohne Toleranz. Noch stärker ist die eigenschaftsbasierte Variante mit jqwik, die ein unabhängiges Orakel auf Basis von BigDecimal verwendet.

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());
    }
}

Der entscheidende Unterschied zum Spiegel-Test: Das Orakel rechnet auf einem anderen Weg als der Produktivcode. Nur dann prüft der Test die Fachlichkeit statt die Annahmen der Implementierung. Die korrigierte Version rechnet durchgehend mit BigDecimal und rundet an einer definierten Stelle.

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));
    }
}

In der Datenbank entspricht dem NUMERIC(12,2), in der API ein String oder ein Betrag in kleinsten Einheiten als long, niemals ein JSON-Float. Die Reihenfolge ist die eigentliche Lehre: Wer erst Code und dann Tests auf diesen Code erzeugt, bekommt Tests mit denselben Formeln, denselben Annahmen und denselben Lücken. Die Abdeckung steigt, die Aussagekraft nicht. Fachliche Tests entstehen aus der Regel und brauchen ein Orakel, das unabhängig vom Produktionsweg rechnet.

Produktionsreife ist ein Prozess, kein Zustand

Produktionsreife lässt sich nicht in einem Durchlauf abhaken. Sie entsteht in Runden, und was in jeder Runde drankommt, entscheidet nicht eine feste Liste, sondern eine Risikobewertung. arc42 gibt dafür zwei nützliche Werkzeuge. Kapitel 10 hält die Qualitätsszenarien fest, also konkrete Anforderungen in der Form „unter dieser Bedingung reagiert das System so“. Kapitel 11 führt die Risiken und technischen Schulden als offene, bewertete Liste.

Eine Runde läuft immer gleich: das größte Risiko aus der Liste bewerten, es härten, danach neu bewerten. Genau das macht aus „Reife ist ein Prozess“ mehr als einen Spruch. Ein Harness, also der vorbereitete Auslieferungspfad, kann diese Runden mechanisch vorbereiten. Die Reihenfolge nach Risiko kann er nicht abnehmen. Ein Qualitätsszenario ist billig aufzuschreiben und zwingt zur Präzision. „Ein EU-Geschäftskunde mit gültiger Umsatzsteuer-Identifikationsnummer erhält eine Rechnung mit Reverse-Charge-Hinweis“ ist prüfbar. „Steuer korrekt behandeln“ ist es nicht.

Die folgenden vier Runden ordnen die Risiken des orders-api nach Wirkung: fachliche Korrektheit, rechtliche Korrektheit, Security, operative Reife. Die Reihenfolge ist nicht in Stein gemeißelt, sie ergibt sich aus der Bewertung. Ein Datenverlust bei der Migration wiegt schwerer als ein fehlendes Dashboard, also kommt er zuerst.

Runde 1: fachliche Korrektheit

Die Risikoliste ganz oben: Der Service nimmt Geld und Bestand entgegen, und beides muss auch dann stimmen, wenn Requests sich wiederholen oder überlappen. Die Geldrundung ist mit BigDecimal erledigt. Es bleiben drei Regeln, die generierter Code typischerweise übersieht.

Idempotenz beim Checkout

Ein Doppelklick oder ein Netzwerk-Retry des Clients darf keine zweite Bestellung und keine zweite Zahlung erzeugen. Der Client schickt dazu einen selbst erzeugten Idempotenz-Key, und derselbe Key liefert dieselbe Order zurück.

@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)));
    }
}

Damit das trägt, liegt auf idempotency_key ein Unique-Constraint. Zwei parallele Requests mit demselben Key laufen sonst beide in den orElseGet-Zweig, und erst der Constraint verhindert die Dublette zuverlässig.

Kein negativer Bestand unter Nebenläufigkeit

Zwei gleichzeitige Checkouts auf den letzten verfügbaren Artikel lesen beide den Bestand 1, bestehen beide die Prüfung und ziehen beide ab. Ein check-then-act im Anwendungscode sieht in jedem sequentiellen Test korrekt aus und bricht erst unter Last. Die robuste Lösung liegt in der Datenbank.

UPDATE stock
SET available = available - :quantity
WHERE sku = :sku
  AND available >= :quantity;

Verändert das Statement null Zeilen, war nicht genug Bestand da, und der Checkout wird abgelehnt. Die Prüfung und der Abzug sind ein einziger atomarer Schritt, keine zwei.

Statusübergänge als Zustandsautomat

Ein öffentlicher Setter oder ein generisches updateStatus(orderId, status) erlaubt jeden Übergang, auch SHIPPED zurück zu NEW. Erlaubt sein soll nur ein definierter Graph. Die Regel gehört ins Aggregat, nicht in den 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);
    }
}

Jeder dieser Fälle hat einen Test, der aus der Regel stammt, nicht aus dem Code: zwei Requests mit demselben Idempotenz-Key ergeben eine Order, zwei parallele Abzüge lassen den Bestand nie unter null fallen, ein verbotener Übergang wirft statt still zu überschreiben.

Runde 2: rechtliche Korrektheit als Risiko

Nach der Neubewertung steht das nächste Risiko oben, und es ist teuer: falsche Steuer, ein nicht abgleichbarer Beleg, eine verletzte Löschpflicht oder ein nicht nachweisbarer Vertragsschluss. Diese Regeln stehen außerhalb des Codes, in Steuerrecht, Handelsrecht und Datenschutz, und deshalb ist der blinde Fleck von generiertem Code hier am größten.

Ein Hinweis vorweg, und er ist der eigentliche Kern dieser Runde: Was hier steht, sind Domänen- und Risikoregeln für die Modellierung. Das ist keine Steuer- oder Rechtsberatung. Die Sicherheit, dass ein konkreter Fall wirklich stimmt, kommt nicht aus dem Code und nicht aus diesem Artikel, sondern aus der Zusammenarbeit mit einer Steuerkanzlei und einem Anwalt. Genau dieser Schritt macht aus „wir glauben, es passt“ eine belastbare Grundlage. Der Code setzt danach um, was dort geklärt wurde.

Steuer ist Fachlichkeit, nicht Konstante

Auch hier gilt: keine Steuer- oder Rechtsberatung. „19 Prozent auf alles“ ist nur einer von vielen Zweigen, zufällig der häufigste im deutschen B2C-Geschäft. Der Satz ergibt sich aus Kundentyp, Lieferland, Status der Umsatzsteuer-Identifikationsnummer und Steuerklasse des Produkts. B2C im Inland trägt Regel- oder ermäßigten Satz, B2C grenzüberschreitend in der EU oberhalb der Schwelle den Satz des Ziellandes über das One-Stop-Shop-Verfahren, B2B in der EU mit gültiger Umsatzsteuer-Identifikationsnummer greift das Reverse-Charge-Verfahren mit null Prozent, Pflichthinweis auf der Rechnung und Meldung in der Zusammenfassenden Meldung, eine Ausfuhr ins Drittland ist steuerfrei. Die Prüfung der Umsatzsteuer-Identifikationsnummer über das VIES-Bestätigungsverfahren ist dabei eine fachliche Vorbedingung mit eigenem Zustand, nicht eine Formatprüfung.

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));
    }
}

Der fachliche Test kommt aus der Steuerregel und ist gegen eine Implementierung, die pauschal 19 Prozent zurückgibt, rot.

@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");
}

Heikel wird es an den Rändern, die ein Spiegel-Test nie sieht: Ist VIES zum Bestellzeitpunkt nicht erreichbar, ist der Zustand weder gültig noch ungültig, und das Fallback-Verhalten ist eine fachliche Entscheidung. Ein inländischer Geschäftskunde mit deutscher Umsatzsteuer-Identifikationsnummer bekommt kein Reverse Charge, sondern 19 Prozent. Der Steuersatz gehört an die Position, nicht an die Bestellung, denn ein Warenkorb kann Regel- und ermäßigten Satz mischen. Und der Satz muss zum maßgeblichen Zeitpunkt eingefroren werden, nicht bei jeder Berechnung neu gezogen.

Die Rechnung muss mit der Buchhaltung abgleichen

Eine Rechnung ist kein hübsches PDF am Ende, sondern ein rechtlich bindender Beleg, dessen Zahlen sich im Buchhaltungssystem exakt wiederfinden müssen. Das System prüft nicht wohlwollend „ungefähr gleich“, es bucht Position für Position. Ein prozentualer Rabatt auf die Bestellsumme, auf die Positionen verteilt, weicht durch Rundung pro Position um einen Cent von der gerundeten Gesamtsumme ab, und die Abstimmung bricht bei jeder betroffenen Bestellung. Ein Gutschein ist keine simple Betragsreduktion, sondern gehört als eigene Position mit korrektem Steuerbezug auf den Beleg. Weicht der Zahlungseingang vom Rechnungsbetrag ab, durch Überzahlung, Teilzahlung oder einbehaltene Gebühren, braucht das einen eigenen Zustand, sonst steht die Bestellung dauerhaft als „bezahlt, aber unklar“ zwischen Shop und Buchhaltung.

Löschen und trotzdem aufbewahren

Eine Bestellung besteht fast vollständig aus personenbezogenen Daten, also gilt die Datenschutz-Grundverordnung für den Kern des Datenmodells, nicht für ein Randmodul. Der spannendste Konflikt steckt in der Löschung: Dem Recht auf Löschung aus Artikel 17 steht die gesetzliche Aufbewahrungspflicht für Rechnungen nach Paragraf 147 der Abgabenordnung und Paragraf 257 des Handelsgesetzbuchs gegenüber, je nach Belegart acht bis zehn Jahre. Artikel 17 Absatz 3 nimmt genau diesen Fall von der Löschpflicht aus. Die Lösung ist nicht der Hard-Delete, sondern Anonymisierung auf dem operativen Bestand bei getrennter Aufbewahrung der steuerrelevanten Belegdaten.

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;

Der generierte Lösch-Endpunkt macht daraus ein deleteById mit ON DELETE CASCADE, erfüllt Artikel 17 und verletzt dabei die Aufbewahrungspflicht. Der Spiegel-Test prüft, dass der Datensatz danach fehlt, und bestätigt genau den falschen Weg. Zwei weitere Fallen lauern daneben: Personenbezug steckt auch in Logs, Mail-Queues und Suchindizes, eine Auskunft nach Artikel 15 nur über die Haupttabelle ist unvollständig, und ein ungesalzener Hash der E-Mail ist rückrechenbar, also keine Anonymisierung, sondern Pseudonymisierung.

AGB nachweisbar machen

Mit dem Klick auf den Bestellbutton entsteht ein Vertrag, und im Streitfall muss belegbar sein, welchen Bedingungen der Kunde wann zugestimmt hat. Ein Boolean termsAccepted beantwortet das nicht, denn AGB ändern sich. Nachweisbar ist der Vertragsschluss nur, wenn die Bestellung festhält, welche AGB-Version akzeptiert wurde und wann, und wenn jede Fassung unveränderlich archiviert ist.

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);
    }
}

Die Zustimmung ist eine Invariante des Bestell-Aggregats, keine Formularvalidierung. Wird das Häkchen nur im Frontend geprüft, legt ein direkter API-Aufruf die Bestellung trotzdem an. Und die Bestellbestätigung transportiert die Bedingungen in der Fassung zum Bestellzeitpunkt, nicht einen Link auf die jeweils aktuelle Version.

Runde 3: Security und Auth

Security-Eigenschaften sind negative Anforderungen: Es geht um Requests, die der glückliche Pfad nie stellt. Deshalb sagen ein grüner Deploy und eine grüne Pipeline über sie nichts aus. In arc42 gehört dieses Cluster doppelt verankert, als Qualitätsszenario in Kapitel 10 und als bewertetes Risiko in Kapitel 11.

Die Authentifizierung ist bei Spring Security weitgehend Konfiguration und meist das kleinere Problem: Kunden über ein validiertes Access Token, Admins mit eigener Rolle im Token, Maschinen über Client Credentials. Das Risiko sitzt eine Ebene tiefer, in der Autorisierung auf Objektebene.

Ein Kunde darf nur seine eigenen Bestellungen sehen

Fehlt diese Prüfung, kann jeder authentifizierte Nutzer mit einer beliebigen Order-Id fremde Bestellungen abrufen. OWASP führt das als Broken Object Level Authorization, klassisch IDOR, seit Jahren auf Platz 1 der API Security Top 10. Es ist kein exotischer Angriff, sondern eine URL mit einer anderen Zahl darin. So sieht der Endpunkt aus, den generiertes CRUD liefert.

@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());
    }
}

Der Spiegel-Test dazu erzeugt einen Nutzer, legt dessen Bestellung an, ruft sie mit dessen Token ab und ist grün. Der Fall „zweiter Nutzer, fremde Id“ kommt darin nie vor, weil er im Code nie vorkam.

@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()));
}

Der Test, der die eigentliche Anforderung formuliert, ist gegen diesen Controller rot. Er ist der wertvollste Test des Artikels, weil er als einziger gegen den generierten Code scheitert.

@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());
}

Die robuste Korrektur zieht die Ownership in die Abfrage selbst, damit sie nicht vergessen werden kann, und antwortet auf eine fremde Id mit 404, damit die Existenz fremder Bestellungen nicht durchsickert.

@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));
    }
}

Dem Webhook nicht blind vertrauen

Der Payment-Webhook ist ein von außen erreichbarer Endpunkt, der Geldstatus verändert. Wer dem Payload vertraut, akzeptiert einen selbstgebauten POST mit "status": "PAID" als Zahlungsbestätigung. Pflicht sind zwei Prüfungen: die Signatur über den Request-Body per HMAC mit dem geteilten Secret, zeitkonstant verglichen, und ein Replay-Schutz über die Event-Id, die verarbeitete Events dedupliziert. Ohne Event-Id-Prüfung springt ein Tage später erneut zugestelltes, gültig signiertes payment.succeeded den Status von REFUNDED zurück auf PAID. Der Spiegel-Test postet einen unsignierten Payload und prüft, dass der Status danach PAID ist, er dokumentiert damit wörtlich die Verwundbarkeit.

Was der Client nie setzen darf

Felder wie status, grossTotal und customerId sind serverseitig bestimmt. Eingabe-DTOs sind explizite Projektionen, die nur die Felder enthalten, die der Client legitim liefert.

record CreateOrderRequest(List<OrderItemRequest> items, ShippingAddressRequest shippingAddress) {}

Bindet der Controller stattdessen die Entity oder ein vollständig gespiegeltes DTO, sendet ein Client "grossTotal": 0.01 und "status": "PAID" einfach mit. Der Spiegel-Test bestätigt sogar, dass alle Felder ankommen, und verteidigt damit den Bug bei jeder späteren Härtung.

Zwei weitere Punkte gehören auf die Risikoliste: Fortlaufende numerische Ids machen den Bestand aufzählbar, nicht ratbare Ids wie UUID oder ULID entschärfen das strukturell, und ohne Rate-Limit erlaubt der Checkout Bestell-Spam, der Reservierungen bindet. Und personenbezogene Daten oder Zahlungsreferenzen haben in Logs und Fehlermeldungen nichts verloren. Ein Exception-Handler, der ex.getMessage() in den Response-Body schreibt, macht aus einer Fehlermeldung mit Payment-Referenz ein Datenleck, das der Spiegel-Test als zugesicherte Eigenschaft festschreibt.

Runde 4: operative Reife

Die letzte Runde macht den Service betreibbar: Änderungen ohne Ausfall, sauberes Verhalten bei Neustarts, Sichtbarkeit im Betrieb und definierte Bereitschaft.

Migrationen ohne Downtime

Der teuerste Fehler bei einer Schemaänderung auf einer Live-Tabelle ist eine Migration, die die alte Version des Codes bricht, während sie ausgerollt wird. Während eines rollierenden Deployments laufen alte und neue Pods für kurze Zeit parallel, beide gegen dasselbe Schema. Das Muster dafür heißt expand and contract, und jeder Schritt ist für sich rückwärtskompatibel. Angenommen, orders bekommt eine neue Spalte für den Bruttobetrag in kleinsten Einheiten. Zuerst die additive Migration.

ALTER TABLE orders ADD COLUMN gross_total_minor BIGINT;

Diese Migration bricht keinen alten Pod, denn eine zusätzliche, nullbare Spalte stört bestehende Abfragen nicht. Der neue Code schreibt beide Felder und liest bevorzugt das neue. Ist der Bestand befüllt und der alte Code vollständig abgelöst, macht eine spätere Migration die Spalte verbindlich und entfernt die alte.

ALTER TABLE orders ALTER COLUMN gross_total_minor SET NOT NULL;
ALTER TABLE orders DROP COLUMN gross_total;

Der Rollback-Vertrag ist damit klar: Jede einzelne Migration ist mit der jeweils vorherigen Code-Version verträglich, also lässt sich der Code zurückrollen, ohne dass das Schema bricht. Genau das ist der Unterschied zwischen „die Migration lief durch“ und „die Migration ist sicher“.

Sauberes Verhalten bei Neustarts

Kubernetes braucht zwei getrennte Signale. Die Liveness-Probe sagt, ob der Prozess noch lebt oder neu gestartet werden muss. Die Readiness-Probe sagt, ob er gerade Traffic annehmen kann. Wer beide verwechselt, killt einen Pod, der nur kurz beschäftigt ist, oder schickt Requests an einen, der noch startet.

livenessProbe:
  httpGet:
    path: /actuator/health/liveness
    port: 8080
  initialDelaySeconds: 10
  periodSeconds: 10
readinessProbe:
  httpGet:
    path: /actuator/health/readiness
    port: 8080
  periodSeconds: 5

Dazu gehört ein sauberes Herunterfahren, damit ein Pod laufende Requests zu Ende bearbeitet, bevor er verschwindet.

server:
  shutdown: graceful
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s

Sichtbarkeit statt Blindflug

Produktionsreife heißt, das Verhalten unter Last zu kennen. Nützlich sind die RED-Signale, also Rate, Errors und Duration pro Endpunkt, strukturierte Logs mit einer Korrelations-Id als Schlüssel und verteiltes Tracing über die Grenzen zum Payment-Provider und zur Datenbank. Aus einem Qualitätsszenario wird ein messbares Ziel, ein Service Level Objective. Ein Beispiel: 99,5 Prozent der Checkout-Requests über 30 Tage werden in unter 500 Millisekunden beantwortet. Das ist prüfbar, und es sagt, wann es eng wird, bevor Kunden es merken.

Definierte Bereitschaft

Das SLO ist die Grundlage für Alarme, die an einem tatsächlichen Nutzerproblem hängen und nicht an einer beliebigen CPU-Zahl. Ein Alarm auf die Fehlerbudget-Rate des SLO schlägt an, wenn das Budget zu schnell verbraucht wird. Dazu gehört ein Runbook, das sagt, was im Alarmfall zu tun ist. Erst mit dieser Bereitschaft ist der Service nicht nur ausgerollt, sondern betreibbar.

Was ein Harness abnimmt und was nicht

Zurück zum Bild vom Anfang. Ein Harness, also der vorbereitete Auslieferungspfad aus Pipeline, Migrations-Konvention, Probes, Secrets-Handling, Observability-Grundausstattung und einem sicheren Standard-Endpunkt, ist genau der richtige Ort für Investition. Er nimmt dir die Mechanik ab, die sich bei jedem Service wiederholt, und keine der vier Runden wird dadurch teurer. Das ist echte Ersparnis, und dort Energie reinzustecken ist richtig.

Was der Harness nicht abnimmt, ist die Bewertung. Welche Annahme in deinem Bestand riskant ist, welcher Steuerfall zutrifft, welches Qualitätsszenario zählt und welches Risiko zuerst gehärtet gehört, das bleibt Denkarbeit. Ein Harness beschleunigt die Mechanik, nicht die Reife. Er führt Entscheidungen aus, die vorher jemand verstanden haben muss, und generierter Code macht diese Annahmen nur schneller wirksam, richtig wie falsch.

Damit schließt sich der Kreis zum Anfang. „Harness ziehen und fertig“ ist an der Mechanik richtig und beim Wort „fertig“ zu kurz gedacht. Der Harness bringt dich verlässlich bis „läuft im Cluster“. Die vier Runden bis „produktionsreif“ gehst du selbst.

FAQ

Ist ein grüner CI-Lauf kein gutes Zeichen? Doch, er ist notwendig. Er beweist, dass der Service baut, startet und den glücklichen Pfad beantwortet. Er ist nur nicht hinreichend, weil Produktionsreife an den Fällen hängt, die der glückliche Pfad nie berührt.

Warum finden KI-generierte Tests solche Fehler nicht? Weil sie aus dem Code abgeleitet werden. Ein Test, der die Implementierung spiegelt, prüft, dass der Code tut, was er tut. Fachliche Fehler stecken aber in den Annahmen des Codes, und diese Annahmen stehen auf beiden Seiten des Vergleichs. Ein aussagekräftiger Test braucht ein Orakel, das unabhängig vom Produktionsweg rechnet.

Reicht hohe Testabdeckung als Nachweis für Produktionsreife? Nein. Abdeckung misst, welche Zeilen ausgeführt wurden, nicht welche Anforderungen geprüft sind. Man kann 100 Prozent Abdeckung mit Spiegel-Tests erreichen und trotzdem keinen einzigen fachlichen oder Security-Fall abdecken.

Was ist der Unterschied zwischen Liveness- und Readiness-Probe? Die Liveness-Probe entscheidet, ob der Prozess neu gestartet werden muss. Die Readiness-Probe entscheidet, ob er gerade Traffic bekommen soll. Ein Pod kann leben und trotzdem nicht bereit sein, etwa während er startet oder eine Abhängigkeit kurz weg ist.

Wie erfülle ich Recht auf Löschung und Aufbewahrungspflicht gleichzeitig? Über Anonymisierung statt Hard-Delete. Der Personenbezug im operativen Bestand wird entfernt, die steuerrelevanten Belegdaten liegen getrennt und werden erst nach Ablauf ihrer Frist gelöscht. Der konkrete Fall gehört mit Steuerkanzlei und Anwalt geklärt. Auch hier gilt: keine Steuer- oder Rechtsberatung.

Was ist BOLA oder IDOR und warum ist es so häufig? Broken Object Level Authorization bedeutet, dass eine fehlende Ownership-Prüfung den Zugriff auf fremde Objekte erlaubt. Es ist häufig, weil das übliche CRUD-Muster keinen Aufrufer-Kontext kennt und ein Endpunkt ohne Ownership-Prüfung im Test genauso grün ist wie einer mit.

Braucht ein kleines Projekt arc42? Nicht das ganze Dokument. Zwei Kapitel reichen als Werkzeug: eine kurze Liste von Qualitätsszenarien und eine offene Risikoliste. Beides kostet wenig und macht die Runden zur Produktionsreife überhaupt erst planbar.

Fazit

Produktionsreife ist kein Zustand, den ein grüner Deploy herstellt, sondern ein risikogetriebener Prozess in Runden. Der Weg dorthin ist die Arbeit, die Qualitätsszenarien und Risiken überhaupt erst aufzuschreiben, bevor irgendjemand, Mensch oder Modell, den Code dazu erzeugt. Wer erst Code und dann Tests auf diesen Code generiert, bekommt einen geschlossenen Kreis, in dem die Anforderung nie vorkommt. Der nächste Schritt ist klein und wirksam: Nimm dir den Service, an dem du gerade arbeitest, und schreibe fünf Qualitätsszenarien auf, die dein glücklicher Pfad nicht abdeckt. Die ersten fünf roten Tests markieren die Grenze zwischen „läuft im Cluster“ und „produktionsreif“.

Quellen

  • arc42, Vorlage für Architekturdokumentation, Kapitel 10 (Qualitätsanforderungen) und Kapitel 11 (Risiken und technische Schulden), arc42.org
  • OWASP API Security Top 10, insbesondere API1 Broken Object Level Authorization, owasp.org
  • Verordnung (EU) 2016/679 (Datenschutz-Grundverordnung), Artikel 15, 17 und 20
  • Abgabenordnung Paragraf 147 und Handelsgesetzbuch Paragraf 257 (Aufbewahrungsfristen)
  • Umsatzsteuerrecht zu One-Stop-Shop, Reverse-Charge-Verfahren und Zusammenfassender Meldung, Bundeszentralamt für Steuern

Alle Code-Beispiele sind eigene und dienen dem durchgehenden Beispiel orders-api. Der Artikel ist ein Erfahrungsbericht aus der Praxis und keine Steuer- oder Rechtsberatung. Für den konkreten Fall gehören Steuerkanzlei und Anwalt hinzugezogen.

$ lang DE EN ES