Production incident

The payment type that shipped half-handled

45 minintermediate312 yrs

A real incident: symptom first, cause hidden, tradeoff at the end.

The incident

Netbanking was added two sprints ago. The PR touched every switch the author could find by grepping for "Upi". It found three of five. The two it missed do not throw at the point of the mistake. They fall into a default branch that was written to be safe, and is not: 1. Finance reports that netbanking orders settled with zero platform fee. Four weeks of them. Nobody noticed, because zero is a plausible number and nothing ever threw. 2. The SLA dashboard shows netbanking as a non-instant method, so those orders went down the slow path and customers saw a delay the product team never designed. Nothing is wrong with the new record. The defect is that this design has no way to tell anyone a case is missing. Fix the two wrong answers — and then fix the thing that let them happen, so that the sixth payment type cannot repeat it.

What this teaches

  • A default branch is a promise to handle cases you have not thought about
  • Sealing a hierarchy lets the compiler check every switch for exhaustiveness
  • Deleting a default either compiles — proving the switch was complete — or names a real gap
  • A silently wrong number is a worse failure than an exception
  • Adding a permitted subtype is a source-breaking change, and that is the feature

Starter

Starter.javaOpen in playground
import java.util.*;

/**
 * Incident reproduction: the payment type that shipped half-handled.
 *
 * Netbanking was added two sprints ago. The PR touched every switch the
 * author could find by grepping for "Upi". It found three of five.
 *
 * The two it missed do not throw at the point of the mistake — they fall
 * into a default branch that was written to be safe and is not:
 *
 *   1. Finance reports that netbanking orders settle with zero platform fee.
 *      Four weeks of them. Nobody noticed, because zero is a plausible
 *      number and no exception was ever thrown.
 *
 *   2. The SLA dashboard shows netbanking as a non-instant method, so those
 *      orders are queued on the slow path and customers see a delay that the
 *      product team did not design.
 *
 * There is nothing wrong with the new record. The defect is that this design
 * cannot tell anyone a case is missing.
 *
 * TASKS
 *   1. Run it and find the two wrong answers.
 *   2. Fix them — and then do the part that matters: change the design so
 *      the SIXTH payment type cannot repeat this. Seal the hierarchy and
 *      delete every default branch.
 *   3. Deleting a default will sometimes compile straight away. When it does,
 *      you have just proved that switch was already exhaustive. Note which
 *      ones those were.
 *   4. In a comment: adding a permitted subtype now breaks compilation for
 *      every consumer. Is that a cost or the feature? Answer for a library
 *      and for an internal service separately.
 */
public class Starter {

    /* The hierarchy is open, so no switch below can be checked. */
    interface Payment {}

    record Card(String network, long amountPaise) implements Payment {}

    record Upi(String handle, long amountPaise) implements Payment {}

    record Cash(long amountPaise) implements Payment {}

    /** Added two sprints ago. */
    record Netbanking(String bank, long amountPaise) implements Payment {}

    /** Site 1 — MISSED. A silent zero is worse than a loud failure. */
    static long feePaise(Payment payment) {
        return switch (payment) {
            case Card c -> Math.round(c.amountPaise() * 0.02);
            case Upi u -> 0L;
            case Cash c -> 0L;
            default -> 0L;
        };
    }

    /** Site 2 — MISSED. Wrong SLA path, no error anywhere. */
    static boolean isInstant(Payment payment) {
        return switch (payment) {
            case Card c -> true;
            case Upi u -> true;
            case Cash c -> false;
            default -> false;
        };
    }

    /** Site 3 — updated correctly in the original PR. */
    static String label(Payment payment) {
        return switch (payment) {
            case Card c -> c.network() + " card";
            case Upi u -> "UPI " + u.handle();
            case Cash c -> "cash";
            case Netbanking n -> n.bank() + " netbanking";
            default -> "unknown";
        };
    }

    /** Site 4 — updated correctly in the original PR. */
    static String settlementAccount(Payment payment) {
        return switch (payment) {
            case Card c -> "acct-card";
            case Upi u -> "acct-upi";
            case Cash c -> "acct-cash";
            case Netbanking n -> "acct-netbanking";
            default -> throw new IllegalStateException("unhandled payment: " + payment);
        };
    }

    public static void main(String[] args) {
        List<Payment> payments = List.of(
                new Card("visa", 500_00L),
                new Upi("ravi@okhdfc", 500_00L),
                new Cash(500_00L),
                new Netbanking("hdfc", 500_00L));

        for (Payment payment : payments) {
            System.out.printf("%-22s fee=%-6d instant=%-6s -> %s%n",
                    label(payment), feePaise(payment), isInstant(payment),
                    settlementAccount(payment));
        }

        // The published rate card: netbanking carries a flat 500 paise fee,
        // and it settles instantly.
        Payment netbanking = new Netbanking("hdfc", 500_00L);
        boolean feeCorrect = feePaise(netbanking) == 500L;
        boolean slaCorrect = isInstant(netbanking);

        System.out.println();
        System.out.println("netbanking fee charged correctly : " + feeCorrect);
        System.out.println("netbanking on the instant path   : " + slaCorrect);
        System.out.println(feeCorrect && slaCorrect ? "PASS" : "FAIL");
    }
}

Run it locally:

cd exercises/java/modern-java/sealed-classes/03-production
javac Starter.java -d /tmp/out && java -cp /tmp/out Starter

Hints

  1. Hint 1

    Compare feePaise() and label(). One handles four types and one handles three plus a default. Which one would tell you if a fifth arrived?

  2. Hint 2

    Seal Payment first, before fixing anything. Then delete the defaults one at a time and let the compiler tell you which sites were really wrong.

  3. Hint 3

    Records are implicitly final, so they already satisfy the 'final, sealed or non-sealed' rule — you do not have to touch them.

  4. Hint 4

    Two of the four switches will compile the moment you delete their default. That is information, not luck: write down which ones and why.

Done when

  • Payment is sealed and names its four permitted subtypes
  • No switch over Payment has a default branch
  • Netbanking is charged 500 paise and routed to the instant path
  • A comment records which switches were already exhaustive before the fix
  • A comment answers whether source-breaking consumers is a cost or the point

Solution

Show the solution — try it yourself first
Solution.javaOpen in playground
import java.util.*;

/**
 * Solution: the payment type that shipped half-handled.
 *
 * The two wrong answers are symptoms. The defect is that an open interface
 * gives the compiler nothing to check, so every switch had to end in a
 * default branch — and a default branch is a promise to handle a case you
 * have not thought about, which is not a promise anyone can keep.
 *
 * The fix is in three parts:
 *
 *   1. Seal Payment, naming the four permitted types.
 *   2. Delete every default branch. Two of the four switches then failed to
 *      compile, naming exactly the two sites the original PR missed — the
 *      compiler found in one build what four weeks of production did not.
 *   3. Handle the missing cases properly.
 *
 * Sites 3 and 4 compiled with no changes once their defaults were removed,
 * which is its own useful result: it proved those switches were already
 * exhaustive rather than merely believed to be.
 */
public class Solution {

    /**
     * The one line that changes everything. Records are implicitly final, so
     * they satisfy the "final, sealed or non-sealed" rule for free.
     */
    sealed interface Payment permits Card, Upi, Cash, Netbanking {}

    record Card(String network, long amountPaise) implements Payment {}

    record Upi(String handle, long amountPaise) implements Payment {}

    record Cash(long amountPaise) implements Payment {}

    record Netbanking(String bank, long amountPaise) implements Payment {}

    /** Site 1 — the compiler refused this without the Netbanking case. */
    static long feePaise(Payment payment) {
        return switch (payment) {
            case Card c -> Math.round(c.amountPaise() * 0.02);
            case Upi u -> 0L;
            case Cash c -> 0L;
            case Netbanking n -> 500L;          // flat fee, per the rate card
        };
    }

    /** Site 2 — likewise. */
    static boolean isInstant(Payment payment) {
        return switch (payment) {
            case Card c -> true;
            case Upi u -> true;
            case Cash c -> false;
            case Netbanking n -> true;
        };
    }

    /** Site 3 — default deleted, compiled unchanged. It was already complete. */
    static String label(Payment payment) {
        return switch (payment) {
            case Card c -> c.network() + " card";
            case Upi u -> "UPI " + u.handle();
            case Cash c -> "cash";
            case Netbanking n -> n.bank() + " netbanking";
        };
    }

    /** Site 4 — same. The IllegalStateException was unreachable, not safety. */
    static String settlementAccount(Payment payment) {
        return switch (payment) {
            case Card c -> "acct-card";
            case Upi u -> "acct-upi";
            case Cash c -> "acct-cash";
            case Netbanking n -> "acct-netbanking";
        };
    }

    public static void main(String[] args) {
        List<Payment> payments = List.of(
                new Card("visa", 500_00L),
                new Upi("ravi@okhdfc", 500_00L),
                new Cash(500_00L),
                new Netbanking("hdfc", 500_00L));

        for (Payment payment : payments) {
            System.out.printf("%-22s fee=%-6d instant=%-6s -> %s%n",
                    label(payment), feePaise(payment), isInstant(payment),
                    settlementAccount(payment));
        }

        Payment netbanking = new Netbanking("hdfc", 500_00L);
        boolean feeCorrect = feePaise(netbanking) == 500L;
        boolean slaCorrect = isInstant(netbanking);

        // What the design now guarantees, stated as a check rather than a
        // comment: the permitted set is in the class file, so a fifth type
        // cannot be introduced without editing this line — and editing it
        // breaks every switch above until each one handles the new case.
        List<String> permitted = new ArrayList<>();
        for (Class<?> c : Payment.class.getPermittedSubclasses()) {
            permitted.add(c.getSimpleName());
        }

        System.out.println();
        System.out.println("permitted subtypes               : " + permitted);
        System.out.println("netbanking fee charged correctly : " + feeCorrect);
        System.out.println("netbanking on the instant path   : " + slaCorrect);
        System.out.println(feeCorrect && slaCorrect ? "PASS" : "FAIL");
    }
}

Stretch

Add a fifth payment type and count the compile errors before you write a single line of logic. Then argue the other side honestly: this hierarchy is internal, so breaking every consumer at compile time is free. Describe what changes if Payment ships in a library used by teams you cannot recompile — what you would do instead, and what you would put in the release notes.

← Back to What do sealed classes enable that abstract classes cannot?