Bracket command execution with a scope
A pipeline filter runs around one concern. An execution scope brackets the
whole command: it begins before any filter or handler runs, and completes after
the outcome is known, whether that outcome is success, a validation rejection, a
thrown exception or cancellation. That makes it the right place for a lifetime
concern — a transaction, a unit of work, a diagnostic span — rather than
something you open in handle and hope to close.
Arc’s own Chronicle integration is built this way: ChronicleCommandTransaction
is an execution scope, which is why an event transaction commits or rolls back
around the command rather than inside it.
The contract
Section titled “The contract”public interface BlockingCommandExecutionScope { void begin(CommandContext context);
CommandResult<?> complete(CommandContext context, CommandResult<?> result);}
public interface AsyncCommandExecutionScope { void begin(CommandContext context);
CompletionStage<CommandResult<?>> complete(CommandContext context, CommandResult<?> result);}public interface CommandExecutionScope { fun begin(context: CommandContext)
suspend fun complete(context: CommandContext, result: CommandResult<*>): CommandResult<*>?}begin is synchronous and runs before filters and the handler.
complete runs for every post-begin outcome, in reverse begin order, so
scopes nest like a stack. It may return an immutable fragment to amend the final
result — returning null leaves the result unchanged. Only scopes that actually
began are completed.
Register a scope
Section titled “Register a scope”Declare it as a bean. Arc collects every CommandExecutionScope in the context.
public class UnitOfWorkScope implements BlockingCommandExecutionScope { private final UnitOfWork unitOfWork;
public UnitOfWorkScope(UnitOfWork unitOfWork) { this.unitOfWork = unitOfWork; }
@Override public void begin(CommandContext context) { unitOfWork.begin(); }
@Override public CommandResult<?> complete(CommandContext context, CommandResult<?> result) { if (result.isSuccess()) { unitOfWork.commit(); } else { unitOfWork.rollback(); } return null; }}
@Configurationclass ScopeConfiguration { @Bean CommandExecutionScope unitOfWorkScope(UnitOfWork unitOfWork) { return new BlockingCommandExecutionScopeAdapter(new UnitOfWorkScope(unitOfWork)); }}@Componentclass UnitOfWorkScope(private val unitOfWork: UnitOfWork) : CommandExecutionScope { override fun begin(context: CommandContext) { unitOfWork.begin() }
override suspend fun complete(context: CommandContext, result: CommandResult<*>): CommandResult<*>? { if (result.isSuccess) { unitOfWork.commit() } else { unitOfWork.rollback() } return null }}Returning null is the common case: the scope did its work and has nothing to
say about the response.
Completion is bounded
Section titled “Completion is bounded”complete runs under a timeout — executionScopeCompletionTimeout, five seconds
by default, and it must be positive. A scope that hangs cannot hold the request
open indefinitely.
This matters for what you put in complete. A commit is a reasonable thing to
await; a long retry loop or an unbounded network call is not, because exceeding
the timeout fails the completion rather than extending it.
From Java
Section titled “From Java”The Java tab above implements BlockingCommandExecutionScope and registers it through its adapter,
because Arc collects only CommandExecutionScope beans. Java implements one
of two interfaces, depending on whether the completion work is asynchronous, and
registers it through the matching adapter:
AsyncCommandExecutionScope—completereturnsCompletionStage<CommandResult<?>>, registered viaAsyncCommandExecutionScopeAdapter.BlockingCommandExecutionScope—completereturnsCommandResult<?>directly, registered viaBlockingCommandExecutionScopeAdapter.
The blocking variant is called inline on Arc’s bounded request worker pool and occupies one of its
workers for as long as it runs, the same as a blocking filter. Keep complete
short, or implement the asynchronous variant for work that waits on I/O.
When not to use a scope
Section titled “When not to use a scope”A scope runs for every executed command, and it begins before the filters, so it also completes
when a filter rejects the command. Preflight validate calls do not run scopes, and neither do
failures that happen before scopes begin, such as a missing handler or an exception while building
the command context. If the concern belongs to one command or one
group of them, a pipeline filter is the narrower tool and
says so in its own declaration.
A scope is also not a compensation mechanism. complete observes the outcome and
can amend the result; it does not re-run the handler or undo work the handler did
directly against a service.