Stop Writing switch Statements in Your Spring Boot Services
The Registry/Engine pattern, or how to let Spring do the dispatching for you

Stop Writing switch Statements in Your Spring Boot Services
The Registry/Engine pattern, or how to let Spring do the dispatching for you
Every Spring codebase I’ve worked on has one. A service class that started clean, then grew a switch statement, then grew a second one, and eventually became the file nobody wants to open on a Friday afternoon.
It usually looks something like this:
@Service
public class PaymentService {
public PaymentResult process(Payment payment) {
switch (payment.getType()) {
case CARD:
return processCard(payment);
case PAYPAL:
return processPaypal(payment);
case BANK_TRANSFER:
return processBankTransfer(payment);
case CRYPTO:
return processCrypto(payment);
default:
throw new IllegalArgumentException("Unsupported: " + payment.getType());
}
} // 400 lines of private methods below
}
It works. It’s readable. And it has one property that will bite you: every new payment type means editing an existing, tested, deployed class.
That’s a textbook violation of the Open/Closed Principle — software should be open for extension, closed for modification. But the theory matters less than the practical consequences. Your class grows without bound. Its test suite grows with it. Two developers adding two payment methods in the same sprint get a merge conflict in the same switch block. And the default branch fails at runtime, in production, with a customer waiting.
There’s a better way, and the surprising part is that Spring has supported it since forever. Most developers just never noticed.
The insight: Spring already knows all your beans
Here’s the thing that changes how you think about this.
When you ask Spring to inject a List<PaymentHandler>, it doesn't look for a bean of type List. It finds every bean in the context that implements PaymentHandler and hands you all of them.
@Component
public class SomeService {
public SomeService(List<PaymentHandler> allHandlers) {
// Spring injected every PaymentHandler implementation. All of them.
}
}
That’s it. That’s the whole trick. Everything below is just building something useful on top of it.
Step 1: Define the contract
Each handler declares what it can do and how it does it.
public interface PaymentHandler {
PaymentType supports();
PaymentResult handle(Payment payment);
}
Two methods. supports() is the routing key — it's what the registry will use to build its lookup table. handle() is the actual work.
An implementation is a plain @Component:
@Component
@RequiredArgsConstructor
public class CardPaymentHandler implements PaymentHandler {
private final CardGatewayClient gateway;
@Override
public PaymentType supports() {
return PaymentType.CARD;
}
@Override
public PaymentResult handle(Payment payment) {
var response = gateway.charge(payment.getCardToken(), payment.getAmount());
return PaymentResult.from(response);
}
}
Notice that this handler has its own dependencies, injected normally. That’s a real advantage over a switch full of private methods — each branch of your logic becomes a first-class Spring bean with its own collaborators, its own unit test, and its own reason to change.
Step 2: Build the registry
The registry’s only job is to turn a list of handlers into a lookup table, once, at startup.
@Component
public class PaymentHandlerRegistry {
private final Map<PaymentType, PaymentHandler> handlers;
public PaymentHandlerRegistry(List<PaymentHandler> handlerList) {
this.handlers = handlerList.stream()
.collect(Collectors.toMap(
PaymentHandler::supports,
Function.identity()
));
}
public PaymentHandler get(PaymentType type) {
var handler = handlers.get(type);
if (handler == null) {
throw new UnsupportedPaymentTypeException(type);
}
return handler;
}
}
The map is built exactly once, when the application context starts. Lookups afterwards are O(1) hash lookups on an effectively immutable map — no reflection, no scanning, no per-request cost.
There’s a subtle bonus here that I’ll come back to later: if two handlers claim the same PaymentType, Collectors.toMap throws IllegalStateException and your application refuses to start. That's not a bug. That's the pattern doing you a favor.
Step 3: The engine
If the registry only did lookups, you’d have a Strategy pattern with extra steps. The engine is where it earns its keep.
@Service
@RequiredArgsConstructor
@Slf4j
public class PaymentEngine {
private final PaymentHandlerRegistry registry;
private final PaymentValidator validator;
private final MeterRegistry meterRegistry;
@Transactional
public PaymentResult process(Payment payment) {
validator.validate(payment);
var handler = registry.get(payment.getType());
var sample = Timer.start(meterRegistry);
log.info("Processing payment {} via {}",
payment.getId(), handler.getClass().getSimpleName());
try {
var result = handler.handle(payment);
meterRegistry.counter("payment.success",
"type", payment.getType().name()).increment();
return result;
} catch (PaymentException e) {
meterRegistry.counter("payment.failure",
"type", payment.getType().name()).increment();
log.error("Payment {} failed", payment.getId(), e);
throw e;
} finally {
sample.stop(meterRegistry.timer("payment.duration",
"type", payment.getType().name()));
}
}
}
Validation, transaction boundaries, structured logging, metrics, error translation — all of it lives in exactly one place and applies uniformly to every handler, present and future. Add a fifteenth payment method and it gets instrumented for free.
This separation is the real payoff. The registry answers “who?”, the engine answers “how do we run it?”, and the handler answers “what?” Three questions, three places, each independently testable.
Adding a new case
Here’s the part worth putting on a slide.
To support Apple Pay, you write one new file:
@Component
@RequiredArgsConstructor
public class ApplePayHandler implements PaymentHandler {
private final ApplePayClient client;
@Override
public PaymentType supports() {
return PaymentType.APPLE_PAY;
}
@Override
public PaymentResult handle(Payment payment) {
return PaymentResult.from(client.authorize(payment));
}
}
That’s the whole change. Zero existing files modified. Zero existing tests touched. Your code review is one new class and one new test class, and the reviewer can hold the entire change in their head.
Compare that to the switch version: edit the enum, edit the service, edit the service's test, resolve a merge conflict with whoever else is doing the same thing this sprint.
The traps
I’ve seen this pattern go wrong in a few specific ways. Worth knowing them before you ship it.
Duplicate keys fail loudly — and that’s good. Two handlers returning the same PaymentType will blow up at context startup, not at 3 a.m. when a customer hits that code path. If you want a clearer error than the default, collect manually:
Map<PaymentType, PaymentHandler> map = new EnumMap<>(PaymentType.class);
for (PaymentHandler handler : handlerList) {
var previous = map.put(handler.supports(), handler);
if (previous != null) {
throw new IllegalStateException(
"Duplicate handler for %s: %s and %s".formatted(
handler.supports(),
previous.getClass().getSimpleName(),
handler.getClass().getSimpleName()));
}
}
Conditional beans create silent gaps. A handler annotated @ConditionalOnProperty disappears from the registry when the flag is off, and you find out at runtime. If a type is mandatory, assert completeness at startup:
@PostConstruct
void assertAllTypesCovered() {
var missing = EnumSet.allOf(PaymentType.class);
missing.removeAll(handlers.keySet());
if (!missing.isEmpty()) {
throw new IllegalStateException("No handler registered for: " + missing);
}
}
Fail at boot. Always fail at boot.
Empty lists. With no implementations on the classpath at all, Spring will fail to satisfy the dependency. Mark it @Autowired(required = false), or better, keep the startup assertion above so the failure message actually tells you what's wrong.
Ordering matters when handlers can overlap. If you move to predicate-based matching where several handlers might accept the same input, injected List order is determined by @Order / Ordered. Don't rely on classpath scanning order — it's not a contract.
Don’t do this for two cases. A registry, an engine, an interface, and two @Components to replace a five-line if/else is not architecture, it's ceremony. My rough threshold: three or more branches, and you expect the list to grow. Below that, the if is genuinely the better code.
Two useful variants
Bean names as keys. Spring can inject a Map<String, PaymentHandler> directly, keyed by bean name. Zero registry code — but you've moved your routing key into a string, which no compiler will check for you. Convenient for plugin-style systems, risky for domain logic.
Predicate matching. When routing depends on more than one field, replace supports() with a richer test:
public interface PaymentHandler {
boolean supports(Payment payment);
PaymentResult handle(Payment payment);
}
The registry then iterates instead of doing a map lookup — O(n) instead of O(1), which is irrelevant for a dozen handlers. Pair it with @Order and a catch-all fallback handler registered last.
The takeaway
The Registry/Engine pattern isn’t clever. It’s three small classes and one thing Spring was already doing for you.
But it changes the shape of your codebase in a way that compounds. New behavior arrives as new files rather than edits to old ones. Merge conflicts on shared dispatch logic disappear. Cross-cutting concerns live in exactly one place. And your unsupported-case errors move from production runtime to application startup, which is the single best trade in software engineering.
The next time you find yourself typing switch inside a @Service, pause and ask whether that list is going to grow. If the answer is yes, you already know what to do.
If you’ve hit an interesting variation of this pattern — or a case where it made things worse — I’d genuinely like to hear about it in the comments.
메타데이터
- post_id
- 58f88cc28ea0
- slug
- stop-writing-switch-statements-in-your-spring-boot-services-58f88cc28ea0
- url
- https://medium.com/@marmelkambou2/stop-writing-switch-statements-in-your-spring-boot-services-58f88cc28ea0
- canonical_url
- https://medium.com/@marmelkambou2/stop-writing-switch-statements-in-your-spring-boot-services-58f88cc28ea0
- author_url
- https://medium.com/@marmelkambou2
- status
- ok
- fetched_at
- 2026-08-27 01:51:41