Skip to content

v7.0.0-preview.1

Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 01 Sep 21:04
· 0 commits to main since this release

This is a pre-release of 7.0.0. Packages are published to NuGet with the 7.0.0-preview.1
version suffix, so dotnet add package only resolves them when a pre-release version is requested
explicitly or --prerelease is 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 contracts ICommandCompletionHandler, IQueryCompletionHandler, and IEventCompletionHandler run in a
    finally on every mediation path, exactly once, and receive a read-only MessageCompletionContext carrying 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 a Verdict
    from DecideAsync, with the axis contracts ICommandGuard<TCommand>, IQueryGuard<TQuery>, and
    IEventGuard<TEvent>. A refusal always carries a reason, may carry a code, and reports MediationOutcome.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> returns Validity from ValidateAsync, with the axis contracts
    ICommandValidator<TCommand>, IQueryValidator<TQuery>, and IEventValidator<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.Invalid rather than Denied and 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. ValidationFailure carries 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
    contracts ICommandRefusalMapper, IQueryRefusalMapper, and IStreamQueryRefusalMapper. One registration against
    ICommand covers 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 as LiteBusMessageDeniedException or
    LiteBusMessageInvalidException.
  • Shortcuts. A pre-stage handler that answers a message whose work is already done implements
    IMessageShortcut<TMessage> or IMessageShortcut<TMessage, TMessageResult> and returns a Shortcut from
    TryAnswerAsync, with the axis contracts ICommandShortcut<TCommand>, ICommandShortcut<TCommand, TCommandResult>,
    IQueryShortcut<TQuery, TQueryResult>, IStreamQueryShortcut<TQuery, TQueryResult>, and IEventShortcut<TEvent>. A
    cache hit or a replayed idempotent command reports MediationOutcome.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
    UseOutputCache after UseAuthorization; because LiteBus owns its stages, it makes the mistake unrepresentable
    instead of documenting it. PreStage names the four stages and IPreStageHandlerDescriptor.Stage records 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 with AsyncEnumerable.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.RunAsyncPreStages gives 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 in LiteBus.Messaging rather than in the abstractions package.
  • RunAsyncErrorHandlers and RunAsyncCompletionHandlers each take the execution context and open their own ambient
    scope, so a strategy no longer has to wrap them. The error runner also captures the ExceptionDispatchInfo itself,
    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.
  • LiteBusMessageDeniedException and LiteBusMessageInvalidException reach 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, and LiteBusMessageInvalidException.Failures carries every failure the validator stage collected.
  • MediationExceptionFilters.IsRefusal and IsRetryableDispatchException classify 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.
  • MediationOutcome distinguishes Succeeded, Answered, Denied, Invalid, Failed, and Canceled. 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.Metadata exposes 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
    through IMessageDefinition<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.
  • IMessageDeclarationSource marks 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 an IAuditDefinition<TMessage>, declare
    the constant half of a record; IAuditScope supplies what only the handler knows. EnableAuditing() on the command
    and query module builders registers the writer, which hands an AuditRecord to the application's IAuditTrail.
    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>() or UseAuditTrail(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.
  • AuditDeclaration is a closed hierarchy of AuditedDeclaration and AuditExemptDeclaration, so a declaration cannot
    hold a combination that means nothing, such as a category on an exemption.
  • ReasonRequired on an audited declaration is enforced. A successful action that declares it and supplies no reason
    raises LiteBusConfigurationException rather than writing an incomplete record.
  • AuditTrailDiagnosticCheck reports the litebus.audit.trail probe as unhealthy when auditing is enabled and no
    IAuditTrail is registered, so a missing sink surfaces before the first audited mediation.
  • IAuditOutcomeMapper and MessageModuleBuilder.UseAuditOutcomeMapper let an application that refuses by throwing
    record its own exception as AuditOutcome.Denied rather than AuditOutcome.Failed. Refusing through a guard needs no
    mapper.
  • MediationExceptionData.SuppressedCompletionFaults is 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.
  • LB1018 reports 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 with dotnet_diagnostic.LB1018.severity = warning.
  • LB1019 reports a shortcut that implements the untyped shortcut contract for a message that produces a result.
    Because ICommand<TResult> derives from ICommand, that contract compiles there, and answering from it fails at
    runtime with LiteBusConfigurationException. 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.
  • HandlerPriorities reserves a priority band for handlers shipped by LiteBus, so ordering against them is a documented
    guarantee. Application handlers stay below ReservedFloor and, with no explicit priority, run first.
  • IHandlerDescriptor.ContractType records the closed contract a descriptor was discovered from, and PipelineDispatch
    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, so RegisterFromAssembly discovers them.
  • AsyncBroadcastMediationStrategy observes 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. PipelineContracts holds 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 the PreStage ordinals 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 the IAsyncEnumerable and
    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.HasPreStageHandlers answers 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
    LiteBusConfigurationException instead 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.NET advisory reached transitively through
    Testcontainers and restores a clean NuGetAudit run. 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.MySql has no EF Core 10 provider, and SQLitePCLRaw stays 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
    the MessageAttributes and Attributes collections, 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.Abort and LiteBusExecutionAbortedException are removed. A pre-handler that stopped the pipeline
    now implements a guard contract and returns a Verdict, or a shortcut contract and returns a Shortcut. The break is
    a compile error rather than a change in behavior, which is deliberate: a flag that left Abort() compiling would have
    silently started running the statements after it.
  • ICommandValidator<TCommand> and IQueryValidator<TQuery> return Task<Validity> from ValidateAsync instead of
    Task, and derive from IMessageValidator<TMessage> rather than from the pre-handler contract. A validator that
    reported a failure by throwing now returns Validity.Invalid(...) instead. The break is a compile error rather than a
    change in behavior, for the same reason the Abort() 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 with IMessagePreHandler<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> and IAsyncMessageErrorHandler<TMessage, TMessageResult> are renamed
    IMessageErrorHandler<TMessage> and IMessageErrorHandler<TMessage, TMessageResult>. The prefix named nothing there:
    no synchronous error handler exists, and the contract derives straight from the IMessageErrorHandler marker. 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 keeps IAsyncMessageHandler, where the prefix does name something: it is the Task-returning specialization
    of IMessageHandler<TMessage, TMessageResult>, alongside IStreamMessageHandler for the IAsyncEnumerable one.
  • IMessageDescriptor and IMessageDependencies gained RefusalMappers and IndirectRefusalMappers. Custom
    implementations, including test doubles, must add them. IMessageDependencies.HasPreStageHandlers ships 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 calls SuppressPostHandlers().
  • The synchronous handler layer is removed, and the asynchronous one takes over its names. In v6.0.2
    IMessagePreHandler<TMessage> declared object PreHandle(TMessage) and IMessagePostHandler<TMessage, TMessageResult>
    declared object PostHandle(TMessage, TMessageResult?), with IAsyncMessagePreHandler<TMessage>,
    IAsyncMessagePostHandler<TMessage>, and IAsyncMessagePostHandler<TMessage, TMessageResult> holding the Task
    members beside them. Every handler is asynchronous now, so the IAsync names are gone and the members they declared
    live on IMessagePreHandler<TMessage> and IMessagePostHandler<TMessage, TMessageResult>. A handler that implemented
    an IAsync contract changes the interface name only; a handler that implemented a synchronous one becomes
    asynchronous. The axis contracts such as ICommandPreHandler<TCommand> and IQueryPostHandler<TQuery, TResult> are
    unchanged at the call site and now derive from those directly.
  • IExecutionContext gained PostHandlersSuppressed and SuppressPostHandlers(). 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.
  • MessageContextExtensions moves out of the LiteBus.Messaging.Abstractions package 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 references LiteBus.Messaging, so the fix is a
    using directive.
  • IPreHandlerDescriptor is IPreStageHandlerDescriptor, and the PreHandlers and IndirectPreHandlers collections
    on IMessageDescriptor and IMessageDependencies are PreStageHandlers and IndirectPreStageHandlers. One
    collection holds all four roles, so ILazyHandlerCollection<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
    noindex so 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.