Skip to content

feat(19): ConsumerDefinition<TConsumer> discovered through DI (public retry, opt-in topology) - #45

Merged
asawicki merged 19 commits into
mainfrom
asawicki/feature/19-consumer-definitions
Aug 13, 2026
Merged

feat(19): ConsumerDefinition<TConsumer> discovered through DI (public retry, opt-in topology)#45
asawicki merged 19 commits into
mainfrom
asawicki/feature/19-consumer-definitions

Conversation

@asawicki

Copy link
Copy Markdown
Collaborator

Feature 19 — ConsumerDefinition<TConsumer> discovered through DI

Adds a public ConsumerDefinition<TConsumer> base type that co-locates per-consumer configuration (routing keys, retry, dead-letter, AcceptUntyped, MassTransit envelope, prefetch/concurrency) close to the consumer, discovered through explicit DI registration (no assembly scan). Fully additive and non-breaking; the raw-first and manual-topology defaults are unchanged.

Highlights

  • Single-param façade ConsumerDefinition<TConsumer> + a new message-agnostic IConsumerConfigurator<TConsumer> that the existing two-param IConsumerConfigurator<TConsumer, TMessage> now inherits (additive).
  • Public retry surface IRetryConfigurator (Interval/Incremental/Exponential/Handle<TException>/Ignore<TException>) with a fluent consumer.Retry(r => r.Exponential(...)) on the façade; Build() → RetryPolicy stays in the core (zero-dep Abstractions preserved).
  • Consumer<TConsumer>() inference overload — TMessage resolved once at startup (MakeGenericMethod, zero per-message reflection); multiple IConsumer<T> fail fast at startup.
  • Opt-in topology helper (exchange+queue+binding) exposed on the RabbitMQ transport seam only — never on the transport-agnostic base type; manual topology remains the default.
  • Secure-by-default startup warning when a definition combines AcceptUntyped() with the envelope/topology helper without a schema-validation middleware.
  • Observability: a retry-attempt counter on the public retry path (0-B/op dispatch preserved).

Subtasks

ID Title Status Commit
19.1 Single-param ConsumerDefinition<TConsumer> + IConsumerConfigurator<TConsumer> façade PASS 566b506
19.2 Publicize IRetryConfigurator into Abstractions PASS edbf0d3
19.3 Consumer<TConsumer>() inference overload PASS 4b469e8
19.4 Extend ConsumerRegistration with retry carrier + endpoint knobs PASS 49abba4
19.5 Map public IRetryConfigurator to core RetryPolicy PASS f7d6663
19.6 Startup TMessage inference + concrete Consumer<TConsumer>() PASS 9459efa
19.7 Discover ConsumerDefinition<TConsumer> through DI at startup PASS c928c52
19.8 Materialize definition settings into ConsumerRegistration (core) PASS 15ac385
19.18 Retry-attempt observability on the public retry surface PASS b5167e9
19.9 Materialize definition settings into the RabbitMQ transport configurator PASS b045f18
19.12 0-B/op dispatch allocation benchmark PASS 5158666
19.13 High-risk behavioral tests for ConsumerDefinition enforcement PASS aaae1d7
19.10 Opt-in consumer topology helper on the RabbitMQ transport seam PASS 16cbd2a
19.11 Secure-by-default warning for untyped consumer definitions PASS 3cec387
19.14 Integration tests for ConsumerDefinition on a live RabbitMQ broker PASS* 178a87c
19.15 Finalize public API surface + contract tests (incl. façade Retry) PASS 3b85807
19.16 User-facing guide for consumer definitions PASS cceb7ff
19.17 ConsumerDefinition showcase sample with retry/routing/topology PASS* 8d16bda

Build & Test

  • dotnet build BareWire.slnx -c Release0 warnings / 0 errors (TreatWarningsAsErrors).
  • Unit tests — 2118 / 2118 pass.
  • Contract tests (public-API baseline + layer rules + I-2 topology-seam + M-1 auto-topology-not-default) — 38 pass.

Caveats

  • *Live broker runs pending Docker. The integration tests (19.14) and the sample E2E smoke (19.17) are authored and compile, but their live runs against a real RabbitMQ were not executed because Docker was unavailable in the build environment. They should be run in CI / a Docker-enabled environment before relying on them.
  • Design decision (ratified): ConsumerDefinition is single-parameter (ConsumerDefinition<TConsumer>); the two-parameter variant was rejected because a single-param class cannot reference the two-param configurator in its Configure signature (CS0311). The façade gained a fluent Retry(...) so retry composes ergonomically.

asawicki added 19 commits July 16, 2026 05:17
…rConfigurator<TConsumer> facade

Public abstract ConsumerDefinition<TConsumer> in BareWire.Abstractions.Configuration
(zero-dep, transport-agnostic) co-locates per-consumer settings via a new single-param,
message-agnostic IConsumerConfigurator<TConsumer> facade. The existing two-param
IConsumerConfigurator<TConsumer, TMessage> now inherits the single-param one (additive,
non-breaking). TMessage is bound at startup by inference, never on the base type signature.
Abstractions public-API snapshot regenerates additively in 19.15.
Public IRetryConfigurator (Interval/Incremental/Exponential/Handle<TException>/Ignore<TException>,
chained shape) now lives in BareWire.Abstractions so a consumer definition can compose a retry
policy. Build()->RetryPolicy stays in the core (BareWire) — RetryPolicy is not referenced from the
zero-dep Abstractions. The former internal IRetryConfigurator is removed; RetryConfigurator maps the
public contract to RetryPolicy in the core. Abstractions public-API snapshot regenerates additively in 19.15.
…ndpointConfigurator

Additive single-param overload for a consumer with a single IConsumer<T>; TMessage is
resolved at startup by inference (19.6). Existing Consumer<TConsumer, TMessage>() unchanged.
Abstractions public-API snapshot regenerates additively in 19.15.
…nt knobs

Additive, default-off ConfigureRetry (Action<IRetryConfigurator>?) plus PrefetchCount/
ConcurrentMessageLimit. The retry carrier is typed on the public IRetryConfigurator contract
and does not pull the core RetryPolicy type into the zero-dep Abstractions, nor degrade to an
untyped handle (I-1). Abstractions public-API snapshot regenerates additively in 19.15.
RetryPolicyMaterializer bridges the ConsumerRegistration retry carrier (Action<IRetryConfigurator>)
to a core RetryPolicy. Build()->RetryPolicy stays in the core; RetryMiddleware and settlement
(ack/nack/dead-letter) are unchanged.
…lement Consumer<TConsumer>()

Concrete Consumer<TConsumer>() on both the core and RabbitMQ endpoint configurators. TMessage is
resolved once at startup (MakeGenericMethod -> baked closed delegate; zero per-message reflection,
ADR-003); multiple IConsumer<T> fail fast at startup with an actionable message requiring the
explicit Consumer<TConsumer, TMessage>() overload. Greens the full solution build.
The core resolves explicitly DI-registered ConsumerDefinition<TConsumer> instances at startup,
invokes Configure(IReceiveEndpointConfigurator, IConsumerConfigurator<TConsumer>), and materializes
the settings into ConsumerRegistration. No assembly scan and no IRegistrationConfigurator; consumers
are still not auto-registered. An unregistered definition is not applied. Zero hot-path reflection.
…stration (core configurator)

The core ConsumerConfigurator now materializes definition-sourced settings (retry carrier,
prefetch/concurrency knobs) into ConsumerRegistration via internal setters and an extended Build(),
coexisting with the existing routing-key / AcceptUntyped / envelope materialization. No public-API
change. Per-project configurator (core and transport internals are not shared).
New IBareWireInstrumentation.RecordRetryAttempt(endpoint, messageType, errorType) with a
Counter<long> (TagList, no heap alloc) in BareWireMetrics, a no-op in both NullInstrumentation
implementers, invoked only inside the RetryMiddleware retry branch (post-exception path) — the
0-B/op success path is untouched and settlement/ack-nack is unchanged. Message-type tag is
precompiled at endpoint build time. Abstractions public-API snapshot regenerates in 19.15.
… transport configurator

Separate internal sealed ConsumerConfigurator in the RabbitMQ transport materializes definition
settings into a ConsumerRegistration equivalent to the core path (19.8), per the dual-impl parity
requirement (core and transport internals are not shared). No public-API change.
…inition

MemoryDiagnoser benchmark proving the no-opt-in dispatch path stays 0 B/op: discovery and TMessage
inference happen once at startup (baked closed delegate), so per-delivery there is no reflection and
only precompiled ConsumerRegistration field reads. Confirms the <512 B/op consume budget is unchanged.
…ement

Eight facts across the four ADR-036 enforcement guarantees: (1) the retry carrier maps to the
correct core RetryPolicy while the Abstractions-resident carrier neither references RetryPolicy nor
degrades to an untyped handle (I-1); (2) a DI-registered definition applies endpoint/per-consumer
settings bit-for-bit on both core and transport; (3) single-IConsumer<T> infers TMessage, multiple
fail fast at startup; (4) a consumer with no definition behaves bit-identically to before.
…t seam

Opt-in helper (extension method in BareWire.Transport.RabbitMQ) declares exchange+queue+binding for
a consumer through the transport adapter. Invariants held: opt-in only (ADR-002 not reversed — without
the call topology is unchanged); routed through the transport seam, not a core->RabbitMQ call; the
binding declaration and the dispatcher routing-key pattern stay separate axes (no silent coupling);
the AMQP helper is absent from ConsumerDefinition<T> and the zero-dep Abstractions (I-2).
The SEC-13 advisory (BareWireBusControl) reads the materialized per-consumer flags on binding.Consumers
and warns at bus build when a definition combines AcceptUntyped() with the MassTransit envelope and/or
the opt-in topology helper without a configured SchemaValidationMiddleware. The definition does not by
itself widen the attack surface; deserialization hardening (no polymorphic serializer, STJ MaxDepth,
payload size limit) matches the type-less path, assuming broker-level publish ACL.
…tMQ broker

Aspire-fixtured integration tests: a ConsumerDefinition with retry + dead-letter + routing keys applied
on a real broker; the opt-in topology helper declares exchange+queue+binding through the adapter while
without the opt-in call topology stays unchanged (ADR-002); binding and dispatcher routing-key pattern
are independent axes. Compiles green; the live broker run is pending a Docker-hosted RabbitMQ.
…t tests

Completes the single-param facade with a public Retry(Action<IRetryConfigurator>) so a
ConsumerDefinition composes retry ergonomically (consumer.Retry(r => r.Exponential(...))) — the
method is promoted onto IConsumerConfigurator<TConsumer>, implemented on the core and RabbitMQ
two-param configurators and on the definition facade (flowing into ConsumerRegistration.ConfigureRetry).
Regenerates the additive BareWire.Abstractions public-API baseline for the whole feature, adds the
I-2 topology-seam assertion (the AMQP topology helper is absent from ConsumerDefinition<T>/Abstractions)
and the M-1 fitness test (auto-topology is not the default). ContractTests green; layer rules unchanged.
New English DocFX article covering: defining a single-param ConsumerDefinition<TConsumer> and
registering it through DI; composing per-consumer settings in Configure (routing keys, AcceptUntyped,
MassTransit envelope, and the fluent consumer.Retry(r => ...)); the opt-in topology helper with manual
topology as the default; and TMessage inference for a single IConsumer<T>. Includes a Differences-vs-
MassTransit note warning that DI-explicit discovery (no assembly scan) silently skips an unregistered
definition. Described inline (no ADR numbers or internal paths).
…t-in topology

New BareWire.Samples.ConsumerDefinitionShowcase: a ConsumerDefinition<TransferConsumer> discovered
through explicit DI registration that co-locates a retry policy (consumer.Retry(r => r.Exponential(...))),
routing-key patterns, and the opt-in transport topology helper. Registered in the Aspire AppHost with a
wired E2E smoke test (SamplesAppFixture) asserting routing + retry + topology on a real broker. Code and
README are English and self-contained. The live E2E run is pending a Docker-hosted RabbitMQ (unavailable
in this environment); the sample compiles and the test is discoverable.
The discovery previously passed a no-op IReceiveEndpointConfigurator to a definition's Configure, so
endpoint-level settings a user applied through the endpoint argument (prefetch, concurrency, retry
count/interval, serializer overrides) were silently dropped. A capturing configurator now records the
settings that map onto EndpointBinding and materializes them back into the binding, and it throws a
clear NotSupportedException for endpoint operations a per-consumer definition cannot express (nested
consumer/raw/saga registration, per-endpoint ordering, the consume-topology toggle, default content
type, raw-serializer options) instead of dropping them. Reflection-wrapped exceptions are unwrapped so
the real NotSupportedException surfaces.
@asawicki
asawicki merged commit 3d5fd5a into main Aug 13, 2026
1 check passed
@asawicki
asawicki deleted the asawicki/feature/19-consumer-definitions branch August 13, 2026 13:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant