Production incident

The method that emptied the customer's cart

45 minjunior16 yrs

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

The incident

Checkout has two helpers that both go through the cart's item list. `redactForLogging` strips items flagged as restricted so they never reach the log aggregator — a compliance requirement. It is documented as read-only. `emptyCart` is called after a successful order so the session starts clean. Support tickets show two symptoms that nobody connected: 1. Some customers are charged for fewer items than they ordered. It only happens on orders containing a restricted item, and only when the debug log level is enabled. 2. Customers who complete an order still see the old items in their cart on the next page load. Ops "fixed" this months ago by telling people to clear cookies. Both bugs come from the same misunderstanding, pulling in opposite directions: one method changes the caller's data when it must not, and the other fails to change it when it must. Find the root cause, fix both, and then answer the design question: what would make this class of bug impossible here rather than merely fixed?

What this teaches

  • A method receiving a mutable collection can change the caller's data
  • Assigning to the parameter changes nothing the caller can see
  • Read-only by documentation is not a mechanism — only the types are
  • The two bugs are the same misunderstanding in opposite directions
  • Immutable inputs and returned values remove the ambiguity structurally

Starter

Starter.java
import java.util.*;

/**
 * Incident reproduction: the checkout helpers.
 *
 * Two bugs, one misunderstanding, opposite directions:
 *   - redactForLogging mutates the caller's list when it must not
 *   - emptyCart fails to mutate it when it must
 */
public class Starter {

    record Item(String name, int paise, boolean restricted) {}

    static final class Cart {
        private final List<Item> items = new ArrayList<>();

        void add(Item item) {
            items.add(item);
        }

        /** Hands out the live list. Convenient, and the reason both bugs exist. */
        List<Item> items() {
            return items;
        }

        int totalPaise() {
            return items.stream().mapToInt(Item::paise).sum();
        }
    }

    /**
     * Strip restricted items so they never reach the log aggregator.
     * Documented as read-only. It is not.
     */
    static List<Item> redactForLogging(List<Item> items) {
        items.removeIf(Item::restricted);
        return items;
    }

    /** Called after a successful order so the next page load starts clean. */
    static void emptyCart(List<Item> items) {
        items = new ArrayList<>();
    }

    public static void main(String[] args) {
        Cart cart = new Cart();
        cart.add(new Item("Keyboard", 250000, false));
        cart.add(new Item("Restricted knife", 180000, true));
        cart.add(new Item("Mouse", 90000, false));

        int itemsOrdered = cart.items().size();
        int expectedTotal = cart.totalPaise();
        System.out.println("customer ordered   = " + itemsOrdered + " items, "
            + expectedTotal + " paise");

        // Debug logging is on, so this runs. On INFO it never did — which is
        // why the bug only appeared for some customers.
        List<Item> forLog = redactForLogging(cart.items());
        System.out.println("log line           = " + forLog.size() + " items (restricted hidden)");

        int itemsCharged = cart.items().size();
        int chargedTotal = cart.totalPaise();
        System.out.println("customer charged   = " + itemsCharged + " items, "
            + chargedTotal + " paise");

        // Order complete — clear the cart for the next session.
        emptyCart(cart.items());
        int afterCheckout = cart.items().size();
        System.out.println("cart after order   = " + afterCheckout + " items (expected 0)");

        System.out.println();
        boolean chargedCorrectly = itemsCharged == itemsOrdered && chargedTotal == expectedTotal;
        boolean logRedacted = forLog.stream().noneMatch(Item::restricted);
        boolean cartCleared = afterCheckout == 0;

        System.out.println("charged for everything ordered : " + chargedCorrectly);
        System.out.println("restricted item kept out of log: " + logRedacted);
        System.out.println("cart emptied after order       : " + cartCleared);
        System.out.println(chargedCorrectly && logRedacted && cartCleared ? "PASS" : "FAIL");
    }
}

Run it locally:

cd exercises/java/oop/pass-by-value/03-production
javac Starter.java -d /tmp/out && java -cp /tmp/out Starter

Hints

  1. Hint 1

    Read redactForLogging line by line. Does it copy the list before removing from it, or remove from the one it was handed?

  2. Hint 2

    Now read emptyCart. Assigning a new list to the parameter — who can see that afterwards?

  3. Hint 3

    One method follows the arrow when it should have copied. The other moves the arrow when it should have followed it.

  4. Hint 4

    For the design question: what could the method signature do so that removing an item would not compile?

Done when

  • Logging no longer removes items from the customer's cart
  • emptyCart actually empties the cart the caller holds
  • The redaction still hides restricted items from the log output
  • A comment explains the signature change that makes the first bug impossible

Solution

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

/**
 * Root cause: one misunderstanding of pass-by-value, expressed twice in
 * opposite directions.
 *
 * BUG 1 — redactForLogging mutated the caller's data.
 *   `items.removeIf(...)` follows the copied reference to the SAME list the
 *   Cart holds, and deletes from it. The method got its own copy of the arrow,
 *   not its own list. So preparing a log line permanently removed the
 *   restricted item from the customer's cart, and they were charged for two
 *   items instead of three.
 *
 *   It only reproduced with debug logging on because that is the only path that
 *   called it — which is why it looked intermittent and customer-specific.
 *
 *   Fix: copy before filtering. The method never touches the caller's list.
 *
 * BUG 2 — emptyCart mutated nothing.
 *   `items = new ArrayList<>()` moves only the METHOD'S copy of the arrow. The
 *   Cart's own field still points at the original list, so the cart was never
 *   cleared. Nothing threw, nothing logged, and the old items reappeared on the
 *   next page load. "Tell them to clear cookies" was treating a symptom.
 *
 *   Fix: follow the arrow — items.clear() — or better, give Cart a method that
 *   owns the operation.
 *
 * The rule both bugs violate: following the reference is visible to the caller,
 * moving it is not. Bug 1 followed it when it should have copied; bug 2 moved
 * it when it should have followed it.
 *
 * What would make this impossible rather than fixed:
 *
 *   1. Do not hand out the live list. Cart.items() now returns an
 *      unmodifiable view, so `removeIf` on it throws instead of silently
 *      corrupting the cart — the failure becomes loud and immediate.
 *   2. Type the parameter by what the method is allowed to do. redactForLogging
 *      takes a Collection it only reads and RETURNS a new list; there is no
 *      path by which it can modify the input.
 *   3. Put mutation behind named methods on the owner (cart.empty()), so
 *      "empty the cart" cannot be attempted by assigning to a parameter.
 *
 * Note what is still not protected: the unmodifiable view guards the LIST, not
 * the Item objects inside it. Item is a record with only immutable components
 * here, so it is safe — but a mutable element type would still be reachable
 * through the view. Immutability has to go all the way down.
 */
public class Solution {

    record Item(String name, int paise, boolean restricted) {}

    static final class Cart {
        private final List<Item> items = new ArrayList<>();

        void add(Item item) {
            items.add(item);
        }

        /** FIX 3: a read-only view. Mutating it throws rather than corrupting. */
        List<Item> items() {
            return Collections.unmodifiableList(items);
        }

        /** Mutation lives here, named, where it cannot be done by accident. */
        void empty() {
            items.clear();
        }

        int totalPaise() {
            return items.stream().mapToInt(Item::paise).sum();
        }
    }

    /**
     * FIX 1: takes a Collection it only reads, returns a new List. The input is
     * never modified, and the signature is what guarantees it.
     */
    static List<Item> redactForLogging(Collection<Item> items) {
        List<Item> safe = new ArrayList<>(items);
        safe.removeIf(Item::restricted);
        return safe;
    }

    public static void main(String[] args) {
        Cart cart = new Cart();
        cart.add(new Item("Keyboard", 250000, false));
        cart.add(new Item("Restricted knife", 180000, true));
        cart.add(new Item("Mouse", 90000, false));

        int itemsOrdered = cart.items().size();
        int expectedTotal = cart.totalPaise();
        System.out.println("customer ordered   = " + itemsOrdered + " items, "
            + expectedTotal + " paise");

        List<Item> forLog = redactForLogging(cart.items());
        System.out.println("log line           = " + forLog.size() + " items (restricted hidden)");

        int itemsCharged = cart.items().size();
        int chargedTotal = cart.totalPaise();
        System.out.println("customer charged   = " + itemsCharged + " items, "
            + chargedTotal + " paise");

        // FIX 2: ask the owner to do it, instead of reassigning a parameter.
        cart.empty();
        int afterCheckout = cart.items().size();
        System.out.println("cart after order   = " + afterCheckout + " items (expected 0)");

        // Proof that the old bug is now loud instead of silent.
        cart.add(new Item("Later item", 1000, true));
        try {
            cart.items().removeIf(Item::restricted);
            System.out.println("view is still mutable — not protected");
        } catch (UnsupportedOperationException e) {
            System.out.println("mutating the view -> " + e.getClass().getSimpleName()
                + " (fails loudly, as it should)");
        }
        cart.empty();

        System.out.println();
        boolean chargedCorrectly = itemsCharged == itemsOrdered && chargedTotal == expectedTotal;
        boolean logRedacted = forLog.stream().noneMatch(Item::restricted);
        boolean cartCleared = afterCheckout == 0;

        System.out.println("charged for everything ordered : " + chargedCorrectly);
        System.out.println("restricted item kept out of log: " + logRedacted);
        System.out.println("cart emptied after order       : " + cartCleared);
        System.out.println(chargedCorrectly && logRedacted && cartCleared ? "PASS" : "FAIL");
    }
}

Stretch

Make Cart expose its items as an unmodifiable view so no caller can mutate the list at all, and give it explicit methods for the changes that are allowed. Then decide: does the unmodifiable view protect the Item objects themselves, or only the list? What would it take to protect both?

← Back to Is Java pass-by-value or pass-by-reference?