intermediate~2h

Validating Commands with Interceptors

Learn to catch universal, cross-cutting command mistakes before they ever reach an aggregate, using a MessageDispatchInterceptor registered centrally on the command bus.

Learning objectives

  • Explain why some checks belong in a central interceptor instead of inside every aggregate
  • Implement a MessageDispatchInterceptor that rejects obviously invalid commands
  • Register a dispatch interceptor with the CommandBus
  • Distinguish which checks belong in an interceptor versus inside an aggregate's command handler

◆ Story

Recall the one clerk in Customer Service who's actually allowed to say no to a request once it reaches their desk. Now imagine a second, earlier checkpoint: a mailroom clerk who checks that an envelope even has a valid return address and isn't obviously empty, before it's ever routed to the department clerk for a real decision. Some checks are so basic and so universal — every single piece of mail needs them, regardless of what department it's headed to — that it would be wasteful to repeat that same check at every individual clerk's desk.

That mailroom clerk is what a MessageDispatchInterceptor is, applied to commands. It runs once, centrally, on every command dispatched through the system, before any of them are routed to a specific aggregate at all. It's not a replacement for the aggregate's own validation — it's an earlier, cheaper filter for the kind of check that applies universally, regardless of which aggregate or which command type is involved.

◆ The problem

Validating inside an aggregate's command handler works perfectly well for rules specific to one command type — an empty email on a CreateCustomerCommand, an overdrawn balance on a withdrawal. But some checks apply to every single command flowing through the system, regardless of its specific type: "this identifier field must be a valid, non-empty format," "this text field must never contain certain forbidden characters." Repeating that same check inside every command handler, across every aggregate, in every microservice, is genuinely repetitive, and it's easy to forget to add on a brand-new command type down the line.

A MessageDispatchInterceptor solves exactly this. It's registered once, centrally, on the command bus, and it runs on every command dispatched through the system before that command is routed to any aggregate at all. Instead of remembering to duplicate a universal check inside every new command handler you ever write, you write it once, in one interceptor, and every command — present and future — passes through it automatically.

A MessageDispatchInterceptor<CommandMessage<?>> implements a single handle method that returns a function applied to every command as it's dispatched. Inside that function, you inspect the command's payload — often with a type check against a specific command class, when the rule only applies to certain command types — and either let it through unchanged or throw to reject it outright, before it ever reaches an aggregate.

Writing the interceptor class alone isn't enough — it also has to be explicitly registered with the CommandBus, typically inside a @Configuration class, injecting both the CommandBus and the interceptor and calling registerDispatchInterceptor() once at startup. Without this registration step, the interceptor class exists, compiles cleanly, and simply never runs, since Axon has no way to discover it on its own.

The distinction in scope between this and aggregate-level validation matters. An interceptor runs centrally, once, regardless of which aggregate the command is ultimately headed to — ideal for cross-cutting, stateless checks. Business rules that depend on an aggregate's own current state — "you can't withdraw more than the current balance" — can only be checked inside the aggregate itself, since only the aggregate, rebuilt from its own event history, actually knows its own current state. Use interceptors for universal, stateless checks; use the aggregate for anything that depends on the aggregate's own data.

💻 Code example

@Component public class CommandValidationInterceptor implements MessageDispatchInterceptor<CommandMessage<?>> { @Override public BiFunction<Integer, CommandMessage<?>, CommandMessage<?>> handle(List<? extends CommandMessage<?>> messages) { return (index, command) -> { Object payload = command.getPayload(); if (payload instanceof CreateCustomerCommand cmd) { if (cmd.getCustomerId() == null || cmd.getCustomerId().isBlank()) { throw new IllegalArgumentException("customerId is required on every command"); } } return command; // unchanged and allowed through, on to the aggregate }; } } @Configuration public class AxonConfig { @Autowired public void registerInterceptor(CommandBus commandBus, CommandValidationInterceptor interceptor) { commandBus.registerDispatchInterceptor(interceptor); } }

The single most common mistake with dispatch interceptors is exactly the one already flagged: writing the interceptor class, having it compile fine, and forgetting the registration step. Because there's no compile-time link forcing you to register it, this failure is silent — commands that should be rejected sail through untouched, and nothing in the logs points at a missing registration line. Always confirm a new interceptor is actually firing — a simple log line inside it while testing is enough — rather than assuming it works just because the class compiled.

A second mistake is putting a check inside an interceptor that actually needs an aggregate's current state to evaluate correctly. An interceptor runs before any aggregate is loaded, so it has no access to that aggregate's fields at all — trying to check something like "does this customer already exist" inside an interceptor either requires an extra, separate lookup (adding real complexity and a potential race condition against the aggregate's own state) or simply can't be done correctly there. That category of check belongs inside the aggregate's command handler instead, where the current state is already available after replay.

A third, more subtle mistake is stacking too much unrelated logic into one interceptor. Since one interceptor runs on every command in the system, cramming validation for many unrelated command types into a single class makes it a growing, hard-to-navigate bottleneck. Splitting checks across several focused interceptors, each registered independently, tends to stay more maintainable as a system grows.

▲ Edge case

An interceptor that mutates a command's payload rather than only validating it (returning a modified command instead of the original) is technically allowed by the handle method's signature, but should be used sparingly and predictably — attaching metadata like a correlation ID is a reasonable use; silently changing a command's actual business fields is not, since it makes debugging a rejected or misbehaving command far harder to trace back to its original request.

Real systems commonly use dispatch interceptors for a small set of genuinely universal concerns. Attaching a correlation ID to every command as it's dispatched — so that a single request can be traced across every service and event it touches — is one of the most common uses, applying the same tracing idea used for HTTP requests in a typical web service, just applied here to commands instead. Centrally logging every command that flows through the system for audit purposes is another common use, since it guarantees every command is captured exactly once, in one place, rather than relying on every individual command handler to remember to log itself.

Interceptors are also a natural place to enforce organization-wide policies that shouldn't depend on any individual engineer remembering to add a check — things like rejecting commands missing a required tenant identifier in a multi-tenant system, or rejecting commands whose payload exceeds some maximum size before they're allowed anywhere near an aggregate.

Because interceptors run centrally and cheaply, before any aggregate loading happens, they're also a sensible place to reject obviously malformed input as early and as cheaply as possible — failing fast on a clearly invalid command before spending the cost of replaying an aggregate's full event history only to reject it moments later inside the command handler.

  • Q: What kind of validation belongs in an interceptor, versus inside the aggregate? A: Universal, stateless checks that apply to every command belong in an interceptor; checks depending on an aggregate's own current state can only happen inside the aggregate, since only the aggregate has access to that state after replay.

  • Q: What's a genuinely common mistake when adding a dispatch interceptor? A: Forgetting to explicitly register it with the CommandBus, so the class compiles fine but silently never actually runs.

  • Q: When does a MessageDispatchInterceptor run, relative to an aggregate? A: Before any command is routed to an aggregate — it runs centrally, once, on every command dispatched through the system.

  • Q: Why can't an interceptor check something like "does this customer already exist"? A: An interceptor runs before any aggregate is loaded, so it has no access to an aggregate's current state; that kind of check belongs inside the aggregate's command handler instead.

  • Q: Name two common real-world uses for a command dispatch interceptor. A: Attaching a correlation ID to every command for tracing, and centrally logging every command that flows through the system for audit purposes.

Want a visual for this concept?

Generate a diagram tailored to “Validating Commands with Interceptors” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.

Sign in to generate a visual →

Practice quiz

Next Step

Continue to Event Processors: Subscribing vs Tracking← Back to all Event-Driven Microservices chapters