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.
Write a command filter
Section titled “Write a command filter”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. Returning anything unsuccessful stops the command before its
handler is called:
public class BillingCommandFilter : CommandFilter { override suspend fun execute(context: CommandContext): CommandResult<*> { if (!context.commandType.name.startsWith(BILLING_PACKAGE)) { return CommandResult.success(context.correlationId) } if (context.principal.isInRole(BILLING_ROLE)) { return CommandResult.success(context.correlationId) } return CommandResult.unauthorized( context.correlationId, "Role '$BILLING_ROLE' is required for commands in '$BILLING_PACKAGE'." ) }
private companion object { const val BILLING_PACKAGE = "com.example.features.billing" const val BILLING_ROLE = "billing" }}Register it as a bean and it applies to every command:
@Beanpublic fun billingCommandFilter(): CommandFilter = BillingCommandFilter()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.
Write a query filter
Section titled “Write a query filter”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 class BillingQueryFilter : QueryFilter { override suspend fun execute(context: QueryContext): QueryResult<*> { if (!context.queryName.value.startsWith(BILLING_PACKAGE)) { return QueryResult.success<Any>(context.correlationId) } if (context.principal.isInRole(BILLING_ROLE)) { return QueryResult.success<Any>(context.correlationId) } return QueryResult.unauthorized<Any>(context.correlationId) }}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.
In ordinary Java
Section titled “In ordinary Java”Java implementations never implement a suspending method. Pick the adapter that matches how your code is already written:
| Your code | Implement | Register with |
|---|---|---|
| Synchronous | BlockingCommandFilter / BlockingQueryFilter | BlockingCommandFilterAdapter / BlockingQueryFilterAdapter |
CompletionStage | AsyncCommandFilter / AsyncQueryFilter | AsyncCommandFilterAdapter / AsyncQueryFilterAdapter |
public final class BillingCommandFilter implements BlockingCommandFilter { @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(), "Billing role required."); }}
@Beanpublic CommandFilter billingCommandFilter() { return new BlockingCommandFilterAdapter(new BillingCommandFilter());}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.
Order them deliberately
Section titled “Order them deliberately”Filters are collected in Spring @Order sequence, and that order is the complete rule — there is no
hidden precedence:
@Bean@Order(10)public fun auditFilter(): CommandFilter = AuditCommandFilter()
@Bean@Order(20)public fun billingFilter(): CommandFilter = BillingCommandFilter()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.
When not to reach for a filter
Section titled “When not to reach for a filter”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.
See it running
Section titled “See it running”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.