The other day, a discussion about AI in delivery. Someone summed it up nicely: pull a harness, tell the AI what you want, automation does the rest, done. On the mechanics, that is actually true. What stuck was the word “done”.
Because a service is deployed, the pipeline is green, the health check returns 200, and for many teams that is exactly what “done” means. But it only means that the service starts and responds, not that it does the right thing under load, under attack, under malformed input, and under the rules of tax law, data protection and contract law. Between “runs in the cluster” and “production-ready” lies work, and that work does not get faster when an AI assistant writes the code. It tends to get harder, because nobody knows anymore which assumptions are baked into the generated code.
This post walks that path with a single example: an order service we call orders-api. Spring Boot, PostgreSQL, Kubernetes, a checkout with payment, stock, invoice and cancellation. We start at “deployed green” and work our way toward production readiness in rounds, guided by a risk assessment in the spirit of arc42. At the end stands the thesis that carries the whole article.
Contents
- The green deploy that proves nothing
- Green tests that only mirror the code
- Production readiness is a process, not a state
- Round 1: business correctness
- Round 2: legal correctness as a risk
- Round 3: security and auth
- Round 4: operational maturity
- What a harness takes off your plate and what it does not
- FAQ
- Conclusion
- Sources
The green deploy that proves nothing
The orders-api is freshly deployed. The pipeline was green, the containers are running, GET /actuator/health returns 200. A smoke test creates an order, pays for it, gets an order number back. Everything works, and this is exactly where the work of many teams stops.
A green deploy proves that the service starts, reaches its dependencies and answers one happy request. That is necessary, and it says nothing about the rest. It says nothing about what happens on a second, identical checkout request. Nothing about whether another customer can read someone else’s order. Nothing about the tax on a delivery to France, nothing about the behavior during a migration of the live table, nothing about the invoice that has to reconcile with the accounting system three systems down the line.
Production readiness is the sum of exactly these questions. They are uncomfortable because none of them shows up on the happy path that a deploy verifies.
Green tests that only mirror the code
The common reflex in working with AI assistance makes the problem worse instead of solving it: first the code is generated, then tests are generated for exactly that code. The tests are green, coverage goes up, and still nothing that matters has been verified. A test derived from the code can only confirm that the code does what the code does. It contains no knowledge about the business rules that was not already in the code.
A small example makes this tangible. The orders-api calculates the order total. The generated code computes with double and rounds with 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);
}
}The code looks clean, compiles and produces correct results for the usual demo amounts. The test generated afterwards confirms that with round numbers and, in the second case, with the same formula as the production code and a tolerance of one 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);
}
}Both tests are green. They prove exclusively that the code does what the code does. The second test is the pattern in its purest form: expectation and implementation are the same calculation, so a bug in the formula sits on both sides of the assertEquals. On top of that, the one-cent tolerance swallows exactly the class of errors that matters when money is involved.
A business test emerges the other way around. The rule says: tax is rounded commercially, HALF_UP to two decimal places. A net amount of 4.50 euros yields 0.855 euros of tax, commercially rounded to 0.86 euros. The double implementation returns 0.85, because the product internally lands just below 0.855 and gets rounded down. A single value chosen from the business rule exposes that.
@Test
void shouldRoundTaxHalfUpWhenTaxLandsOnHalfCent() {
OrderTotals totals = checkoutService.calculateTotals(
List.of(new OrderItem("SKU-1", 4.50, 1)));
assertEquals(0.86, totals.tax());
}This test is red. It differs from the generated test in two respects: the input value comes from the business rule, not from the implementation, and the assertion is exact, with no tolerance. Even stronger is the property-based variant with jqwik, which uses an independent oracle built on 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());
}
}The decisive difference from the mirror test: the oracle calculates along a different path than the production code. Only then does the test verify the business rule instead of the implementation’s assumptions. The corrected version calculates with BigDecimal throughout and rounds at one defined point.
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 the database this corresponds to NUMERIC(12,2), in the API to a string or an amount in minor units as a long, never a JSON float. The order of steps is the real lesson: if you generate code first and then tests for that code, you get tests with the same formulas, the same assumptions and the same gaps. Coverage rises, meaning does not. Business tests come from the rule and need an oracle that calculates independently of the production path.
Production readiness is a process, not a state
Production readiness cannot be checked off in a single pass. It emerges in rounds, and what each round covers is decided not by a fixed list but by a risk assessment. arc42 provides two useful tools for this. Chapter 10 records the quality scenarios, meaning concrete requirements in the form “under this condition, the system reacts like this”. Chapter 11 keeps the risks and technical debt as an open, prioritized list.
A round always runs the same way: assess the biggest risk on the list, harden it, then reassess. That is exactly what turns “readiness is a process” into more than a slogan. A harness, meaning the prepared delivery path, can mechanically prepare these rounds. The ordering by risk is something it cannot take over. A quality scenario is cheap to write down and forces precision. “An EU business customer with a valid VAT identification number receives an invoice with a reverse charge note” is verifiable. “Handle tax correctly” is not.
The following four rounds order the risks of the orders-api by impact: business correctness, legal correctness, security, operational maturity. The order is not set in stone, it follows from the assessment. Data loss during a migration weighs more than a missing dashboard, so it comes first.
Round 1: business correctness
At the very top of the risk list: the service takes money and stock, and both have to stay correct even when requests repeat or overlap. Money rounding is settled with BigDecimal. Three rules remain that generated code typically misses.
Idempotency at checkout
A double click or a network retry by the client must not create a second order or a second payment. The client sends a self-generated idempotency key, and the same key returns the same order.
@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)));
}
}For this to hold, a unique constraint sits on idempotency_key. Otherwise, two parallel requests with the same key both run into the orElseGet branch, and only the constraint reliably prevents the duplicate.
No negative stock under concurrency
Two simultaneous checkouts of the last available item both read a stock of 1, both pass the check and both subtract. A check-then-act in application code looks correct in every sequential test and only breaks under load. The robust solution lives in the database.
UPDATE stock
SET available = available - :quantity
WHERE sku = :sku
AND available >= :quantity;If the statement changes zero rows, there was not enough stock, and the checkout is rejected. The check and the subtraction are one single atomic step, not two.
Status transitions as a state machine
A public setter or a generic updateStatus(orderId, status) allows every transition, including SHIPPED back to NEW. What should be allowed is only a defined graph. The rule belongs in the aggregate, not in the 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);
}
}Each of these cases has a test that comes from the rule, not from the code: two requests with the same idempotency key result in one order, two parallel subtractions never let stock fall below zero, a forbidden transition throws instead of silently overwriting.
Round 2: legal correctness as a risk
After reassessment, the next risk sits on top, and it is expensive: wrong tax, a receipt that cannot be reconciled, a violated deletion obligation or a contract that cannot be proven. These rules live outside the code, in tax law, commercial law and data protection, and that is why the blind spot of generated code is largest here.
One note up front, and it is the actual core of this round: what follows are domain and risk rules for modeling. This is not tax or legal advice. The certainty that a concrete case is really correct comes not from the code and not from this article, but from working with a tax advisory firm and a lawyer. That very step turns “we believe it fits” into a solid foundation. The code then implements what was clarified there.
Tax is business logic, not a constant
The same applies here: this is not tax or legal advice. “19 percent on everything” is only one of many branches, which happens to be the most common one in German B2C business. The rate follows from customer type, delivery country, status of the VAT identification number and tax class of the product. Domestic B2C carries the standard or reduced rate, cross-border B2C within the EU above the threshold carries the destination country’s rate via the One-Stop-Shop procedure, B2B within the EU with a valid VAT identification number falls under the Reverse Charge procedure with zero percent, a mandatory note on the invoice and a report in the Zusammenfassende Meldung (EC Sales List), and an export to a third country is tax-free. Verifying the VAT identification number through the VIES confirmation procedure is a business precondition with its own state, not a format check.
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));
}
}The invoice note strings in the code are German wording mandated by law: “Steuerschuldnerschaft des Leistungsempfängers” states that the tax liability shifts to the recipient of the service, “Steuerfreie Ausfuhrlieferung” marks a tax-free export delivery. The business test comes from the tax rule and is red against an implementation that flatly returns 19 percent.
@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");
}It gets delicate at the edges that a mirror test never sees: if VIES is unreachable at order time, the state is neither valid nor invalid, and the fallback behavior is a business decision. A domestic business customer with a German VAT identification number gets no Reverse Charge but 19 percent. The tax rate belongs on the line item, not on the order, because a cart can mix standard and reduced rates. And the rate has to be frozen at the relevant point in time, not pulled fresh on every calculation.
The invoice has to reconcile with accounting
An invoice is not a pretty PDF at the end, but a legally binding receipt whose numbers have to match the accounting system exactly. That system does not check benevolently for “roughly equal”, it books line item by line item. A percentage discount on the order total, distributed across the line items, drifts one cent away from the rounded grand total through per-item rounding, and reconciliation breaks for every affected order. A voucher is not a simple amount reduction but belongs on the receipt as its own line item with the correct tax reference. If the payment received deviates from the invoice amount, through overpayment, partial payment or withheld fees, that needs its own state, otherwise the order sits permanently as “paid, but unclear” between shop and accounting.
Deleting and retaining at the same time
An order consists almost entirely of personal data, so the General Data Protection Regulation applies to the core of the data model, not to some side module. The most interesting conflict sits in deletion: the right to erasure from Article 17 stands against the statutory retention obligation for invoices under Section 147 of the German Fiscal Code (AO) and Section 257 of the German Commercial Code (HGB), eight to ten years depending on the type of record. Article 17(3) exempts exactly this case from the deletion obligation. The solution is not a hard delete, but anonymization of the operational data with separate retention of the tax-relevant receipt data.
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;The generated deletion endpoint turns this into a deleteById with ON DELETE CASCADE, satisfies Article 17 and violates the retention obligation in the process. The mirror test checks that the record is gone afterwards, and confirms exactly the wrong approach. Two more traps lurk nearby: personal data also sits in logs, mail queues and search indexes, so a data subject access response under Article 15 covering only the main table is incomplete, and an unsalted hash of the email address can be reversed, so it is not anonymization but pseudonymization.
Making terms and conditions provable
With the click on the order button, a contract comes into existence, and in a dispute it must be provable which terms the customer agreed to and when. A boolean termsAccepted does not answer that, because terms change. The conclusion of the contract is provable only if the order records which version of the terms was accepted and when, and if every version is archived immutably.
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);
}
}The acceptance is an invariant of the order aggregate, not a form validation. If the checkbox is only checked in the frontend, a direct API call creates the order anyway. And the order confirmation carries the terms in the version valid at order time, not a link to whatever version is current.
Round 3: security and auth
Security properties are negative requirements: they are about requests the happy path never makes. That is why a green deploy and a green pipeline say nothing about them. In arc42, this cluster belongs anchored twice, as a quality scenario in chapter 10 and as an assessed risk in chapter 11.
Authentication with Spring Security is largely configuration and usually the smaller problem: customers via a validated access token, admins with their own role in the token, machines via client credentials. The risk sits one level deeper, in authorization at the object level.
A customer may only see their own orders
If this check is missing, any authenticated user can fetch other people’s orders with an arbitrary order id. OWASP has listed this as Broken Object Level Authorization, classically IDOR, at number 1 of the API Security Top 10 for years. It is not an exotic attack, it is a URL with a different number in it. This is what the endpoint that generated CRUD delivers looks like.
@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());
}
}The mirror test for it creates a user, creates that user’s order, fetches it with that user’s token and is green. The case “second user, foreign id” never appears in it, because it never appeared in the code.
@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()));
}The test that states the actual requirement is red against this controller. It is the most valuable test of the article, because it is the only one that fails against the generated code.
@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());
}The robust fix pulls the ownership into the query itself, so it cannot be forgotten, and answers a foreign id with 404, so the existence of other people’s orders does not leak.
@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));
}
}Do not blindly trust the webhook
The payment webhook is an externally reachable endpoint that changes money state. If you trust the payload, you accept a hand-crafted POST with "status": "PAID" as a payment confirmation. Two checks are mandatory: the signature over the request body via HMAC with the shared secret, compared in constant time, and replay protection via the event id, which deduplicates processed events. Without the event id check, a validly signed payment.succeeded delivered again days later flips the status from REFUNDED back to PAID. The mirror test posts an unsigned payload and checks that the status is PAID afterwards, thereby literally documenting the vulnerability.
What the client must never set
Fields like status, grossTotal and customerId are determined server-side. Input DTOs are explicit projections that contain only the fields the client legitimately provides.
record CreateOrderRequest(List<OrderItemRequest> items, ShippingAddressRequest shippingAddress) {}If the controller instead binds the entity or a fully mirrored DTO, a client simply sends "grossTotal": 0.01 and "status": "PAID" along. The mirror test even confirms that all fields arrive, and thereby defends the bug during every later hardening.
Two more items belong on the risk list: sequential numeric ids make the inventory enumerable, unguessable ids like UUID or ULID defuse that structurally, and without a rate limit the checkout allows order spam that ties up reservations. And personal data or payment references have no business in logs and error messages. An exception handler that writes ex.getMessage() into the response body turns an error message with a payment reference into a data leak, which the mirror test locks in as a guaranteed property.
Round 4: operational maturity
The last round makes the service operable: changes without downtime, clean behavior on restarts, visibility in operation and defined readiness.
Migrations without downtime
The most expensive mistake in a schema change on a live table is a migration that breaks the old version of the code while it is being rolled out. During a rolling deployment, old and new pods run in parallel for a short time, both against the same schema. The pattern for this is called expand and contract, and every step is backward-compatible on its own. Suppose orders gets a new column for the gross amount in minor units. First the additive migration.
ALTER TABLE orders ADD COLUMN gross_total_minor BIGINT;This migration breaks no old pod, because an additional, nullable column does not disturb existing queries. The new code writes both fields and prefers reading the new one. Once the data is backfilled and the old code is fully retired, a later migration makes the column mandatory and removes the old one.
ALTER TABLE orders ALTER COLUMN gross_total_minor SET NOT NULL;
ALTER TABLE orders DROP COLUMN gross_total;The rollback contract is then clear: every single migration is compatible with the immediately preceding code version, so the code can be rolled back without the schema breaking. That is exactly the difference between “the migration went through” and “the migration is safe”.
Clean behavior on restarts
Kubernetes needs two separate signals. The liveness probe says whether the process is still alive or needs a restart. The readiness probe says whether it can accept traffic right now. If you mix them up, you kill a pod that is only briefly busy, or you send requests to one that is still starting.
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
periodSeconds: 5This includes a graceful shutdown, so a pod finishes its in-flight requests before it disappears.
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30sVisibility instead of flying blind
Production readiness means knowing the behavior under load. Useful are the RED signals, meaning rate, errors and duration per endpoint, structured logs with a correlation id as the key, and distributed tracing across the boundaries to the payment provider and the database. A quality scenario becomes a measurable target, a Service Level Objective. One example: 99.5 percent of checkout requests over 30 days are answered in under 500 milliseconds. That is verifiable, and it tells you when things get tight before customers notice.
Defined readiness
The SLO is the basis for alerts that hang on an actual user problem and not on some arbitrary CPU number. An alert on the SLO’s error budget burn rate fires when the budget is consumed too fast. This includes a runbook that says what to do when the alert fires. Only with this readiness is the service not just rolled out, but operable.
What a harness takes off your plate and what it does not
Back to the image from the beginning. A harness, meaning the prepared delivery path of pipeline, migration convention, probes, secrets handling, observability baseline and a secure default endpoint, is exactly the right place to invest. It takes the mechanics off your plate that repeat with every service, and none of the four rounds gets more expensive because of it. That is a real saving, and putting energy there is right.
What the harness does not take off your plate is the assessment. Which assumption in your codebase is risky, which tax case applies, which quality scenario counts and which risk gets hardened first, that remains thinking work. A harness accelerates the mechanics, not the maturity. It executes decisions that someone must have understood beforehand, and generated code only makes those assumptions effective faster, right or wrong alike.
That closes the loop to the beginning. “Pull a harness and done” is right on the mechanics and too short on the word “done”. The harness reliably gets you to “runs in the cluster”. The four rounds to “production-ready” you walk yourself.
FAQ
Is a green CI run not a good sign? It is, and it is necessary. It proves that the service builds, starts and answers the happy path. It is just not sufficient, because production readiness hinges on the cases the happy path never touches.
Why do AI-generated tests not find such errors? Because they are derived from the code. A test that mirrors the implementation verifies that the code does what it does. Business errors, however, sit in the code’s assumptions, and those assumptions stand on both sides of the comparison. A meaningful test needs an oracle that calculates independently of the production path.
Is high test coverage enough as proof of production readiness? No. Coverage measures which lines were executed, not which requirements were verified. You can reach 100 percent coverage with mirror tests and still cover not a single business or security case.
What is the difference between the liveness and the readiness probe? The liveness probe decides whether the process needs a restart. The readiness probe decides whether it should receive traffic right now. A pod can be alive and still not ready, for instance while it is starting or a dependency is briefly gone.
How do I satisfy the right to erasure and the retention obligation at the same time? Through anonymization instead of a hard delete. The personal data in the operational records is removed, the tax-relevant receipt data is kept separately and only deleted after its retention period expires. The concrete case belongs clarified with a tax advisory firm and a lawyer. The same applies here: this is not tax or legal advice.
What is BOLA or IDOR and why is it so common? Broken Object Level Authorization means that a missing ownership check allows access to other people’s objects. It is common because the usual CRUD pattern knows no caller context, and an endpoint without an ownership check is just as green in a test as one with it.
Does a small project need arc42? Not the whole document. Two chapters suffice as tools: a short list of quality scenarios and an open risk list. Both cost little and are what makes the rounds toward production readiness plannable in the first place.
Conclusion
Production readiness is not a state that a green deploy establishes, but a risk-driven process in rounds. The way there is the work of writing down quality scenarios and risks in the first place, before anyone, human or model, generates the code for them. If you generate code first and then tests for that code, you get a closed loop in which the requirement never appears. The next step is small and effective: take the service you are currently working on and write down five quality scenarios your happy path does not cover. The first five red tests mark the line between “runs in the cluster” and “production-ready”.
Sources
- arc42, template for architecture documentation, chapter 10 (quality requirements) and chapter 11 (risks and technical debt), arc42.org
- OWASP API Security Top 10, in particular API1 Broken Object Level Authorization, owasp.org
- Regulation (EU) 2016/679 (General Data Protection Regulation), Article 15, 17 and 20
- Section 147 of the German Fiscal Code (AO) and Section 257 of the German Commercial Code (HGB) (retention periods)
- VAT law on One-Stop-Shop, the Reverse Charge procedure and the EC Sales List (Zusammenfassende Meldung), German Federal Central Tax Office
All code examples are my own and serve the running example orders-api. The article is a field report from practice and is not tax or legal advice. For the concrete case, a tax advisory firm and a lawyer belong at the table.