Production incident

The event bus nobody could call

45 minintermediate310 yrs

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

The incident

The bus was typed strictly at first: <T extends Event> void subscribe(Class<T> type, Handler<T> handler) which meant the audit handler — a Handler<Event>, deliberately written to accept anything — could not be registered for OrderPlaced. Generics are invariant, so a Handler<Event> is not a Handler<OrderPlaced>. The reviewer was right that the API was too strict. The fix they chose was to take a raw Handler and suppress the warning. It compiled, the audit handler registered, and everyone moved on. Six weeks later, publishing a PaymentFailed throws ClassCastException from inside a handler that has nothing to do with payments. The stack trace blames that handler. The handler is correct. Find what the raw type switched off. Then fix the signature so the audit handler still registers AND the mismatched registration cannot be written at all — no raw types, no @SuppressWarnings.

What this teaches

  • Invariance rejects a legitimate call, and the wildcard is the intended answer
  • A Handler consumes its type, so PECS says `? super T`
  • A raw type does not relax a constraint, it removes every constraint
  • The right wildcard permits what was wanted AND forbids what was wrong
  • Class.cast() is a checked cast: it fails at the boundary, naming both types

Starter

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

/**
 * Incident reproduction: the event bus nobody could call.
 *
 * The bus was typed strictly at first:
 *
 *     <T extends Event> void subscribe(Class<T> type, Handler<T> handler)
 *
 * which meant the audit handler — a Handler<Event>, deliberately written to
 * accept anything — could not be registered for OrderPlaced. Generics are
 * invariant, so Handler<Event> is not a Handler<OrderPlaced>.
 *
 * The reviewer's fix was to take a raw Handler and suppress the warning. It
 * compiled, the audit handler registered, and everyone moved on.
 *
 * Six weeks later, publishing a PaymentFailed throws ClassCastException from
 * inside a handler that has nothing to do with payments. The stack trace
 * blames the handler. The handler is correct.
 *
 * TASKS
 *   1. Run it and find which handler received an event it never asked for.
 *   2. Work out what the raw type switched off, and where.
 *   3. Fix subscribe() so the audit handler still registers AND a mismatched
 *      registration cannot be written. No raw types, no @SuppressWarnings.
 *   4. In a comment: which wildcard did you reach for, and is a Handler a
 *      producer or a consumer of its type?
 */
public class Starter {

    interface Event {}

    record OrderPlaced(String orderId) implements Event {}

    record PaymentFailed(String reason) implements Event {}

    interface Handler<T extends Event> {
        void handle(T event);
    }

    /** The bus, after the "fix". */
    @SuppressWarnings({"rawtypes", "unchecked"})
    static final class EventBus {
        /** Raw on purpose — this is the defect, and it is one word. */
        private final Map<Class<?>, List<Handler>> handlers = new HashMap<>();

        /**
         * DEFECT: `Handler` is raw, so the compiler no longer relates the
         * handler's type to the key it is being filed under. Anything can be
         * registered for anything.
         */
        <T extends Event> void subscribe(Class<T> type, Handler handler) {
            handlers.computeIfAbsent(type, k -> new ArrayList<>()).add(handler);
        }

        /** Delivers to handlers for the exact type, then to catch-alls. */
        void publish(Event event) {
            for (Class<?> key : List.of(event.getClass(), Event.class)) {
                for (Handler h : handlers.getOrDefault(key, List.of())) {
                    h.handle(event);
                }
            }
        }
    }

    public static void main(String[] args) {
        List<String> delivered = new ArrayList<>();
        boolean noMisdelivery = true;

        EventBus bus = new EventBus();

        // Legitimate: an audit handler that genuinely accepts any Event.
        Handler<Event> audit = e -> delivered.add("audit <- " + e);
        bus.subscribe(Event.class, audit);

        // Legitimate: a handler for one specific event type.
        Handler<OrderPlaced> orders = e -> delivered.add("orders <- " + e.orderId());
        bus.subscribe(OrderPlaced.class, orders);

        // THE BUG, six weeks ago, in a hurry: meant to be Event.class.
        // With a raw Handler parameter nothing here is checked, so it compiles.
        bus.subscribe(Event.class, orders);

        System.out.println("── publishing two events ──");
        try {
            bus.publish(new OrderPlaced("ORD-1"));
            bus.publish(new PaymentFailed("card declined"));
        } catch (ClassCastException e) {
            noMisdelivery = false;
            System.out.println("  ClassCastException during delivery:");
            System.out.println("    " + e.getMessage());
        }

        System.out.println();
        for (String line : delivered) {
            System.out.println("  " + line);
        }

        System.out.println();
        boolean auditSawBoth = delivered.stream().filter(s -> s.startsWith("audit")).count() == 2;

        System.out.println("every event reached its handlers : " + noMisdelivery);
        System.out.println("audit saw both events            : " + auditSawBoth);
        System.out.println(noMisdelivery && auditSawBoth ? "PASS" : "FAIL");
    }
}

Run it locally:

cd exercises/java/generics/generics-variance/03-production
javac Starter.java -d /tmp/out && java -cp /tmp/out Starter

Hints

  1. Hint 1

    Find the one word that is different about the Handler parameter. It is not a wildcard — it is the absence of one.

  2. Hint 2

    Ask the PECS question about Handler: handle(T) takes a T and returns nothing. Producer or consumer?

  3. Hint 3

    Once you have the wildcard, try to write the six-week-old bug again. What does the compiler say?

  4. Hint 4

    The dispatch still needs a cast somewhere. Class.cast() is checked — compare what it would do on a mismatch with what an unchecked (T) does.

Done when

  • The audit handler registers for Event.class and for OrderPlaced.class
  • Registering a Handler<OrderPlaced> under Event.class no longer compiles
  • No raw types and no @SuppressWarnings anywhere
  • No ClassCastException during delivery
  • A comment says whether a Handler is a producer or a consumer, and why

Solution

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

/**
 * Solution: the event bus nobody could call.
 *
 * Two separate mistakes, and the interesting part is that the first one was
 * a real problem — the reviewer was right that the API was too strict.
 *
 *   The original  <T extends Event> subscribe(Class<T>, Handler<T>)
 *   rejected the audit handler, because generics are invariant and a
 *   Handler<Event> is not a Handler<OrderPlaced>.
 *
 *   The raw-type "fix" solved that by switching off the relationship
 *   entirely, so any handler could be filed under any key.
 *
 * The right answer is the wildcard, and which one falls straight out of
 * PECS. A Handler CONSUMES its type — handle(T) takes a T and returns
 * nothing — so it is `? super T`:
 *
 *     <T extends Event> void subscribe(Class<T> type, Handler<? super T> h)
 *
 * That accepts the Handler<Event> for OrderPlaced, because Event is a
 * supertype of OrderPlaced. And it REJECTS filing a Handler<OrderPlaced>
 * under Event.class, because OrderPlaced is not a supertype of Event — which
 * is the six-week-old bug, now a compile error.
 *
 * The one remaining cast is Class.cast(), which is checked rather than
 * unchecked: the key proves the type, and if it were ever wrong it would
 * throw at the boundary naming both types instead of deep inside a handler.
 */
public class Solution {

    interface Event {}

    record OrderPlaced(String orderId) implements Event {}

    record PaymentFailed(String reason) implements Event {}

    interface Handler<T extends Event> {
        void handle(T event);
    }

    static final class EventBus {
        /**
         * Handlers are stored already adapted to Consumer<Event>, so nothing
         * raw and nothing unchecked survives past subscribe().
         */
        private final Map<Class<?>, List<Consumer<Event>>> handlers = new HashMap<>();

        /**
         * FIX: `? super T` — a Handler consumes its type, so it may be a
         * handler of T or of anything above T.
         */
        <T extends Event> void subscribe(Class<T> type, Handler<? super T> handler) {
            handlers.computeIfAbsent(type, k -> new ArrayList<>())
                    .add(event -> handler.handle(type.cast(event)));
        }

        void publish(Event event) {
            for (Class<?> key : List.of(event.getClass(), Event.class)) {
                for (Consumer<Event> handler : handlers.getOrDefault(key, List.of())) {
                    handler.accept(event);
                }
            }
        }
    }

    public static void main(String[] args) {
        List<String> delivered = new ArrayList<>();
        boolean noMisdelivery = true;

        EventBus bus = new EventBus();

        Handler<Event> audit = e -> delivered.add("audit <- " + e);
        bus.subscribe(Event.class, audit);

        Handler<OrderPlaced> orders = e -> delivered.add("orders <- " + e.orderId());
        bus.subscribe(OrderPlaced.class, orders);

        // The API is no longer too strict: the audit handler registers for a
        // SPECIFIC type too, which the original signature refused.
        bus.subscribe(OrderPlaced.class, audit);

        // And the six-week-old bug no longer compiles:
        //
        //   bus.subscribe(Event.class, orders);
        //
        //   error: method subscribe in class EventBus cannot be applied to
        //          given types;
        //     required: Class<T>,Handler<? super T>
        //     found:    Class<Event>,Handler<OrderPlaced>
        //     reason: inference variable T has incompatible bounds
        //
        // which is the whole point — the wildcard did not just permit what
        // was wanted, it forbade what was wrong.

        System.out.println("── publishing two events ──");
        try {
            bus.publish(new OrderPlaced("ORD-1"));
            bus.publish(new PaymentFailed("card declined"));
        } catch (ClassCastException e) {
            noMisdelivery = false;
            System.out.println("  ClassCastException during delivery:");
            System.out.println("    " + e.getMessage());
        }

        System.out.println();
        for (String line : delivered) {
            System.out.println("  " + line);
        }

        System.out.println();
        long auditLines = delivered.stream().filter(s -> s.startsWith("audit")).count();
        long orderLines = delivered.stream().filter(s -> s.startsWith("orders")).count();

        // audit is registered twice for OrderPlaced (catch-all + specific),
        // so it sees that event twice and PaymentFailed once.
        boolean auditSawAll = auditLines == 3;
        boolean ordersSawOnlyOrders = orderLines == 1;

        System.out.println("every event reached its handlers : " + noMisdelivery);
        System.out.println("audit saw every delivery         : " + auditSawAll);
        System.out.println("orders handler saw only orders   : " + ordersSawOnlyOrders);
        System.out.println(noMisdelivery && auditSawAll && ordersSawOnlyOrders ? "PASS" : "FAIL");
    }
}

Stretch

The bus delivers to the exact type and to Event.class, so a handler for a supertype in between is never called. Make delivery walk the event's whole type hierarchy, keeping the signature safe. Then decide whether a handler registered twice should be called twice, and write down why — the current behaviour is a consequence rather than a decision.

← Back to Why is List<String> not a List<Object>?