Note: This document captures user requirements and needs. Technical design decisions and implementation details should be documented in an Architecture Decision Record (ADR) in
docs/adr/.
Linked Issue: #2176 - Enable pipeline validation upon startup
As a developer new to Brighter, I would like Brighter to validate my pipeline configuration at startup, so that I can find and fix misconfiguration immediately instead of discovering errors only when a message is processed at runtime.
Today, many configuration mistakes — mixing sync and async handlers, incorrect attribute ordering, missing handler or mapper registrations — are only surfaced when a message arrives on the broker and the pipeline fails. This leads to a slow and frustrating "build → run → send message → see error → fix → repeat" cycle. The developer should be able to know their configuration is wrong before any message is consumed.
Brighter is used in three distinct deployment scenarios, each adding a layer of configuration:
-
AddBrighter()— Command Dispatcher (CQRS). Registers handler pipelines, subscriber registry, mapper registry, and command processor. Used by all Brighter applications. Configured viaIBrighterBuilder. -
AddProducers()— Outgoing Messages. AddsPublicationdefinitions, aProducerRegistry, and the outbox-producer mediator. Used when the application sends or publishes messages to a broker. -
AddConsumers()— Incoming Messages (Service Activator). AddsSubscriptiondefinitions, aDispatcher, and message pumps (Reactor/Proactor). Used when the application consumes messages from a broker.
Each path has its own validation needs. A developer may use only path 1 (pure CQRS), paths 1+2 (sends messages but doesn't consume), or all three (full messaging).
ValidateOnBuild: Traverses everyServiceDescriptorat startup and verifies the entire dependency tree can be satisfied. ThrowsAggregateExceptionif any service is unresolvable. Enabled by default in Development since .NET 9.ValidateOnStart()(Options pattern): Registers anIHostedServicethat eagerly resolvesIOptions<T>at startup, triggering allIValidateOptions<T>validators. Fails the host before any request is served.- Pattern: Runtime validation at startup, not compile-time.
- Startup topology validation:
bus.Start()creates and validates broker topology for all registered consumers. Unresolvable consumer dependencies fail the bus start. - Roslyn analyzers (
MassTransit.Analyzers): Compile-time checks for message contract correctness (MCA0001–MCA0003). GetProbeResult(): Returns a JSON diagnostic tree via a visitor pattern.- Pattern: Two layers — Roslyn analyzers for static structure, runtime validation for wiring.
dotnet run -- describe: CLI command prints tabular diagnostic report of all listeners, message routing, sending endpoints, error handling, and HTTP endpoints. Also supports--jsonfor file export.DescribeHandlerMatch(): Explains why a type is or isn't recognized as a handler.- Pattern: Rich diagnostic output as a developer tool, not just pass/fail validation.
LogPipeline(verbose: true): Logs the full incoming/outgoing pipeline chain viaILoggerat startup.- Pattern: Simplest approach — pipeline visualization via standard logging.
- A layered approach: Static analyzers catch what they can at compile time, and runtime startup validation catches everything else.
- A diagnostic report in addition to pass/fail validation: frameworks give developers visibility into "here's what I configured" — not just "something is wrong".
A two-layer strategy aligned with .NET ecosystem conventions, applied across all three configuration paths (Roslyn analyzer extensions are out of scope — see ADR 0054):
A startup diagnostic report that shows the developer exactly how Brighter has been wired. The report covers whichever paths the application uses:
- AddBrighter: Registered handler pipelines with their attribute chains.
- AddProducers: Publications with their outgoing message mappers (custom vs default) and transforms.
- AddConsumers: Subscriptions with pump type, handler chain, and incoming message mappers.
Developer experience: The developer opts in via configuration. Brighter logs a structured report to ILogger. Summary at Information level, full detail at Debug level.
An opt-in validation that checks the fully-wired configuration at startup, before any messages are sent or consumed. Separate validation rules apply to each configuration path.
Developer experience: If validation fails, Brighter throws a single exception containing all errors, preventing the host from starting.
Roslyn analyzer extensions are out of scope for this specification. They are documented separately in ADR 0054 (0054-roslyn-analyzer-extensions-for-pipeline-validation.md) for future implementation.
Backstop error-handling attributes (RejectMessageOnErrorAttribute, DeferMessageOnErrorAttribute, DontAckOnErrorAttribute) should be at the outermost position (lowest step number, typically step 0) in the pipeline. If a backstop attribute has a step number higher than a UseResiliencePipelineAttribute, report a warning — the resilience pipeline will catch exceptions before the backstop handler sees them, making the backstop ineffective.
If a handler implements the sync interface (IHandleRequests<T>), it should use sync attributes (e.g. RejectMessageOnErrorAttribute, not RejectMessageOnErrorAsyncAttribute). If async, it should use async attributes. Mismatches must be reported.
The diagnostic report must show, for each registered handler:
- The handler pipeline chain — the full sequence of decorators in step order (backstop attributes, resilience pipeline, inbox, target handler) so the developer can see the decoration order.
- Whether the handler is sync (
IHandleRequests<T>) or async (IHandleRequestsAsync<T>). - Any resilience pipeline policies referenced by name.
This applies to all Brighter applications, not just those consuming messages.
Every Publication should have a RequestType set, and that type must implement IRequest. (Note: this is already covered by Roslyn analyzers BRT001/BRT002 — the startup validator should also check it for cases the analyzer cannot catch, e.g. programmatic registration.)
The diagnostic report must show, for each Publication:
- The outgoing message mapper that will be used — identifying whether it is a custom mapper (explicitly registered) or the default mapper (e.g.
JsonMessageMapper<T>). Since Brighter has a default mapper fallback, a missing custom mapper is not an error — but the developer should know which mapper resolves so they can confirm it's what they intend. - Any outgoing transforms (
WrapWithAttribute) configured on the mapper'sMapToMessagemethod, in pipeline order. - The topic (
RoutingKey) the publication targets.
When a Subscription uses MessagePumpType.Reactor, the registered handler for its RequestType must implement IHandleRequests<T> (sync). When it uses MessagePumpType.Proactor, the handler must implement IHandleRequestsAsync<T> (async). A mismatch must be reported as an error.
Every Subscription must have at least one handler registered for its RequestType. A subscription with no handler must be reported as an error.
When a Subscription's RequestType implements ICommand, the message should be dispatched via Send() (expecting MT_COMMAND). When it implements IEvent, it should be dispatched via Publish() (expecting MT_EVENT). Today ValidateMessageType() in the message pump only logs a warning — the startup validator should report when a subscription's RequestType is ambiguous (implements neither ICommand nor IEvent) or when the type hierarchy doesn't match the expected dispatch pattern.
The diagnostic report must show, for each Subscription:
- The pump type (Reactor/Proactor) and handler interface (sync/async).
- The incoming message mapper that resolves (custom or default).
- Any incoming transforms (
UnwrapWithAttribute) configured on the mapper'sMapToRequestmethod. - The handler pipeline chain for the subscription's
RequestType. - The channel name and routing key (topic).
All validation errors from all configuration paths must be collected and reported together in a single exception, not one at a time. The developer should see every problem in a single startup failure, not have to fix-and-restart repeatedly. The exception type should extend Brighter's existing ConfigurationException so that existing catch blocks continue to work (ADR 0053 specifies PipelineValidationException : ConfigurationException). Validation warnings (severity = Warning) must be logged at LogLevel.Warning via ILogger after errors are thrown — warnings are surfaced for developer awareness but do not prevent startup.
Each validation error message must identify:
- The subscription/publication/handler that has the problem.
- What the problem is.
- How to fix it (brief guidance).
Example: "Subscription 'OrderCreated' uses MessagePumpType.Reactor but handler 'OrderCreatedHandler' implements IHandleRequestsAsync<OrderCreated>. Use MessagePumpType.Proactor or change the handler to implement IHandleRequests<OrderCreated>."
The diagnostic report should be written via ILogger so it integrates with whatever logging infrastructure the developer has configured (console, Serilog, etc.). The report should be logged at Information level for a brief summary line (e.g. "Brighter: 3 handler pipelines, 2 publications, 5 subscriptions configured") and at Debug level for the full per-item detail. This follows ASP.NET Core's convention where detailed information is available at Debug level.
Validation must work correctly regardless of which combination of paths the developer uses:
- AddBrighter only: Validates handler pipelines only.
- AddBrighter + AddProducers: Validates handler pipelines and publications.
- AddBrighter + AddConsumers: Validates handler pipelines and subscriptions.
- All three: Validates everything.
ValidatePipelines() and DescribePipelines() each accept an optional bool enabled parameter (defaulting to true) that controls whether the feature actually runs at startup. This allows the developer to gate validation or diagnostics on an external signal — for example, checking IHostEnvironment.EnvironmentName or reading from IConfiguration — without removing the method call from the configuration chain. The two flags are independent: the developer may enable validation but disable diagnostics, or vice versa. Brighter does not prescribe how the flag is stored — by accepting a simple bool, the developer can source it from environment checks, configuration, feature flags, or any other mechanism.
ValidatePipelines() accepts an optional bool throwOnError parameter (defaulting to true) that controls whether validation errors cause a PipelineValidationException to be thrown. When throwOnError is false, validation still runs and errors are logged at LogLevel.Error via ILogger, but the application is allowed to continue starting. This serves as an explicit reminder to the developer that validation can terminate the program, and gives them a way to opt into a "warn but don't fail" mode — for example, in staging environments where they want visibility into issues without blocking deployment.
Startup validation and diagnostic reporting must be opt-in. Developers who don't enable them see no change in behavior. This avoids impacting startup time in production for existing users.
Validation should complete in well under 1 second for typical configurations (≤50 subscriptions). It should use reflection on types rather than instantiating handlers or mappers.
The feature must not change any existing API signatures or default behavior. Existing applications that do not opt in must work identically.
Validation must be configured through the IBrighterBuilder interface, not tied to a specific implementation. Although ServiceCollectionBrighterBuilder is the only implementation today, the design should allow other implementations to participate.
- Constraint: Startup validation requires a built
IServiceProviderand thus runs after DI registration, not at compile time. - Constraint: Handler pipeline validation (FR-1, FR-2, FR-3) applies even without producers or consumers — a pure CQRS application benefits from these checks.
- Roslyn analyzer extensions: New compile-time diagnostics for the
Paramore.Brighter.Analyzerpackage (e.g. attribute ordering warnings, pump/handler mismatch detection in the IDE). Documented in ADR 0054 for future implementation. - Broker connectivity validation: Checking whether the broker is reachable or topics/queues exist. This is a separate concern (health checks).
- Message schema validation: Validating that message payloads match expected schemas.
- Comprehensive pipeline correctness: Validating that the business logic in handlers is correct (that's what tests are for).
- Automatic fix-up: The validator reports problems; it does not attempt to auto-correct them.
- Performance profiling: Analyzing pipeline performance characteristics at startup.
Given a handler with [UseResiliencePipeline(step: 0)] and [RejectMessageOnError(step: 1)], when startup validation runs, then a warning is reported that the backstop attribute is ineffective.
Given an async handler (IHandleRequestsAsync<T>) decorated with a sync attribute ([RejectMessageOnError]), when startup validation runs, then an error is reported identifying the mismatch.
Given registered handler pipelines, when the diagnostic report is generated, then for each handler the report shows the full attribute chain in step order, the handler's sync/async nature, and referenced resilience policies.
Given a Publication with no RequestType set (programmatic registration), when startup validation runs, then an error is reported.
Given a Publication for OrderCreated with a custom mapper and one for PaymentReceived with no custom mapper, when the diagnostic report is generated, then it shows the custom mapper name for OrderCreated and JsonMessageMapper<PaymentReceived> (default) for PaymentReceived, along with any transforms.
Given a subscription with MessagePumpType.Reactor and an async-only handler, when startup validation runs, then an error is reported identifying the mismatch.
Given a subscription for OrderCreated with no handler registered, when startup validation runs, then an error is reported identifying the missing handler.
Given a subscription whose RequestType implements ICommand, and a message arrives with MT_EVENT, this is a known mismatch. The startup validator should report when a subscription's RequestType implements neither ICommand nor IEvent.
Given subscriptions for incoming messages, when the diagnostic report is generated, then for each subscription the report shows: pump type, handler chain, incoming mapper (custom vs default), channel name, and routing key.
Given a configuration with errors across all three paths (handler, producer, consumer), when startup validation runs, then all errors appear in the exception, not just the first one.
Given an existing application that does not enable validation or diagnostics, when the application starts, then behavior is identical to before this feature was added.
Given an application that only calls AddBrighter() (no producers, no consumers), when validation is enabled, then only handler pipeline checks run — no errors about missing subscriptions or publications.
Given .ValidatePipelines(enabled: false) and .DescribePipelines(enabled: false), when the application starts, then neither validation nor the diagnostic report runs — behaving as if neither method was called. Given .ValidatePipelines(enabled: true), validation runs normally. The enabled parameter defaults to true so that .ValidatePipelines() without arguments continues to enable validation.
Given a configuration with validation errors and .ValidatePipelines(throwOnError: false), when startup validation runs, then all errors are logged at LogLevel.Error via ILogger but no exception is thrown and the application continues starting. Given .ValidatePipelines(throwOnError: true) (the default), validation errors throw PipelineValidationException as before.
| What | Where | When |
|---|---|---|
MessagePumpType != Unknown |
Subscription constructor |
Object creation (runtime) |
RequestType or MapRequestType set |
Subscription constructor |
Object creation (runtime) |
| Pipeline handlers consistent type | PipelineBuilder |
First message dispatch (runtime) |
| MessageType ↔ IRequest subtype | MessagePump.ValidateMessageType() |
Per-message (WARNING only, not blocking) |
Publication.RequestType set |
Roslyn analyzer BRT001 | Compile time |
Publication.RequestType implements IRequest |
Roslyn analyzer BRT002 | Compile time |
MessagePumpType assigned |
Roslyn analyzer BRT003 | Compile time |
WrapWith/UnwrapWith on correct methods |
Roslyn analyzers BRT004/BRT005 | Compile time |
ProducerRegistry not null |
AddProducers() |
DI registration time |
Producer has Publication with Topic |
ProducerRegistry constructor |
Object creation (runtime) |
| Gap | Configuration Path | Layer |
|---|---|---|
| No visibility into handler pipeline wiring | AddBrighter | Diagnostic Report |
| Backstop attribute after resilience pipeline attribute | AddBrighter | Validation |
| Sync attribute on async handler (or vice versa) | AddBrighter | Validation |
| No visibility into outgoing mappers (custom vs default) | AddProducers | Diagnostic Report |
| No visibility into outgoing transforms | AddProducers | Diagnostic Report |
| Programmatic Publication missing RequestType | AddProducers | Validation |
| No visibility into incoming mappers and subscription wiring | AddConsumers | Diagnostic Report |
| Reactor subscription with async handler (or vice versa) | AddConsumers | Validation |
| Subscription with no handler registered | AddConsumers | Validation |
| Ambiguous RequestType (neither ICommand nor IEvent) | AddConsumers | Validation |