Skip to content
Preptima
mediumDesignCodingMidSeniorStaff

Messages arrive with a type field and each type needs a different object built to process it. How do you design the creation so you do not end up with a growing switch statement?

Replace the switch with a registry the application builds once at startup: a map from discriminator to a factory, validated for duplicate and missing keys as it is constructed, injected rather than global, and with a decided answer for a type the consumer has never seen.

5 min readUpdated 2026-07-29Target archetype: Big Tech, Enterprise Captive, Product Startup
Practice answering out loud

What the interviewer is scoring

  • Whether the candidate names the concrete cost of the switch rather than calling it ugly
  • Does the answer keep the registry an injected object rather than a static global map
  • That duplicate or missing keys are detected when the registry is built, not on the first message
  • Whether instance lifecycle is decided deliberately - shared and stateless, or one per message
  • Does the candidate refuse to instantiate a type named directly by the payload

Answer

What the switch statement actually costs

A switch over the type field works, and dismissing it as ugly is not an answer. Name the costs precisely, because they are what the alternative has to buy back.

The mapping lives in a different file from the thing being mapped, so adding a type means editing a central file that everyone else is also editing, and forgetting that edit produces a class that compiles, ships, and is never reached. The switch also tends to multiply: one for construction, another for validation, a third for choosing a metric name, and they drift, so a type is handled in two of the three places. And the central file accumulates imports of every implementation, which makes the module that dispatches depend on every module that handles, in the wrong direction.

Against that, a switch has one real virtue worth keeping in mind: with an exhaustive switch over a closed enumeration, the compiler tells you when a case is missing. If the set of types is genuinely closed and lives in your own codebase, that check is valuable and a registry throws it away. The registry earns its place when the set is open — new types arrive with new deployments, or from plugins, or from a producer you do not own.

Build the map once and validate it while building

The shape is a map from discriminator to a factory or an instance, constructed at startup from whatever the application decided to wire in.

public interface EventHandler {
    String type();                 // the discriminator this implementation claims
    void handle(Event event);
}

public final class HandlerRegistry {
    private final Map<String, EventHandler> byType;

    // Handlers are injected. Nothing here is static, so a test can build a
    // registry with two fakes and no framework.
    public HandlerRegistry(Collection<EventHandler> handlers) {
        // Collectors.toUnmodifiableMap throws on a duplicate key, so two
        // handlers claiming the same type fail startup rather than silently
        // shadowing each other in whatever order they were discovered.
        this.byType = handlers.stream()
                .collect(Collectors.toUnmodifiableMap(EventHandler::type, Function.identity()));
    }

    public Optional<EventHandler> forType(String type) {
        return Optional.ofNullable(byType.get(type));
    }
}

Two design decisions are doing the work there, and both are about when a mistake surfaces.

The constructor rejects duplicates, so the failure happens at startup with both offending classes named, rather than at three in the morning when a message routes to whichever handler happened to win. That matters more than it looks: duplicate registration is the characteristic failure of this pattern after a merge or a rename, and it is silent unless you make it loud.

And the registry is an object with a constructor rather than a static map with a static register method. That is what makes it testable, what allows two differently-configured registries in one process, and what stops it becoming the global mutable state that the singleton discussion warns about. If you have a dependency-injection container, this is exactly what its collection injection is for — hand it every implementation of the interface and let the registry key them.

Why self-registration disappoints

The tempting refinement is to have each implementation register itself in a static initialiser, so adding a type touches only the new file. In Java this is unreliable for a specific mechanical reason: a class's static initialiser runs on first active use, and a class that nothing references is never loaded, so it never registers. The registration therefore depends on something else having already mentioned the class — which is the coupling you were trying to remove, now invisible.

The dependable versions of the same idea make discovery explicit. A service-loader mechanism reads a declared list of providers from the packaged artefact, so the list is a build-time artefact you can inspect. A DI container scans and injects. A plain hand-written list in the composition root is the least clever option and often the best one, because the complete set of types is then readable in one place, in the order somebody chose.

Never let the payload name the class

The most dangerous version of this design is the shortest one: take the type string from the message, map it to a class name, and instantiate it reflectively. That hands an attacker, or a merely confused producer, the ability to construct arbitrary types inside your process, which is the root of an entire family of deserialisation vulnerabilities. The registry must be a closed set of keys that you enumerated, and an unrecognised key is an unrecognised key — never a class name to try.

The related rule is to keep the discriminator a domain value rather than an implementation detail. If the wire format carries your fully-qualified class names, you cannot rename or move a class without breaking every producer, and you have published your internal package structure as part of the contract.

Deciding what an unknown type means

A consumer will eventually receive a type it does not know, because the producer deployed first. This is a design decision and it should not be an exception nobody handles.

Silently ignoring the message is the worst option, since data disappears with no record. Crashing the consumer is nearly as bad, because one unknown type stops all the known ones and, on a queue with redelivery, becomes a loop. The workable answer is to route it somewhere durable — a dead-letter destination holding the raw payload and the reason — so the message survives, the metric moves, and a redeploy can reprocess it. Then decide deliberately whether the consumer should acknowledge such messages, which is a data-loss question rather than a code question.

Instance lifecycle is part of the creation design

The last decision the registry forces you to make explicitly is what it hands back. A stateless handler can be a single shared instance, created once, called concurrently — cheap, and correct only if it truly holds no per-message state. A handler that accumulates anything about the message it is processing must be created per message, which means the registry stores a factory rather than an instance.

Getting this wrong produces the worst class of bug in this whole design: a shared handler with a mutable field, which works perfectly in testing and corrupts data under concurrency. Storing suppliers rather than instances costs one allocation per message and removes the question, which is usually the right trade unless the object is genuinely expensive to build.

Where several objects must be consistent with each other — a parser, a validator and a serialiser for the same message version — a flat registry of independent entries lets you mix version one's parser with version two's validator. That is the case where an abstract factory is the right answer instead: one factory per version, handing back a coherent set, so an inconsistent combination cannot be assembled by accident.

The switch is not the problem, the location of the mapping is. Build one validated registry at startup from an explicit list, keep the keys a closed set you defined, and decide deliberately whether each entry is a shared instance or a factory.

Likely follow-ups

  • A producer deploys a new type before the consumer knows about it. What should the consumer do with that message?
  • When would an abstract factory be the better shape here rather than a flat registry?
  • Your implementations register themselves in static initialisers and one of them never registers. Why?
  • After a merge, two implementations claim the same type. Where does that fail, and at what moment?

Related questions

factoryregistryobject-lifecycledependency-injectionextensibility