Skip to content

Apply cross-cutting rules with pipeline filters

Some rules belong to an artifact. “This command needs a title” is about that command, and it lives on that command.

Other rules belong to a whole area. “Everything in the billing feature needs the billing role” is not really about any one command — it is about the feature — and writing it on each command in turn means the twenty-first command is the one somebody forgets. A filter is where that kind of rule goes: one declaration that every command or query in the pipeline passes through.

A filter sees the resolved CommandContext — the command instance, its type, the principal authentication already produced, the correlation identifier, the tenant — and returns a result that is merged into the outcome. An unsuccessful result stops the remaining filters. It stops the handler too when the result carries an authorization failure, an exception, or validation feedback that is still blocking after the allowed-severity threshold is applied; warning-only feedback is dropped by default and the handler runs:

public final class BillingCommandFilter implements BlockingCommandFilter {
private static final String BILLING_PACKAGE = "com.example.features.billing";
private static final String BILLING_ROLE = "billing";
@Override
public CommandResult<?> execute(CommandContext context) {
if (!context.getCommandType().getName().startsWith(BILLING_PACKAGE)
|| context.getPrincipal().isInRole(BILLING_ROLE)) {
return CommandResult.success(context.getCorrelationId());
}
return CommandResult.unauthorized(
context.getCorrelationId(),
"Role '" + BILLING_ROLE + "' is required for commands in '" + BILLING_PACKAGE + "'."
);
}
}

View Java snippet source on GitHub

Register it as a bean and it applies to every command:

@Bean
public CommandFilter billingCommandFilter() {
return new BlockingCommandFilterAdapter(new BillingCommandFilter());
}

View Java snippet source on GitHub

A filter that does not apply to the command in front of it returns success and gets out of the way. That first check is what keeps a feature’s rule scoped to the feature.

This example is an ordinary filter, so it runs in @Order sequence with the others. Because its job is to authorize, make it an AuthorizationCommandFilter (in Java, a BlockingAuthorizationCommandFilter registered through BlockingAuthorizationCommandFilterAdapter) when it must run before filters that cause effects. Order them deliberately explains why.

The same shape, against QueryContext. The query’s fully qualified name is what you match on, because a query is a method rather than a type:

public final class BillingQueryFilter implements BlockingQueryFilter {
private static final String BILLING_PACKAGE = "com.example.features.billing";
private static final String BILLING_ROLE = "billing";
@Override
public QueryResult<?> execute(QueryContext context) {
if (!context.getQueryName().value().startsWith(BILLING_PACKAGE)
|| context.getPrincipal().isInRole(BILLING_ROLE)) {
return QueryResult.success(context.getCorrelationId());
}
return QueryResult.unauthorized(context.getCorrelationId());
}
}

View Java snippet source on GitHub

A query filter runs for observable queries too, at subscription time — so a rule written once covers both the one-shot GET and the live subscription.

Java implementations never implement a suspending method. Pick the adapter that matches how your code is already written:

Your codeImplementRegister with
SynchronousBlockingCommandFilter / BlockingQueryFilterBlockingCommandFilterAdapter / BlockingQueryFilterAdapter
CompletionStageAsyncCommandFilter / AsyncQueryFilterAsyncCommandFilterAdapter / AsyncQueryFilterAdapter

A filter whose job is to authorize has its own variant of each interface. Its adapter keeps it in the authorization group that runs first:

  • BlockingAuthorizationCommandFilter, registered with BlockingAuthorizationCommandFilterAdapter
  • BlockingAuthorizationQueryFilter, registered with BlockingAuthorizationQueryFilterAdapter
  • AsyncAuthorizationCommandFilter, registered with AsyncAuthorizationCommandFilterAdapter
  • AsyncAuthorizationQueryFilter, registered with AsyncAuthorizationQueryFilterAdapter

A blocking filter occupies a request thread for as long as it runs. Keep it to decisions it can make from the context it already has; if it needs to call something remote, use the CompletionStage form instead.

Filters are collected in Spring @Order sequence, and that order is the complete rule — there is no hidden precedence:

@Bean
@Order(10)
public CommandFilter auditFilter() {
return new BlockingCommandFilterAdapter(new AuditCommandFilter());
}
@Bean
@Order(20)
public CommandFilter billingFilter() {
return new BlockingCommandFilterAdapter(new BillingCommandFilter());
}

View Java snippet source on GitHub

Authorization filters are the one exception: a filter implementing AuthorizationCommandFilter or AuthorizationQueryFilter runs before the rest regardless of its order, so a rule that denies access is never reached after a filter that has already caused an effect. Implement that interface when your filter’s job is to authorize, and the ordinary one when it is not.

A filter is the wrong tool when the rule is about one artifact. @Authorize and @Roles on the command or the query say the same thing closer to the code they govern, are visible in the generated client and the OpenAPI document, and do not require a reader to go looking for a bean that might apply. See authentication and authorization.

Use a filter when the rule genuinely spans artifacts, and prefer matching on something structural — a package prefix — over a list of names somebody has to maintain.

The samples include a Cross-Cutting Authorization page backed by exactly this pattern, in Kotlin and in Java. Run ./Samples/run.sh, open the page, and add or remove the role in the toolbar to watch both the command and the query change their answer.