v7.0.0-preview.1
Pre-releaseThis is a pre-release of 7.0.0. Packages are published to NuGet with the
7.0.0-preview.1
version suffix, sodotnet add packageonly resolves them when a pre-release version is requested
explicitly or--prereleaseis passed. The notes below describe everything landing in 7.0.0;
parts of it may still change before the stable release.
Major release for .NET 10, describing the change from v6.0.2. Adds a completion stage to the mediation pipeline, four
named decision stages that decide whether work happens, declarative message metadata, and an audit trail built on all
three. The pre stage is where most of the break lands: guards, validators, shortcuts, and pre-handlers are now four
contracts the framework can tell apart before invoking them, which is what lets it fix the order they run in.
Persistence schemas and transport behavior are unchanged.
Added
- A fifth pipeline stage.
IMessageCompletionHandler<TMessage>,IMessageCompletionHandler<TMessage, TMessageResult>,
and the axis contractsICommandCompletionHandler,IQueryCompletionHandler, andIEventCompletionHandlerrun in a
finallyon every mediation path, exactly once, and receive a read-onlyMessageCompletionContextcarrying the
outcome, the result, the exception, the reason, and the elapsed duration. Post-handlers run only when the main handler
succeeds and error handlers only for recoverable exceptions, so until now no stage could observe how a message
actually ended. Recording an audit entry, emitting a metric, or closing a unit of work belongs here. - The completion stage is not cancellable. Handlers receive
CancellationToken.None, because the ending has already
happened and handing the stage the token that just fired would drop exactly the records a review looks for. - Guards. A pre-stage handler that may refuse a message implements
IMessageGuard<TMessage>and returns aVerdict
fromDecideAsync, with the axis contractsICommandGuard<TCommand>,IQueryGuard<TQuery>, and
IEventGuard<TEvent>. A refusal always carries a reason, may carry a code, and reportsMediationOutcome.Denied, which
an audit trail records as a denial. The compiler requires the decision, so nothing after it runs by accident, and an
expected control-flow path stays off the exception path. - Validators.
IMessageValidator<TMessage>returnsValidityfromValidateAsync, with the axis contracts
ICommandValidator<TCommand>,IQueryValidator<TQuery>, andIEventValidator<TEvent>. A validator answers whether
the message is well-formed, which is a different question from whether the caller may send it, so a failure reports
MediationOutcome.Invalidrather thanDeniedand stays out of the list a security review reads. Unlike every other
decision stage, this one runs every validator and collects their failures rather than stopping at the first: a caller
fixing a malformed message should not discover its problems one round trip at a time.ValidationFailurecarries the
message, the member it applies to, and an optional code. - Refusal mappers.
IMessageRefusalMapper<TMessage, TMessageResult>turns a guard refusal or a validation failure into
the value the caller receives, for applications that model failure as data rather than as an exception, with the axis
contractsICommandRefusalMapper,IQueryRefusalMapper, andIStreamQueryRefusalMapper. One registration against
ICommandcovers every command producing that result type, and a mapper registered against a concrete message wins
over it. Without a mapper, a refusal reaches the caller asLiteBusMessageDeniedExceptionor
LiteBusMessageInvalidException. - Shortcuts. A pre-stage handler that answers a message whose work is already done implements
IMessageShortcut<TMessage>orIMessageShortcut<TMessage, TMessageResult>and returns aShortcutfrom
TryAnswerAsync, with the axis contractsICommandShortcut<TCommand>,ICommandShortcut<TCommand, TCommandResult>,
IQueryShortcut<TQuery, TQueryResult>,IStreamQueryShortcut<TQuery, TQueryResult>, andIEventShortcut<TEvent>. A
cache hit or a replayed idempotent command reportsMediationOutcome.Answered, which an audit trail records as a
success because nothing was denied. Keeping that apart from a denial is the distinction a security review reads. - The framework fixes the stage order: guards, then validators, then shortcuts, then pre-handlers. Priority orders
handlers inside a stage and never reorders the stages, so a globally registered cache shortcut cannot answer a caller
that a message-specific authorization guard would have denied, and a malformed message cannot claim an idempotency
key. The order encodes what each stage may assume about its input: a guard sees every message, a validator sees only
messages the caller is allowed to send, a shortcut sees only well-formed ones, and a pre-handler sees only messages
that are going to be handled. Under a single pre-handler stage that ordering rested on a priority number the author
had to remember, and indirect handlers ran ahead of direct ones regardless. ASP.NET Core documents the same hazard for
UseOutputCacheafterUseAuthorization; because LiteBus owns its stages, it makes the mistake unrepresentable
instead of documenting it.PreStagenames the four stages andIPreStageHandlerDescriptor.Stagerecords which one
runs a handler. Shortcut<TMessageResult>types the answer over the result type of the message, so a shortcut that answers a
result-returning message is required by the compiler to supply the value the caller receives. Answering always carries
the result; a stream query that means no items answers withAsyncEnumerable.Empty<T>(), which states that outright
rather than leaving it implied by a missing value. A denial owes the caller nothing, so one guard contract fits every
message, and the value a denied caller receives comes from a refusal mapper instead.MessageContextExtensions.RunAsyncPreStagesgives a custom mediation strategy the same stage order the shipped
strategies use, in one call rather than four, because running one stage without the others cannot honor the ordering
guarantee the split exists to provide.ResolveRefusalResult<TMessageResult>applies the registered refusal mapper,
or raises when none covers the message. Both live inLiteBus.Messagingrather than in the abstractions package.RunAsyncErrorHandlersandRunAsyncCompletionHandlerseach take the execution context and open their own ambient
scope, so a strategy no longer has to wrap them. The error runner also captures theExceptionDispatchInfoitself,
which is what preserves the original stack when nothing recovers, and the completion runner resolves a post-handler's
replacement result in preference to the handler's own. Both rules used to be a strategy's job to remember. The
completion runner takes the outcome, the failure, and the reason a strategy tracked, and builds the context from them.LiteBusMessageDeniedExceptionandLiteBusMessageInvalidExceptionreach the caller when no refusal mapper covers
the message. Both are excluded from the recoverable-exception filter, so error handlers never see a decision as a
fault, andLiteBusMessageInvalidException.Failurescarries every failure the validator stage collected.MediationExceptionFilters.IsRefusalandIsRetryableDispatchExceptionclassify a decision apart from a fault. The
inbox and outbox processors use the second to dead-letter a refusal or a missing handler on the first attempt instead
of spending the retry schedule on an answer that cannot change.MediationOutcomedistinguishesSucceeded,Answered,Denied,Invalid,Failed, andCanceled. Every member
is reported by some path, and each names a state the message ended in rather than a mechanism the pipeline used.IExecutionContext.SuppressPostHandlers()skips the post-handlers that have not run yet. Use it when the work turned
out to be a no-op and the reactions to it should not fire, such as an idempotent command that detects it already ran.
It does not stop the calling handler and does not change the outcome.- Declarative message metadata.
IMessageDescriptor.Metadataexposes values resolved once at registration from
declaring attributes on the message type and from message definitions, so a pipeline stage reads a dictionary instead
of reflecting on every dispatch. - Message definitions. A definition class lives beside the message it describes and declares one value per concern
throughIMessageDefinition<TMessage, TValue>. Declarations are keyed by value type, so one class may declare several
without ambiguity, and applications may declare their own value types that LiteBus applies without interpreting. A
declaration covers the message type it names and every message assignable to it, so one definition can describe a
family of messages; the most derived declaration wins. IMessageDeclarationSourcemarks an attribute as a source of message metadata and states the value type it declares.
Only attributes implementing it are collected, which keeps metadata bounded, and it puts attributes and definitions on
one key so a definition genuinely overwrites an attribute rather than sitting beside it.- An audit trail at the mediation boundary.
[Audited]and[AuditExempt], or anIAuditDefinition<TMessage>, declare
the constant half of a record;IAuditScopesupplies what only the handler knows.EnableAuditing()on the command
and query module builders registers the writer, which hands anAuditRecordto the application'sIAuditTrail.
Because it runs at the completion stage, refusals, failures, and cancellations are recorded as first-class outcomes. - The trail itself is registered on the messaging module through
UseAuditTrail<T>()orUseAuditTrail(instance),
beside the outcome mapper, so the shared half of auditing is configured in one place while the per-axis switch
stays where the decision belongs. AuditDeclarationis a closed hierarchy ofAuditedDeclarationandAuditExemptDeclaration, so a declaration cannot
hold a combination that means nothing, such as a category on an exemption.ReasonRequiredon an audited declaration is enforced. A successful action that declares it and supplies no reason
raisesLiteBusConfigurationExceptionrather than writing an incomplete record.AuditTrailDiagnosticCheckreports thelitebus.audit.trailprobe as unhealthy when auditing is enabled and no
IAuditTrailis registered, so a missing sink surfaces before the first audited mediation.IAuditOutcomeMapperandMessageModuleBuilder.UseAuditOutcomeMapperlet an application that refuses by throwing
record its own exception asAuditOutcome.Deniedrather thanAuditOutcome.Failed. Refusing through a guard needs no
mapper.MediationExceptionData.SuppressedCompletionFaultsis the key under which a completion-handler fault is attached to
the exception that was already ending the mediation, so a failed audit write is never silently discarded.LB1018reports command and query types that state no audit position, so an unaudited message is a recorded decision
rather than an oversight. Disabled by default; enable withdotnet_diagnostic.LB1018.severity = warning.LB1019reports a shortcut that implements the untyped shortcut contract for a message that produces a result.
BecauseICommand<TResult>derives fromICommand, that contract compiles there, and answering from it fails at
runtime withLiteBusConfigurationException. The typed contract is a strict superset for such a message, so the rule
names it and the declaration is where the fix goes. Open generic shortcuts are not reported, and guards and validators
never are: a refusal owes the caller no result, so one contract is correct for every message on those stages.- Registration rejects an untyped shortcut declared for a message that produces a result, so the mistake
LB1019
reports cannot reach production in a project that does not reference the analyzer package. The check runs from
both directions, since a handler may be registered before or after the message it handles. HandlerPrioritiesreserves a priority band for handlers shipped by LiteBus, so ordering against them is a documented
guarantee. Application handlers stay belowReservedFloorand, with no explicit priority, run first.IHandlerDescriptor.ContractTyperecords the closed contract a descriptor was discovered from, andPipelineDispatch
carries the delegate bound to it at registration.
Changed
- Every module builder recognizes guard, validator, shortcut, and refusal mapper contracts, completion handler
contracts, and message definitions as registrable constructs, soRegisterFromAssemblydiscovers them. AsyncBroadcastMediationStrategyobserves cancellations so it can report them to the completion stage, honors a guard
or shortcut decision by publishing to no handlers, and reports no result to completion handlers rather than the task
that tracked its handlers. Cancellation still propagates as before.- A decision on a stream query no longer runs post-handlers. Stopping the pipeline means the work did not happen, so
the reactions to it do not fire; the caller still receives whatever stream the shortcut or the refusal mapper
supplied. - Every dispatchable handler contract is declared in one place.
PipelineContractsholds one row per contract naming
its family, its invoker, and, for a pre-stage contract, its stage and aggregation policy. Dispatch, all four
descriptor builders, and the stage runner read from it, so adding the validator stage no longer takes edits in nine
files, and post-handlers, completion handlers, and refusal mappers are declared the same way rather than hand-wired
beside the table. The run order is read from thePreStageordinals rather than from a hand-written call sequence,
which makes the order the enum documents the order that executes. - The stream mediation strategy routes every fault through one place instead of six, and enumerates the handler's
stream and a post-handler's replacement through one loop instead of two. It is a third shorter. One timing changes
with it: the handler's enumerator is released when its enumeration ends rather than when the whole mediation does, so
it is now disposed before post-handlers run rather than after. A post-handler receives theIAsyncEnumerableand
would enumerate it afresh, so nothing observes this beyond the resource being held for less time. - The inbox and outbox share one processor hook runner. They ran identical copies, and each built a fresh envelope
adapter in all five hook phases, so a single dispatch allocated five of them per axis. The adapter is now built once
per dispatch. - A pre stage that holds no handler is skipped without enumerating the shared descriptor collection.
IMessageDependencies.HasPreStageHandlersanswers from a mask computed once when dependencies are resolved, so a
message with no guard, validator, or shortcut costs nothing for those stages. The default implementation on the
interface enumerates and is correct for custom implementations, so nothing outside LiteBus has to change. - Registering a type that carries a pipeline marker but names no message type is reported with
LiteBusConfigurationExceptioninstead of being accepted. Every marker is memberless, so such a type produced no
descriptor, fell through to message-type registration, and silently never ran. - Pre-handlers, post-handlers, and completion handlers are invoked through the closed contract recorded in their
descriptor at registration, using a delegate built while the descriptor is built. The previous dispatch searched a
handler's interfaces for a method by name on every invocation and called it reflectively, which is how a class
implementing pipeline contracts for several message types could have the wrong method selected. Choosing the contract
from registration metadata makes that class of bug structurally impossible, and building the delegate at registration
keeps reflection out of the dispatch path. - Two definitions declaring the same value type for one message, or two declarations covering one message where neither
is more derived than the other, are reported at registration instead of being resolved by assembly scanning order. - Dependencies are updated to their current versions, which clears the
SSH.NETadvisory reached transitively through
Testcontainers and restores a cleanNuGetAuditrun. Four are deliberately held back: Roslyn stays on 4.x so the
analyzer loads on the compiler the .NET 10 SDK ships, EF Core and Npgsql stay on 9.x because
Pomelo.EntityFrameworkCore.MySqlhas no EF Core 10 provider, andSQLitePCLRawstays on 2.1.x to match the EF Core
9 SQLite provider.
Fixed
- Publishing to and consuming from Amazon SQS no longer raises
NullReferenceException. AWSSDK 4 stopped initializing
theMessageAttributesandAttributescollections, which the mapper wrote to and read from directly, so every
publish failed on the first attribute write. The mapper now supplies its own attribute dictionary and treats an absent
one on a received message as empty. - Handler discovery in the analyzers recognizes the two-parameter post-handler contracts and the stream query
post-handler contract. A handler implementing only those was invisible to LB1011 and LB1012, so an unused
[HandlerTag]on one was not reported.
Breaking
IExecutionContext.AbortandLiteBusExecutionAbortedExceptionare removed. A pre-handler that stopped the pipeline
now implements a guard contract and returns aVerdict, or a shortcut contract and returns aShortcut. The break is
a compile error rather than a change in behavior, which is deliberate: a flag that leftAbort()compiling would have
silently started running the statements after it.ICommandValidator<TCommand>andIQueryValidator<TQuery>returnTask<Validity>fromValidateAsyncinstead of
Task, and derive fromIMessageValidator<TMessage>rather than from the pre-handler contract. A validator that
reported a failure by throwing now returnsValidity.Invalid(...)instead. The break is a compile error rather than a
change in behavior, for the same reason theAbort()removal is: a validator left compiling would have gone on
reporting malformed input as a fault. An adapter over an external validation library changes one line, returning the
failures instead of raising.- The non-generic pre-stage marker is renamed
IMessagePreStageHandler. It is the discovery marker for the whole pre
stage, which now holds four roles, so it can no longer share a name withIMessagePreHandler<TMessage>, the one role
in that stage LiteBus does not name. The post and completion stages hold a single role each and keep the shared name. IAsyncMessageErrorHandler<TMessage>andIAsyncMessageErrorHandler<TMessage, TMessageResult>are renamed
IMessageErrorHandler<TMessage>andIMessageErrorHandler<TMessage, TMessageResult>. The prefix named nothing there:
no synchronous error handler exists, and the contract derives straight from theIMessageErrorHandlermarker. Every
stage now follows one rule, that a marker shares its name with its role when the stage holds a single role. The main
handler keepsIAsyncMessageHandler, where the prefix does name something: it is theTask-returning specialization
ofIMessageHandler<TMessage, TMessageResult>, alongsideIStreamMessageHandlerfor theIAsyncEnumerableone.IMessageDescriptorandIMessageDependenciesgainedRefusalMappersandIndirectRefusalMappers. Custom
implementations, including test doubles, must add them.IMessageDependencies.HasPreStageHandlersships with a
default implementation, so it needs no change unless a custom implementation wants the faster answer.- A refusal or a missing handler now dead-letters on its first attempt in the inbox and outbox processors instead of
consuming the retry schedule. Both fail identically on every attempt, so retrying only delayed the dead-letter entry
an operator was waiting to see. - Only a guard or a shortcut can stop the pipeline. Stopping means skipping the work, and once the main handler has run
there is nothing left to skip. A handler that previously aborted from a later stage callsSuppressPostHandlers(). - The synchronous handler layer is removed, and the asynchronous one takes over its names. In v6.0.2
IMessagePreHandler<TMessage>declaredobject PreHandle(TMessage)andIMessagePostHandler<TMessage, TMessageResult>
declaredobject PostHandle(TMessage, TMessageResult?), withIAsyncMessagePreHandler<TMessage>,
IAsyncMessagePostHandler<TMessage>, andIAsyncMessagePostHandler<TMessage, TMessageResult>holding theTask
members beside them. Every handler is asynchronous now, so theIAsyncnames are gone and the members they declared
live onIMessagePreHandler<TMessage>andIMessagePostHandler<TMessage, TMessageResult>. A handler that implemented
anIAsynccontract changes the interface name only; a handler that implemented a synchronous one becomes
asynchronous. The axis contracts such asICommandPreHandler<TCommand>andIQueryPostHandler<TQuery, TResult>are
unchanged at the call site and now derive from those directly. IExecutionContextgainedPostHandlersSuppressedandSuppressPostHandlers(). Custom implementations, including
test doubles, must add them.IMessageDescriptor,IMessageDependencies, and the handler descriptor interfaces gained members for the completion
stage, message metadata, the recorded contract, and the prebuilt dispatch. Custom implementations of these interfaces
must add them. They are infrastructure contracts implemented by LiteBus itself; applications that only implement
handlers are unaffected.MessageContextExtensionsmoves out of theLiteBus.Messaging.Abstractionspackage and namespace into
LiteBus.Messaging, taking the stage runners with it. They open ambient scopes, order the stages, preserve stack
traces, and decide what a denied caller receives, which is engine work rather than contract. Only a custom mediation
strategy names the type, and every package implementing one already referencesLiteBus.Messaging, so the fix is a
using directive.IPreHandlerDescriptorisIPreStageHandlerDescriptor, and thePreHandlersandIndirectPreHandlerscollections
onIMessageDescriptorandIMessageDependenciesarePreStageHandlersandIndirectPreStageHandlers. One
collection holds all four roles, soILazyHandlerCollection<IMessagePreStageHandler, IPreHandlerDescriptor>
contradicted itself on a single line.
Documentation
- Mediation Layer Design Rules states the twenty rules the
mediation layer follows: the stage model and the capability rule, contract and arity shapes, the vocabulary grid,
what a decision type may express, where each class of configuration error is rejected, and checklists for adding a
pre-stage role or an axis. Known deviations are listed rather than omitted, so they read as decisions rather than as
precedents. The layer had a system; it had never been written down, which left every name looking arbitrary. - The documentation site serves one version per release line, declared in
site/versions.json. The latest stable line
stays at/docs, so existing links and search results keep working across a release, and every other line carries
its identifier as a path prefix such as/v7/docs. A sidebar switcher moves between versions on the same page,
search is scoped to the version being read, and a pre-release line is excluded from the sitemap and marked
noindexso a search engine does not offer it ahead of the stable page answering the same question. Until now the
site served whatever the working branch held, which meant readers on the released package were reading
documentation for APIs they did not have. - The pipeline vocabulary is one word per concept, listed in
Pipeline Vocabulary and enforced across type names, XML comments, and
the documentation. "Refusal" is the category holding a denial and a validation failure, "denial" is what a guard
does, and "answered" is what a shortcut does.