Part XI: Composition
"A pattern in isolation is a curiosity. Patterns in composition are an architecture."
The previous ten chapters covered nine patterns in isolation. Each chapter showed a single problem, a single solution, a single package, a single test double, and a single set of DI registrations. Each chapter mentioned composition with the other patterns in a brief section near the end -- a paragraph here, a code snippet there -- but never committed to showing all nine patterns working together in a single, end-to-end scenario.
This chapter commits.
What follows is a complete subscription renewal flow for a SaaS application. A user submits a renewal request. The system validates the input, looks up the subscription, determines the renewal type, maps between layers, calculates the new expiration date, dispatches through a behavior pipeline, orchestrates payment and provisioning and notification, guarantees domain event delivery, and streams events to real-time analytics subscribers. Every step uses a specific FrenchExDev pattern. Every pattern plays a role that no other pattern fills. And the composition is not artificial -- this is not a contrived example designed to check boxes. This is the kind of flow that every SaaS application has, and the kind of flow that benefits from each pattern being a discrete, replaceable, testable component.
The chapter has six sections, one for each architectural layer in the request lifecycle: input validation, data retrieval, business logic, dispatch, orchestration, and event delivery. After the layers, we cover the composition root (DI registration), a complete integration test, cross-cutting concerns, and the final architecture diagram.
Nine small tools. One coherent architecture.
The Scenario: Subscription Renewal
The scenario is a SaaS subscription renewal. The domain is straightforward: customers have subscriptions to plans, plans have tiers, subscriptions have expiration dates, and renewals can be one of three types depending on whether the customer is staying on the same plan, upgrading to a higher tier, or downgrading to a lower tier.
Here is the complete request lifecycle:
- API Controller receives the renewal request and validates input using Guard.
- Repository looks up the subscription, returning Option (it may not exist).
- Mapper converts the persistence entity to a domain model.
- Business logic determines the renewal type as a Union (Standard, Upgrade, or Downgrade).
- Clock calculates the new expiration date.
- Mediator dispatches the command through a behavior pipeline (logging, validation).
- Saga orchestrates three steps: process payment, update subscription, send confirmation.
- Outbox captures domain events in the same database transaction as the subscription update.
- Reactive streams those events to analytics and dashboard subscribers.
Each step depends on the result of the previous step. Guard validates before lookup. Lookup uses Option to model absence. Mapper transforms before business logic can operate. Union classifies before Clock can calculate. Mediator wraps everything in cross-cutting behaviors. Saga coordinates the multi-step operation. Outbox guarantees the events reach downstream systems. Reactive delivers them in real time.
The dependencies are linear, and the composition is natural.
The Composition Map
Before diving into code, here is the complete flow as a directed graph. Each node is labeled with the pattern it uses and the operation it performs. The numbers indicate execution order.
The colors indicate which architectural layer each node belongs to: red for the API boundary, blue for the application/mediator layer, purple for domain logic, amber for orchestration, and green for infrastructure. The flow is strictly top-down -- there are no cycles, no callbacks, no bidirectional dependencies. Each layer calls into the layer below it and returns a result to the layer above.
This is the Unix pipe model applied to enterprise architecture. Each stage receives input, transforms it, and passes the result forward. The stages compose because they share a common vocabulary (Result<T>, Option<T>, OneOf<...>) and a common dispatch mechanism (IMediator), not because they inherit from a common base class or implement a common marker interface.
The Domain Model
Before showing how the patterns compose, we need the domain types they operate on. These are the value objects, entities, and events that the subscription renewal flow creates, reads, updates, and publishes.
Subscription Aggregate
public sealed class Subscription : IHasDomainEvents
{
public Guid Id { get; private set; }
public Guid CustomerId { get; private set; }
public Plan Plan { get; private set; }
public DateTimeOffset ExpiresAt { get; private set; }
public SubscriptionStatus Status { get; private set; }
private readonly List<object> _domainEvents = new();
public IReadOnlyList<object> DomainEvents => _domainEvents;
public void ClearDomainEvents() => _domainEvents.Clear();
public void Renew(Plan newPlan, DateTimeOffset newExpiration)
{
var previousPlan = Plan;
Plan = newPlan;
ExpiresAt = newExpiration;
Status = SubscriptionStatus.Active;
_domainEvents.Add(new SubscriptionRenewed(
Id, CustomerId, previousPlan.Id, newPlan.Id,
newExpiration, DateTimeOffset.UtcNow));
}
}public sealed class Subscription : IHasDomainEvents
{
public Guid Id { get; private set; }
public Guid CustomerId { get; private set; }
public Plan Plan { get; private set; }
public DateTimeOffset ExpiresAt { get; private set; }
public SubscriptionStatus Status { get; private set; }
private readonly List<object> _domainEvents = new();
public IReadOnlyList<object> DomainEvents => _domainEvents;
public void ClearDomainEvents() => _domainEvents.Clear();
public void Renew(Plan newPlan, DateTimeOffset newExpiration)
{
var previousPlan = Plan;
Plan = newPlan;
ExpiresAt = newExpiration;
Status = SubscriptionStatus.Active;
_domainEvents.Add(new SubscriptionRenewed(
Id, CustomerId, previousPlan.Id, newPlan.Id,
newExpiration, DateTimeOffset.UtcNow));
}
}The Subscription aggregate implements IHasDomainEvents -- the interface from the Outbox package that the OutboxInterceptor uses to capture domain events during SaveChanges. The Renew method mutates state and raises a SubscriptionRenewed event. The interceptor will serialize that event into an OutboxMessage within the same database transaction.
Plan Value Object
public sealed record Plan(
Guid Id,
string Name,
PlanTier Tier,
decimal MonthlyPrice,
int TermMonths);
public enum PlanTier
{
Free = 0,
Starter = 1,
Professional = 2,
Enterprise = 3
}public sealed record Plan(
Guid Id,
string Name,
PlanTier Tier,
decimal MonthlyPrice,
int TermMonths);
public enum PlanTier
{
Free = 0,
Starter = 1,
Professional = 2,
Enterprise = 3
}Renewal Types (Union Variants)
public sealed record StandardRenewal(Plan Plan, int TermMonths);
public sealed record UpgradeRenewal(Plan NewPlan, Plan OldPlan, int TermMonths);
public sealed record DowngradeRenewal(Plan NewPlan, Plan OldPlan, int TermMonths);public sealed record StandardRenewal(Plan Plan, int TermMonths);
public sealed record UpgradeRenewal(Plan NewPlan, Plan OldPlan, int TermMonths);
public sealed record DowngradeRenewal(Plan NewPlan, Plan OldPlan, int TermMonths);Three record types, each carrying the data specific to its renewal scenario. A StandardRenewal keeps the same plan. An UpgradeRenewal moves to a higher tier with a fresh term. A DowngradeRenewal moves to a lower tier and keeps the existing expiration (the customer already paid for the higher tier through the current period).
Domain Events
public abstract record SubscriptionEvent(
Guid SubscriptionId,
Guid CustomerId,
DateTimeOffset OccurredAt);
public sealed record SubscriptionRenewed(
Guid SubscriptionId,
Guid CustomerId,
Guid PreviousPlanId,
Guid NewPlanId,
DateTimeOffset NewExpiration,
DateTimeOffset OccurredAt) : SubscriptionEvent(SubscriptionId, CustomerId, OccurredAt);
public sealed record PaymentProcessed(
Guid SubscriptionId,
Guid CustomerId,
Guid PaymentId,
decimal Amount,
DateTimeOffset OccurredAt) : SubscriptionEvent(SubscriptionId, CustomerId, OccurredAt);
public sealed record RenewalConfirmationSent(
Guid SubscriptionId,
Guid CustomerId,
string EmailAddress,
DateTimeOffset OccurredAt) : SubscriptionEvent(SubscriptionId, CustomerId, OccurredAt);public abstract record SubscriptionEvent(
Guid SubscriptionId,
Guid CustomerId,
DateTimeOffset OccurredAt);
public sealed record SubscriptionRenewed(
Guid SubscriptionId,
Guid CustomerId,
Guid PreviousPlanId,
Guid NewPlanId,
DateTimeOffset NewExpiration,
DateTimeOffset OccurredAt) : SubscriptionEvent(SubscriptionId, CustomerId, OccurredAt);
public sealed record PaymentProcessed(
Guid SubscriptionId,
Guid CustomerId,
Guid PaymentId,
decimal Amount,
DateTimeOffset OccurredAt) : SubscriptionEvent(SubscriptionId, CustomerId, OccurredAt);
public sealed record RenewalConfirmationSent(
Guid SubscriptionId,
Guid CustomerId,
string EmailAddress,
DateTimeOffset OccurredAt) : SubscriptionEvent(SubscriptionId, CustomerId, OccurredAt);All three events derive from SubscriptionEvent, which is the type parameter for the IEventStream<SubscriptionEvent> that the Reactive pattern exposes. Subscribers can listen to the full stream or use the OfType operator to narrow to specific event types.
Persistence Entity
public sealed class SubscriptionEntity
{
public Guid Id { get; set; }
public Guid CustomerId { get; set; }
public Guid PlanId { get; set; }
public string PlanName { get; set; } = string.Empty;
public int PlanTier { get; set; }
public decimal PlanMonthlyPrice { get; set; }
public int PlanTermMonths { get; set; }
public DateTimeOffset ExpiresAt { get; set; }
public int Status { get; set; }
}public sealed class SubscriptionEntity
{
public Guid Id { get; set; }
public Guid CustomerId { get; set; }
public Guid PlanId { get; set; }
public string PlanName { get; set; } = string.Empty;
public int PlanTier { get; set; }
public decimal PlanMonthlyPrice { get; set; }
public int PlanTermMonths { get; set; }
public DateTimeOffset ExpiresAt { get; set; }
public int Status { get; set; }
}The persistence entity is a flat representation that maps directly to a database table. The domain model is a rich object with behavior. The Mapper pattern bridges the two.
DTOs
public sealed record RenewRequest(
string SubscriptionId,
string RequestedPlanId);
public sealed record RenewalReceipt(
Guid SubscriptionId,
string PlanName,
DateTimeOffset NewExpiration,
string RenewalType,
decimal AmountCharged);public sealed record RenewRequest(
string SubscriptionId,
string RequestedPlanId);
public sealed record RenewalReceipt(
Guid SubscriptionId,
string PlanName,
DateTimeOffset NewExpiration,
string RenewalType,
decimal AmountCharged);The RenewRequest is the API input. The RenewalReceipt is the API output. Both are simple DTOs with no behavior.
Layer 1: Input Validation (Guard)
The request lifecycle begins at the API boundary. The controller receives an HTTP POST with a JSON body. Before the request touches any domain logic, it must be validated. Invalid input must be rejected immediately -- not propagated through the mediator pipeline, not used to query the database, not passed to the saga orchestrator. The API boundary is the first line of defense, and Guard.Against is the weapon.
[ApiController]
[Route("api/subscriptions")]
public sealed class SubscriptionsController : ControllerBase
{
private readonly IMediator _mediator;
public SubscriptionsController(IMediator mediator)
{
_mediator = mediator;
}
[HttpPost("renew")]
public async Task<IActionResult> Renew(
[FromBody] RenewRequest request,
CancellationToken ct)
{
// Guard validates at the API boundary.
// If either value is null or empty, Guard.Against throws
// an ArgumentException with the parameter name captured
// via [CallerArgumentExpression].
var subscriptionId = Guard.Against.NullOrEmpty(request.SubscriptionId);
var requestedPlanId = Guard.Against.NullOrEmpty(request.RequestedPlanId);
// Parse the string IDs into Guids.
// Guard.Against.InvalidFormat would work here too, but
// Guid.TryParse + Guard.Against.Null is more explicit.
if (!Guid.TryParse(subscriptionId, out var subGuid))
return BadRequest("Invalid subscription ID format.");
if (!Guid.TryParse(requestedPlanId, out var planGuid))
return BadRequest("Invalid plan ID format.");
var command = new RenewSubscriptionCommand(subGuid, planGuid);
// Dispatch through the mediator pipeline.
var result = await _mediator.SendAsync(command, ct);
// Result.Match converts success/failure to HTTP responses.
return result.Match<IActionResult>(
success: receipt => Ok(receipt),
failure: error => BadRequest(error));
}
}[ApiController]
[Route("api/subscriptions")]
public sealed class SubscriptionsController : ControllerBase
{
private readonly IMediator _mediator;
public SubscriptionsController(IMediator mediator)
{
_mediator = mediator;
}
[HttpPost("renew")]
public async Task<IActionResult> Renew(
[FromBody] RenewRequest request,
CancellationToken ct)
{
// Guard validates at the API boundary.
// If either value is null or empty, Guard.Against throws
// an ArgumentException with the parameter name captured
// via [CallerArgumentExpression].
var subscriptionId = Guard.Against.NullOrEmpty(request.SubscriptionId);
var requestedPlanId = Guard.Against.NullOrEmpty(request.RequestedPlanId);
// Parse the string IDs into Guids.
// Guard.Against.InvalidFormat would work here too, but
// Guid.TryParse + Guard.Against.Null is more explicit.
if (!Guid.TryParse(subscriptionId, out var subGuid))
return BadRequest("Invalid subscription ID format.");
if (!Guid.TryParse(requestedPlanId, out var planGuid))
return BadRequest("Invalid plan ID format.");
var command = new RenewSubscriptionCommand(subGuid, planGuid);
// Dispatch through the mediator pipeline.
var result = await _mediator.SendAsync(command, ct);
// Result.Match converts success/failure to HTTP responses.
return result.Match<IActionResult>(
success: receipt => Ok(receipt),
failure: error => BadRequest(error));
}
}Notice what the controller does not do. It does not look up the subscription. It does not check if the plan exists. It does not determine the renewal type. It does not calculate expiration dates. It does not orchestrate payment. It does not publish events. The controller has exactly two responsibilities: validate raw input and dispatch a command. Everything else happens inside the mediator pipeline.
This is the Guard pattern's role in composition: it defends the outermost boundary. Input that passes Guard is structurally valid -- non-null, non-empty, correctly formatted. It may still be semantically invalid (the subscription ID may not exist, the plan may be discontinued), but those are domain concerns that belong in the handler, not the controller.
Why Guard.Against Instead of Guard.ToResult
At the API boundary, exceptions are appropriate. The ASP.NET middleware pipeline converts unhandled exceptions into 400/500 responses. If Guard.Against.NullOrEmpty throws an ArgumentException, the global exception handler returns a 400 Bad Request with the exception message. This is the expected behavior for invalid API input.
Inside the domain, Guard.ToResult is more appropriate because domain operations return Result<T> -- they do not throw. But at the API boundary, exceptions are idiomatic. The guard chapter (Part IV) covered this distinction in detail: exceptions for boundaries, results for pipelines, ensures for invariants.
The Command
public sealed record RenewSubscriptionCommand(
Guid SubscriptionId,
Guid RequestedPlanId) : ICommand<Result<RenewalReceipt>>;public sealed record RenewSubscriptionCommand(
Guid SubscriptionId,
Guid RequestedPlanId) : ICommand<Result<RenewalReceipt>>;The command implements ICommand<Result<RenewalReceipt>>, which inherits from IRequest<Result<RenewalReceipt>>. The ICommand marker tells the mediator (and the developer reading the code) that this is a write operation. The Result<RenewalReceipt> return type tells the caller that the operation can succeed (returning a receipt) or fail (returning an error message).
This is a sealed record, not a class. Records provide value equality and immutability -- both desirable for commands that are passed through a pipeline of behaviors. No behavior in the pipeline can mutate the command. No behavior needs to override Equals to compare commands. The command is data, and records are the right tool for data.
Layer 2: Data Retrieval (Option + Mapper)
The command handler needs to look up the subscription by ID. The subscription may or may not exist. In a typical C# codebase, this ambiguity is represented by a nullable return type: SubscriptionEntity?. The caller checks for null, forgets to check for null, or checks for null in three slightly different ways across three different code paths.
The Option pattern eliminates this ambiguity. The repository returns Option<SubscriptionEntity> -- a type that is either Some(entity) if the subscription exists or None if it does not. The caller cannot access the entity without first handling the None case. The compiler enforces this. The type system enforces this.
The Repository
[Injectable(Lifetime.Scoped)]
public sealed class SubscriptionRepository : ISubscriptionRepository
{
private readonly AppDbContext _db;
public SubscriptionRepository(AppDbContext db)
{
_db = db;
}
public async Task<Option<SubscriptionEntity>> FindByIdAsync(
Guid id, CancellationToken ct)
{
var entity = await _db.Subscriptions.FindAsync(
new object[] { id }, ct);
return entity is not null
? Option<SubscriptionEntity>.Some(entity)
: Option<SubscriptionEntity>.None;
}
public async Task<Option<PlanEntity>> FindPlanByIdAsync(
Guid planId, CancellationToken ct)
{
var entity = await _db.Plans.FindAsync(
new object[] { planId }, ct);
return entity is not null
? Option<PlanEntity>.Some(entity)
: Option<PlanEntity>.None;
}
}[Injectable(Lifetime.Scoped)]
public sealed class SubscriptionRepository : ISubscriptionRepository
{
private readonly AppDbContext _db;
public SubscriptionRepository(AppDbContext db)
{
_db = db;
}
public async Task<Option<SubscriptionEntity>> FindByIdAsync(
Guid id, CancellationToken ct)
{
var entity = await _db.Subscriptions.FindAsync(
new object[] { id }, ct);
return entity is not null
? Option<SubscriptionEntity>.Some(entity)
: Option<SubscriptionEntity>.None;
}
public async Task<Option<PlanEntity>> FindPlanByIdAsync(
Guid planId, CancellationToken ct)
{
var entity = await _db.Plans.FindAsync(
new object[] { planId }, ct);
return entity is not null
? Option<PlanEntity>.Some(entity)
: Option<PlanEntity>.None;
}
}The [Injectable(Lifetime.Scoped)] attribute triggers the source generator to register SubscriptionRepository as the implementation of ISubscriptionRepository with scoped lifetime. No manual services.AddScoped<ISubscriptionRepository, SubscriptionRepository>() call needed. The generator discovers the interface from the class's implemented interfaces and generates the registration.
The Mapper
The repository returns SubscriptionEntity -- the flat persistence shape. The handler needs Subscription -- the rich domain shape. The Mapper pattern bridges the gap.
[MapFrom(typeof(SubscriptionEntity))]
[MapTo(typeof(SubscriptionEntity))]
public sealed class SubscriptionMapper
: IMapper<SubscriptionEntity, Subscription>
{
[MapProperty(nameof(SubscriptionEntity.PlanId), "Plan.Id")]
[MapProperty(nameof(SubscriptionEntity.PlanName), "Plan.Name")]
[MapProperty(nameof(SubscriptionEntity.PlanTier), "Plan.Tier")]
[MapProperty(nameof(SubscriptionEntity.PlanMonthlyPrice), "Plan.MonthlyPrice")]
[MapProperty(nameof(SubscriptionEntity.PlanTermMonths), "Plan.TermMonths")]
[MapProperty(nameof(SubscriptionEntity.Status), "Status")]
public Subscription Map(SubscriptionEntity source)
{
// Source-generated: the body of this method is replaced at compile time.
// The generator reads the [MapProperty] attributes and emits:
// return new Subscription
// {
// Id = source.Id,
// CustomerId = source.CustomerId,
// Plan = new Plan(source.PlanId, source.PlanName,
// (PlanTier)source.PlanTier, source.PlanMonthlyPrice,
// source.PlanTermMonths),
// ExpiresAt = source.ExpiresAt,
// Status = (SubscriptionStatus)source.Status
// };
throw new NotImplementedException("Source-generated at compile time.");
}
}[MapFrom(typeof(SubscriptionEntity))]
[MapTo(typeof(SubscriptionEntity))]
public sealed class SubscriptionMapper
: IMapper<SubscriptionEntity, Subscription>
{
[MapProperty(nameof(SubscriptionEntity.PlanId), "Plan.Id")]
[MapProperty(nameof(SubscriptionEntity.PlanName), "Plan.Name")]
[MapProperty(nameof(SubscriptionEntity.PlanTier), "Plan.Tier")]
[MapProperty(nameof(SubscriptionEntity.PlanMonthlyPrice), "Plan.MonthlyPrice")]
[MapProperty(nameof(SubscriptionEntity.PlanTermMonths), "Plan.TermMonths")]
[MapProperty(nameof(SubscriptionEntity.Status), "Status")]
public Subscription Map(SubscriptionEntity source)
{
// Source-generated: the body of this method is replaced at compile time.
// The generator reads the [MapProperty] attributes and emits:
// return new Subscription
// {
// Id = source.Id,
// CustomerId = source.CustomerId,
// Plan = new Plan(source.PlanId, source.PlanName,
// (PlanTier)source.PlanTier, source.PlanMonthlyPrice,
// source.PlanTermMonths),
// ExpiresAt = source.ExpiresAt,
// Status = (SubscriptionStatus)source.Status
// };
throw new NotImplementedException("Source-generated at compile time.");
}
}The [MapProperty] attributes handle the impedance mismatch between the flat persistence entity (which has PlanId, PlanName, PlanTier as separate columns) and the domain model (which has a Plan value object). The source generator reads these attributes and emits the correct assignment code at compile time. No reflection. No expression tree compilation. No runtime cost.
Composing Option and Mapper
In the handler, Option and Mapper compose through the Map extension method:
// Inside RenewSubscriptionHandler.HandleAsync
// Step 1: Look up the subscription. Returns Option<SubscriptionEntity>.
var subscriptionOption = await _repository.FindByIdAsync(
command.SubscriptionId, ct);
// Step 2: Map the entity to the domain model.
// Option.Map transforms the inner value if it exists.
// If subscriptionOption is None, the Map is skipped entirely.
var domainOption = subscriptionOption.Map(
entity => _mapper.Map(entity));
// Step 3: Convert Option to Result.
// Some(subscription) becomes Result.Success(subscription).
// None becomes Result.Failure("Subscription not found").
var subscriptionResult = domainOption.ToResult("Subscription not found");
// If the subscription was not found, return the failure immediately.
if (!subscriptionResult.IsSuccess)
return Result<RenewalReceipt>.Failure(subscriptionResult.Error!);
var subscription = subscriptionResult.Value!;// Inside RenewSubscriptionHandler.HandleAsync
// Step 1: Look up the subscription. Returns Option<SubscriptionEntity>.
var subscriptionOption = await _repository.FindByIdAsync(
command.SubscriptionId, ct);
// Step 2: Map the entity to the domain model.
// Option.Map transforms the inner value if it exists.
// If subscriptionOption is None, the Map is skipped entirely.
var domainOption = subscriptionOption.Map(
entity => _mapper.Map(entity));
// Step 3: Convert Option to Result.
// Some(subscription) becomes Result.Success(subscription).
// None becomes Result.Failure("Subscription not found").
var subscriptionResult = domainOption.ToResult("Subscription not found");
// If the subscription was not found, return the failure immediately.
if (!subscriptionResult.IsSuccess)
return Result<RenewalReceipt>.Failure(subscriptionResult.Error!);
var subscription = subscriptionResult.Value!;This is the composition pattern that the Option chapter (Part II) described: Option.Map applies a transformation to the inner value without unwrapping, and Option.ToResult converts the two-state Option<T> (Some/None) to the two-state Result<T> (Success/Failure). The Mapper pattern provides the transformation function. The Option pattern provides the null-safe pipeline. The Result pattern provides the error propagation mechanism.
No null checks. No if (entity == null) return NotFound(). No defensive programming. The type system guarantees that if execution reaches the line after ToResult, the subscription exists and has been mapped to a domain model.
Looking Up the Requested Plan
The same pattern applies to looking up the requested plan:
// Look up the requested plan
var planOption = await _repository.FindPlanByIdAsync(
command.RequestedPlanId, ct);
var planResult = planOption.ToResult("Requested plan not found");
if (!planResult.IsSuccess)
return Result<RenewalReceipt>.Failure(planResult.Error!);
var requestedPlan = planResult.Value!;// Look up the requested plan
var planOption = await _repository.FindPlanByIdAsync(
command.RequestedPlanId, ct);
var planResult = planOption.ToResult("Requested plan not found");
if (!planResult.IsSuccess)
return Result<RenewalReceipt>.Failure(planResult.Error!);
var requestedPlan = planResult.Value!;Two lookups. Two Option<T> returns. Two ToResult conversions. Two early returns on failure. The pattern is consistent, and the error messages are specific. If the subscription is not found, the error says "Subscription not found." If the plan is not found, the error says "Requested plan not found." The caller (the mediator, the controller, the API consumer) gets a meaningful failure message, not a NullReferenceException stack trace.
Layer 3: Business Logic (Union + Clock)
With the subscription and the requested plan in hand, the handler needs to determine what kind of renewal this is. Is the customer staying on the same plan (standard renewal)? Moving to a higher tier (upgrade)? Moving to a lower tier (downgrade)?
This is a closed set of three possibilities. There is no fourth option. There is no "unknown" renewal type. The business rules are clear: same tier is standard, higher tier is upgrade, lower tier is downgrade. A discriminated union expresses this perfectly.
Determining the Renewal Type
private static OneOf<StandardRenewal, UpgradeRenewal, DowngradeRenewal>
DetermineRenewalType(Plan current, Plan requested)
{
if (current.Tier == requested.Tier)
{
return new StandardRenewal(requested, requested.TermMonths);
}
if (requested.Tier > current.Tier)
{
return new UpgradeRenewal(
NewPlan: requested,
OldPlan: current,
TermMonths: requested.TermMonths);
}
return new DowngradeRenewal(
NewPlan: requested,
OldPlan: current,
TermMonths: current.TermMonths);
}private static OneOf<StandardRenewal, UpgradeRenewal, DowngradeRenewal>
DetermineRenewalType(Plan current, Plan requested)
{
if (current.Tier == requested.Tier)
{
return new StandardRenewal(requested, requested.TermMonths);
}
if (requested.Tier > current.Tier)
{
return new UpgradeRenewal(
NewPlan: requested,
OldPlan: current,
TermMonths: requested.TermMonths);
}
return new DowngradeRenewal(
NewPlan: requested,
OldPlan: current,
TermMonths: current.TermMonths);
}The return type is OneOf<StandardRenewal, UpgradeRenewal, DowngradeRenewal>. The implicit conversions from each record type to the OneOf wrapper handle the construction. The caller must handle all three cases through Match -- there is no way to access the inner value without providing a handler for every variant.
This is the Union pattern's role in composition: it makes the business classification exhaustive. If someone adds a fourth renewal type later -- say, LateralRenewal for moving between plans at the same tier with different features -- the type changes to OneOf<StandardRenewal, UpgradeRenewal, DowngradeRenewal, LateralRenewal>, and every Match call in the codebase stops compiling until the new case is handled.
Calculating the Expiration Date
Each renewal type calculates the new expiration date differently. Standard renewals and upgrades get a fresh term from now. Downgrades keep the existing expiration because the customer already paid for the higher tier through the current period.
// Inside RenewSubscriptionHandler.HandleAsync
OneOf<StandardRenewal, UpgradeRenewal, DowngradeRenewal> renewalType =
DetermineRenewalType(subscription.Plan, requestedPlan);
var newExpiration = renewalType.Match(
standard => _clock.UtcNow.AddMonths(standard.TermMonths),
upgrade => _clock.UtcNow.AddMonths(upgrade.TermMonths),
downgrade => subscription.ExpiresAt
);// Inside RenewSubscriptionHandler.HandleAsync
OneOf<StandardRenewal, UpgradeRenewal, DowngradeRenewal> renewalType =
DetermineRenewalType(subscription.Plan, requestedPlan);
var newExpiration = renewalType.Match(
standard => _clock.UtcNow.AddMonths(standard.TermMonths),
upgrade => _clock.UtcNow.AddMonths(upgrade.TermMonths),
downgrade => subscription.ExpiresAt
);The Clock pattern provides _clock.UtcNow instead of DateTimeOffset.UtcNow. In production, _clock is SystemClock.Instance, which delegates to the real system clock. In tests, _clock is FakeClock, which returns a fixed time that the test controls.
This is critical for testing. If the handler used DateTimeOffset.UtcNow directly, the test would need to assert with a tolerance window: "the new expiration is within one second of the expected value." That tolerance window makes tests flaky -- on a slow CI machine, the difference might exceed one second. With FakeClock, the test sets the clock to 2026-01-15T00:00:00Z, calls _clock.UtcNow.AddMonths(12), and asserts that the new expiration is exactly 2027-01-15T00:00:00Z. No tolerance. No flakiness. Deterministic.
Computing the Charge Amount
The charge amount also depends on the renewal type:
var chargeAmount = renewalType.Match(
standard => standard.Plan.MonthlyPrice * standard.TermMonths,
upgrade => {
// Upgrade: charge the difference pro-rated for the remaining term
var remainingMonths = (int)Math.Ceiling(
(subscription.ExpiresAt - _clock.UtcNow).TotalDays / 30);
var priceDifference = upgrade.NewPlan.MonthlyPrice
- upgrade.OldPlan.MonthlyPrice;
var proRatedUpgrade = priceDifference * remainingMonths;
var newTermCharge = upgrade.NewPlan.MonthlyPrice * upgrade.TermMonths;
return proRatedUpgrade + newTermCharge;
},
downgrade => 0m // No charge for downgrades
);var chargeAmount = renewalType.Match(
standard => standard.Plan.MonthlyPrice * standard.TermMonths,
upgrade => {
// Upgrade: charge the difference pro-rated for the remaining term
var remainingMonths = (int)Math.Ceiling(
(subscription.ExpiresAt - _clock.UtcNow).TotalDays / 30);
var priceDifference = upgrade.NewPlan.MonthlyPrice
- upgrade.OldPlan.MonthlyPrice;
var proRatedUpgrade = priceDifference * remainingMonths;
var newTermCharge = upgrade.NewPlan.MonthlyPrice * upgrade.TermMonths;
return proRatedUpgrade + newTermCharge;
},
downgrade => 0m // No charge for downgrades
);Three renewal types. Three pricing formulas. Exhaustive matching guarantees that every type has a formula. If a developer adds a LateralRenewal type and forgets to add a pricing formula, the code does not compile. The union forces completeness.
Building the Renewal Type Label
For the receipt, we need a human-readable label:
var renewalTypeLabel = renewalType.Match(
standard => "Standard",
upgrade => "Upgrade",
downgrade => "Downgrade"
);var renewalTypeLabel = renewalType.Match(
standard => "Standard",
upgrade => "Upgrade",
downgrade => "Downgrade"
);This is the third Match call on the same OneOf value. Each Match extracts different information from the same union. The pattern scales: you can match as many times as you need, and every match is exhaustive.
Layer 4: Dispatch (Mediator + Behaviors)
The subscription renewal command does not go directly to the handler. It passes through the mediator's behavior pipeline -- a chain of middleware components that implement cross-cutting concerns. The controller sends the command. The mediator resolves the behavior chain. Each behavior wraps the next. The innermost behavior calls the handler.
The Behavior Pipeline
The sequence is: controller sends to mediator, mediator enters LoggingBehavior, which calls next() into ValidationBehavior, which calls next() into the handler. The handler does the real work -- repository lookup, mapping, union classification, clock calculation, saga orchestration -- and returns a Result<RenewalReceipt>. The result propagates back through the behaviors in reverse order: validation passes it through unchanged, logging records the elapsed time, mediator returns it to the controller.
LoggingBehavior
[Injectable(Lifetime.Transient)]
public sealed class LoggingBehavior<TRequest, TResult>
: IBehavior<TRequest, TResult>
where TRequest : IRequest<TResult>
{
private readonly ILogger<LoggingBehavior<TRequest, TResult>> _logger;
public LoggingBehavior(
ILogger<LoggingBehavior<TRequest, TResult>> logger)
{
_logger = logger;
}
public async Task<TResult> HandleAsync(
TRequest request,
Func<Task<TResult>> next,
CancellationToken ct)
{
var requestName = typeof(TRequest).Name;
_logger.LogInformation(
"Handling {RequestName}: {@Request}",
requestName, request);
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var result = await next();
stopwatch.Stop();
_logger.LogInformation(
"Handled {RequestName} in {ElapsedMs}ms",
requestName, stopwatch.ElapsedMilliseconds);
return result;
}
}[Injectable(Lifetime.Transient)]
public sealed class LoggingBehavior<TRequest, TResult>
: IBehavior<TRequest, TResult>
where TRequest : IRequest<TResult>
{
private readonly ILogger<LoggingBehavior<TRequest, TResult>> _logger;
public LoggingBehavior(
ILogger<LoggingBehavior<TRequest, TResult>> logger)
{
_logger = logger;
}
public async Task<TResult> HandleAsync(
TRequest request,
Func<Task<TResult>> next,
CancellationToken ct)
{
var requestName = typeof(TRequest).Name;
_logger.LogInformation(
"Handling {RequestName}: {@Request}",
requestName, request);
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var result = await next();
stopwatch.Stop();
_logger.LogInformation(
"Handled {RequestName} in {ElapsedMs}ms",
requestName, stopwatch.ElapsedMilliseconds);
return result;
}
}The behavior is generic over TRequest and TResult. It wraps every command and query that passes through the mediator, not just RenewSubscriptionCommand. The [Injectable(Lifetime.Transient)] attribute registers it as a transient service -- a new instance for every request. The source generator discovers the IBehavior<TRequest, TResult> interface and registers the open generic.
ValidationBehavior
[Injectable(Lifetime.Transient)]
public sealed class ValidationBehavior<TRequest, TResult>
: IBehavior<TRequest, TResult>
where TRequest : IRequest<TResult>
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
public ValidationBehavior(
IEnumerable<IValidator<TRequest>> validators)
{
_validators = validators;
}
public async Task<TResult> HandleAsync(
TRequest request,
Func<Task<TResult>> next,
CancellationToken ct)
{
var failures = _validators
.Select(v => v.Validate(request))
.Where(r => !r.IsValid)
.SelectMany(r => r.Errors)
.ToList();
if (failures.Count > 0)
{
// TResult is expected to be Result<T> for commands.
// Return a failure result without calling the handler.
var errorMessage = string.Join("; ",
failures.Select(f => f.ErrorMessage));
// Use reflection-free result construction via the
// IResultFactory<TResult> pattern from the Result package.
return ResultFactory<TResult>.Failure(errorMessage);
}
return await next();
}
}[Injectable(Lifetime.Transient)]
public sealed class ValidationBehavior<TRequest, TResult>
: IBehavior<TRequest, TResult>
where TRequest : IRequest<TResult>
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
public ValidationBehavior(
IEnumerable<IValidator<TRequest>> validators)
{
_validators = validators;
}
public async Task<TResult> HandleAsync(
TRequest request,
Func<Task<TResult>> next,
CancellationToken ct)
{
var failures = _validators
.Select(v => v.Validate(request))
.Where(r => !r.IsValid)
.SelectMany(r => r.Errors)
.ToList();
if (failures.Count > 0)
{
// TResult is expected to be Result<T> for commands.
// Return a failure result without calling the handler.
var errorMessage = string.Join("; ",
failures.Select(f => f.ErrorMessage));
// Use reflection-free result construction via the
// IResultFactory<TResult> pattern from the Result package.
return ResultFactory<TResult>.Failure(errorMessage);
}
return await next();
}
}If any validator reports errors, the behavior short-circuits -- it never calls next(), so the handler never executes. This is the pipeline's power: cross-cutting concerns run before the handler without the handler knowing they exist. The handler assumes that if it is called, the command has already been validated.
The Handler
The handler is where all the patterns converge. It is the innermost layer of the pipeline, called only after logging and validation have run. It receives a validated command and returns a Result<RenewalReceipt>.
[Injectable(Lifetime.Scoped)]
public sealed class RenewSubscriptionHandler
: IRequestHandler<RenewSubscriptionCommand, Result<RenewalReceipt>>
{
private readonly ISubscriptionRepository _repository;
private readonly IMapper<SubscriptionEntity, Subscription> _mapper;
private readonly IClock _clock;
private readonly SagaOrchestrator<RenewalSagaContext> _saga;
public RenewSubscriptionHandler(
ISubscriptionRepository repository,
IMapper<SubscriptionEntity, Subscription> mapper,
IClock clock,
SagaOrchestrator<RenewalSagaContext> saga)
{
_repository = repository;
_mapper = mapper;
_clock = clock;
_saga = saga;
}
public async Task<Result<RenewalReceipt>> HandleAsync(
RenewSubscriptionCommand command,
CancellationToken ct)
{
// Layer 2: Data Retrieval (Option + Mapper)
var subscriptionOption = await _repository.FindByIdAsync(
command.SubscriptionId, ct);
var subscriptionResult = subscriptionOption
.Map(entity => _mapper.Map(entity))
.ToResult("Subscription not found");
if (!subscriptionResult.IsSuccess)
return Result<RenewalReceipt>.Failure(
subscriptionResult.Error!);
var subscription = subscriptionResult.Value!;
// Look up the requested plan
var planOption = await _repository.FindPlanByIdAsync(
command.RequestedPlanId, ct);
var planResult = planOption.ToResult("Requested plan not found");
if (!planResult.IsSuccess)
return Result<RenewalReceipt>.Failure(planResult.Error!);
var requestedPlan = planResult.Value!;
// Layer 3: Business Logic (Union + Clock)
var renewalType = DetermineRenewalType(
subscription.Plan, requestedPlan);
var newExpiration = renewalType.Match(
standard => _clock.UtcNow.AddMonths(standard.TermMonths),
upgrade => _clock.UtcNow.AddMonths(upgrade.TermMonths),
downgrade => subscription.ExpiresAt
);
var chargeAmount = renewalType.Match(
standard => standard.Plan.MonthlyPrice * standard.TermMonths,
upgrade => {
var remainingMonths = (int)Math.Ceiling(
(subscription.ExpiresAt - _clock.UtcNow).TotalDays / 30);
var diff = upgrade.NewPlan.MonthlyPrice
- upgrade.OldPlan.MonthlyPrice;
return (diff * remainingMonths)
+ (upgrade.NewPlan.MonthlyPrice * upgrade.TermMonths);
},
downgrade => 0m
);
var renewalTypeLabel = renewalType.Match(
standard => "Standard",
upgrade => "Upgrade",
downgrade => "Downgrade"
);
// Layer 5: Orchestration (Saga)
var sagaContext = new RenewalSagaContext
{
SubscriptionId = subscription.Id,
CustomerId = subscription.CustomerId,
NewPlan = requestedPlan,
NewExpiration = newExpiration,
ChargeAmount = chargeAmount,
RenewalType = renewalTypeLabel
};
var sagaResult = await _saga.ExecuteAsync(sagaContext, ct);
if (!sagaResult.IsSuccess)
return Result<RenewalReceipt>.Failure(sagaResult.Error!);
// Build the receipt
var receipt = new RenewalReceipt(
subscription.Id,
requestedPlan.Name,
newExpiration,
renewalTypeLabel,
chargeAmount);
return Result<RenewalReceipt>.Success(receipt);
}
private static OneOf<StandardRenewal, UpgradeRenewal, DowngradeRenewal>
DetermineRenewalType(Plan current, Plan requested)
{
if (current.Tier == requested.Tier)
return new StandardRenewal(requested, requested.TermMonths);
if (requested.Tier > current.Tier)
return new UpgradeRenewal(requested, current,
requested.TermMonths);
return new DowngradeRenewal(requested, current,
current.TermMonths);
}
}[Injectable(Lifetime.Scoped)]
public sealed class RenewSubscriptionHandler
: IRequestHandler<RenewSubscriptionCommand, Result<RenewalReceipt>>
{
private readonly ISubscriptionRepository _repository;
private readonly IMapper<SubscriptionEntity, Subscription> _mapper;
private readonly IClock _clock;
private readonly SagaOrchestrator<RenewalSagaContext> _saga;
public RenewSubscriptionHandler(
ISubscriptionRepository repository,
IMapper<SubscriptionEntity, Subscription> mapper,
IClock clock,
SagaOrchestrator<RenewalSagaContext> saga)
{
_repository = repository;
_mapper = mapper;
_clock = clock;
_saga = saga;
}
public async Task<Result<RenewalReceipt>> HandleAsync(
RenewSubscriptionCommand command,
CancellationToken ct)
{
// Layer 2: Data Retrieval (Option + Mapper)
var subscriptionOption = await _repository.FindByIdAsync(
command.SubscriptionId, ct);
var subscriptionResult = subscriptionOption
.Map(entity => _mapper.Map(entity))
.ToResult("Subscription not found");
if (!subscriptionResult.IsSuccess)
return Result<RenewalReceipt>.Failure(
subscriptionResult.Error!);
var subscription = subscriptionResult.Value!;
// Look up the requested plan
var planOption = await _repository.FindPlanByIdAsync(
command.RequestedPlanId, ct);
var planResult = planOption.ToResult("Requested plan not found");
if (!planResult.IsSuccess)
return Result<RenewalReceipt>.Failure(planResult.Error!);
var requestedPlan = planResult.Value!;
// Layer 3: Business Logic (Union + Clock)
var renewalType = DetermineRenewalType(
subscription.Plan, requestedPlan);
var newExpiration = renewalType.Match(
standard => _clock.UtcNow.AddMonths(standard.TermMonths),
upgrade => _clock.UtcNow.AddMonths(upgrade.TermMonths),
downgrade => subscription.ExpiresAt
);
var chargeAmount = renewalType.Match(
standard => standard.Plan.MonthlyPrice * standard.TermMonths,
upgrade => {
var remainingMonths = (int)Math.Ceiling(
(subscription.ExpiresAt - _clock.UtcNow).TotalDays / 30);
var diff = upgrade.NewPlan.MonthlyPrice
- upgrade.OldPlan.MonthlyPrice;
return (diff * remainingMonths)
+ (upgrade.NewPlan.MonthlyPrice * upgrade.TermMonths);
},
downgrade => 0m
);
var renewalTypeLabel = renewalType.Match(
standard => "Standard",
upgrade => "Upgrade",
downgrade => "Downgrade"
);
// Layer 5: Orchestration (Saga)
var sagaContext = new RenewalSagaContext
{
SubscriptionId = subscription.Id,
CustomerId = subscription.CustomerId,
NewPlan = requestedPlan,
NewExpiration = newExpiration,
ChargeAmount = chargeAmount,
RenewalType = renewalTypeLabel
};
var sagaResult = await _saga.ExecuteAsync(sagaContext, ct);
if (!sagaResult.IsSuccess)
return Result<RenewalReceipt>.Failure(sagaResult.Error!);
// Build the receipt
var receipt = new RenewalReceipt(
subscription.Id,
requestedPlan.Name,
newExpiration,
renewalTypeLabel,
chargeAmount);
return Result<RenewalReceipt>.Success(receipt);
}
private static OneOf<StandardRenewal, UpgradeRenewal, DowngradeRenewal>
DetermineRenewalType(Plan current, Plan requested)
{
if (current.Tier == requested.Tier)
return new StandardRenewal(requested, requested.TermMonths);
if (requested.Tier > current.Tier)
return new UpgradeRenewal(requested, current,
requested.TermMonths);
return new DowngradeRenewal(requested, current,
current.TermMonths);
}
}Count the patterns in this single class:
- Option --
FindByIdAsyncreturnsOption<SubscriptionEntity>, composed withMapandToResult. - Mapper --
_mapper.Map(entity)converts the persistence entity to a domain model. - Union --
DetermineRenewalTypereturnsOneOf<StandardRenewal, UpgradeRenewal, DowngradeRenewal>, matched three times. - Clock --
_clock.UtcNowprovides deterministic time for expiration calculation. - Saga --
_saga.ExecuteAsyncorchestrates the multi-step renewal operation. - Result -- the return type
Result<RenewalReceipt>threads through every failure path.
Six patterns in one handler. Each pattern handles one concern. The handler's code reads as a sequence of domain operations, not as a tangle of null checks, type casts, try-catch blocks, and static method calls.
Layer 5: Orchestration (Saga)
The saga is where the multi-step operation lives. Renewing a subscription requires three discrete actions: charging the customer, updating the subscription record, and sending a confirmation notification. Each action can fail independently. If the payment fails, nothing else should happen. If the subscription update fails, the payment should be refunded. If the notification fails, the subscription update should be reverted and the payment refunded.
The Saga Context
public sealed class RenewalSagaContext : SagaContext
{
// Input
public Guid SubscriptionId { get; set; }
public Guid CustomerId { get; set; }
public Plan NewPlan { get; set; } = null!;
public DateTimeOffset NewExpiration { get; set; }
public decimal ChargeAmount { get; set; }
public string RenewalType { get; set; } = string.Empty;
// State captured by steps (for compensation)
public Guid? PaymentId { get; set; }
public Plan? PreviousPlan { get; set; }
public DateTimeOffset? PreviousExpiration { get; set; }
public string? CustomerEmail { get; set; }
}public sealed class RenewalSagaContext : SagaContext
{
// Input
public Guid SubscriptionId { get; set; }
public Guid CustomerId { get; set; }
public Plan NewPlan { get; set; } = null!;
public DateTimeOffset NewExpiration { get; set; }
public decimal ChargeAmount { get; set; }
public string RenewalType { get; set; } = string.Empty;
// State captured by steps (for compensation)
public Guid? PaymentId { get; set; }
public Plan? PreviousPlan { get; set; }
public DateTimeOffset? PreviousExpiration { get; set; }
public string? CustomerEmail { get; set; }
}The context carries both input data (set by the handler before calling ExecuteAsync) and state captured by steps (set during execution for use during compensation). The PaymentId is set by the payment step so that compensation can issue a refund. The PreviousPlan and PreviousExpiration are set by the update step so that compensation can revert the subscription to its previous state.
The Saga State Machine
The state machine has three terminal states: Completed (all steps succeeded), Compensated (a step failed and all compensations succeeded), and Failed (a step failed and at least one compensation also failed, requiring manual intervention). The orchestrator manages transitions between these states. The handler does not need to know about the state machine -- it calls ExecuteAsync and receives a Result.
Step 1: Process Payment
[Injectable(Lifetime.Scoped)]
public sealed class ProcessPaymentStep : ISagaStep<RenewalSagaContext>
{
private readonly IPaymentGateway _gateway;
public ProcessPaymentStep(IPaymentGateway gateway)
{
_gateway = gateway;
}
public async Task<Result> ExecuteAsync(
RenewalSagaContext context, CancellationToken ct)
{
if (context.ChargeAmount <= 0)
{
// No charge for downgrades -- skip payment.
return Result.Success();
}
var paymentResult = await _gateway.ChargeAsync(
context.CustomerId,
context.ChargeAmount,
description: $"{context.RenewalType} renewal for subscription "
+ $"{context.SubscriptionId}",
ct);
if (!paymentResult.IsSuccess)
return Result.Failure(
$"Payment failed: {paymentResult.Error}");
// Capture the payment ID for compensation.
context.PaymentId = paymentResult.Value;
return Result.Success();
}
public async Task<Result> CompensateAsync(
RenewalSagaContext context, CancellationToken ct)
{
if (!context.PaymentId.HasValue)
return Result.Success(); // Nothing to compensate.
// Idempotent: check if already refunded.
var status = await _gateway.GetPaymentStatusAsync(
context.PaymentId.Value, ct);
if (status == PaymentStatus.Refunded)
return Result.Success();
var refundResult = await _gateway.RefundAsync(
context.PaymentId.Value, ct);
return refundResult.IsSuccess
? Result.Success()
: Result.Failure(
$"Refund failed for payment {context.PaymentId.Value}");
}
}[Injectable(Lifetime.Scoped)]
public sealed class ProcessPaymentStep : ISagaStep<RenewalSagaContext>
{
private readonly IPaymentGateway _gateway;
public ProcessPaymentStep(IPaymentGateway gateway)
{
_gateway = gateway;
}
public async Task<Result> ExecuteAsync(
RenewalSagaContext context, CancellationToken ct)
{
if (context.ChargeAmount <= 0)
{
// No charge for downgrades -- skip payment.
return Result.Success();
}
var paymentResult = await _gateway.ChargeAsync(
context.CustomerId,
context.ChargeAmount,
description: $"{context.RenewalType} renewal for subscription "
+ $"{context.SubscriptionId}",
ct);
if (!paymentResult.IsSuccess)
return Result.Failure(
$"Payment failed: {paymentResult.Error}");
// Capture the payment ID for compensation.
context.PaymentId = paymentResult.Value;
return Result.Success();
}
public async Task<Result> CompensateAsync(
RenewalSagaContext context, CancellationToken ct)
{
if (!context.PaymentId.HasValue)
return Result.Success(); // Nothing to compensate.
// Idempotent: check if already refunded.
var status = await _gateway.GetPaymentStatusAsync(
context.PaymentId.Value, ct);
if (status == PaymentStatus.Refunded)
return Result.Success();
var refundResult = await _gateway.RefundAsync(
context.PaymentId.Value, ct);
return refundResult.IsSuccess
? Result.Success()
: Result.Failure(
$"Refund failed for payment {context.PaymentId.Value}");
}
}The payment step follows the idempotent compensation pattern described in the Saga chapter (Part IX). Before refunding, it checks the current payment status. If the payment is already refunded (because a previous compensation attempt succeeded but the acknowledgment was lost), it returns success without refunding again. This makes the step safe for crash recovery and retry.
Notice the skip logic for downgrades: if the charge amount is zero or negative, the step returns success without calling the payment gateway. Downgrades do not incur a charge. The saga orchestrator does not know this -- it always runs all three steps. The step itself decides whether to do anything. This keeps the orchestration logic generic and the business logic in the steps.
Step 2: Update Subscription
[Injectable(Lifetime.Scoped)]
public sealed class UpdateSubscriptionStep : ISagaStep<RenewalSagaContext>
{
private readonly ISubscriptionRepository _repository;
private readonly IMapper<SubscriptionEntity, Subscription> _mapper;
private readonly AppDbContext _db;
public UpdateSubscriptionStep(
ISubscriptionRepository repository,
IMapper<SubscriptionEntity, Subscription> mapper,
AppDbContext db)
{
_repository = repository;
_mapper = mapper;
_db = db;
}
public async Task<Result> ExecuteAsync(
RenewalSagaContext context, CancellationToken ct)
{
var subscriptionOption = await _repository.FindByIdAsync(
context.SubscriptionId, ct);
var subscription = subscriptionOption
.Map(entity => _mapper.Map(entity))
.OrDefault(null!);
if (subscription is null)
return Result.Failure("Subscription vanished during saga.");
// Capture previous state for compensation.
context.PreviousPlan = subscription.Plan;
context.PreviousExpiration = subscription.ExpiresAt;
// Apply the renewal.
// This raises a SubscriptionRenewed domain event
// that the OutboxInterceptor will capture.
subscription.Renew(context.NewPlan, context.NewExpiration);
// SaveChanges triggers the OutboxInterceptor:
// 1. Scans ChangeTracker for IHasDomainEvents entities.
// 2. Serializes SubscriptionRenewed to JSON.
// 3. Creates an OutboxMessage in the same transaction.
// 4. Clears the entity's domain events list.
await _db.SaveChangesAsync(ct);
return Result.Success();
}
public async Task<Result> CompensateAsync(
RenewalSagaContext context, CancellationToken ct)
{
if (context.PreviousPlan is null
|| context.PreviousExpiration is null)
return Result.Success(); // Execute never ran or captured nothing.
var subscriptionOption = await _repository.FindByIdAsync(
context.SubscriptionId, ct);
var subscription = subscriptionOption
.Map(entity => _mapper.Map(entity))
.OrDefault(null!);
if (subscription is null)
return Result.Failure(
"Subscription vanished during compensation.");
// Revert to previous state.
subscription.Renew(
context.PreviousPlan,
context.PreviousExpiration.Value);
await _db.SaveChangesAsync(ct);
return Result.Success();
}
}[Injectable(Lifetime.Scoped)]
public sealed class UpdateSubscriptionStep : ISagaStep<RenewalSagaContext>
{
private readonly ISubscriptionRepository _repository;
private readonly IMapper<SubscriptionEntity, Subscription> _mapper;
private readonly AppDbContext _db;
public UpdateSubscriptionStep(
ISubscriptionRepository repository,
IMapper<SubscriptionEntity, Subscription> mapper,
AppDbContext db)
{
_repository = repository;
_mapper = mapper;
_db = db;
}
public async Task<Result> ExecuteAsync(
RenewalSagaContext context, CancellationToken ct)
{
var subscriptionOption = await _repository.FindByIdAsync(
context.SubscriptionId, ct);
var subscription = subscriptionOption
.Map(entity => _mapper.Map(entity))
.OrDefault(null!);
if (subscription is null)
return Result.Failure("Subscription vanished during saga.");
// Capture previous state for compensation.
context.PreviousPlan = subscription.Plan;
context.PreviousExpiration = subscription.ExpiresAt;
// Apply the renewal.
// This raises a SubscriptionRenewed domain event
// that the OutboxInterceptor will capture.
subscription.Renew(context.NewPlan, context.NewExpiration);
// SaveChanges triggers the OutboxInterceptor:
// 1. Scans ChangeTracker for IHasDomainEvents entities.
// 2. Serializes SubscriptionRenewed to JSON.
// 3. Creates an OutboxMessage in the same transaction.
// 4. Clears the entity's domain events list.
await _db.SaveChangesAsync(ct);
return Result.Success();
}
public async Task<Result> CompensateAsync(
RenewalSagaContext context, CancellationToken ct)
{
if (context.PreviousPlan is null
|| context.PreviousExpiration is null)
return Result.Success(); // Execute never ran or captured nothing.
var subscriptionOption = await _repository.FindByIdAsync(
context.SubscriptionId, ct);
var subscription = subscriptionOption
.Map(entity => _mapper.Map(entity))
.OrDefault(null!);
if (subscription is null)
return Result.Failure(
"Subscription vanished during compensation.");
// Revert to previous state.
subscription.Renew(
context.PreviousPlan,
context.PreviousExpiration.Value);
await _db.SaveChangesAsync(ct);
return Result.Success();
}
}This step is where the Outbox pattern enters the composition. When _db.SaveChangesAsync(ct) is called, the OutboxInterceptor -- registered as a SaveChangesInterceptor on the DbContext -- fires. It scans the EF Core ChangeTracker for entities that implement IHasDomainEvents, finds the Subscription aggregate with its SubscriptionRenewed event, serializes the event to JSON, creates an OutboxMessage, adds it to the DbContext, and clears the entity's domain events list. All of this happens within the same database transaction as the subscription update.
The domain code does not mention the outbox. The Subscription.Renew method raises domain events. The SaveChanges call persists the subscription. The outbox infrastructure runs invisibly between the two. This is the Outbox pattern's role in composition: transactional event capture without domain intrusion.
Step 3: Send Confirmation
[Injectable(Lifetime.Scoped)]
public sealed class SendConfirmationStep : ISagaStep<RenewalSagaContext>
{
private readonly ICustomerRepository _customers;
private readonly IEmailService _email;
public SendConfirmationStep(
ICustomerRepository customers,
IEmailService email)
{
_customers = customers;
_email = email;
}
public async Task<Result> ExecuteAsync(
RenewalSagaContext context, CancellationToken ct)
{
var customerOption = await _customers.FindByIdAsync(
context.CustomerId, ct);
var emailResult = customerOption
.Map(c => c.EmailAddress)
.ToResult("Customer not found for notification.");
if (!emailResult.IsSuccess)
return Result.Failure(emailResult.Error!);
var email = emailResult.Value!;
context.CustomerEmail = email;
await _email.SendRenewalConfirmationAsync(
email,
context.SubscriptionId,
context.NewPlan.Name,
context.NewExpiration,
context.RenewalType,
ct);
return Result.Success();
}
public Task<Result> CompensateAsync(
RenewalSagaContext context, CancellationToken ct)
{
// Emails cannot be unsent.
// This step is placed last in the saga specifically
// because it is irreversible.
// If this step fails, the preceding steps are compensated.
// If this step succeeds, nothing after it can fail.
return Task.FromResult(Result.Success());
}
}[Injectable(Lifetime.Scoped)]
public sealed class SendConfirmationStep : ISagaStep<RenewalSagaContext>
{
private readonly ICustomerRepository _customers;
private readonly IEmailService _email;
public SendConfirmationStep(
ICustomerRepository customers,
IEmailService email)
{
_customers = customers;
_email = email;
}
public async Task<Result> ExecuteAsync(
RenewalSagaContext context, CancellationToken ct)
{
var customerOption = await _customers.FindByIdAsync(
context.CustomerId, ct);
var emailResult = customerOption
.Map(c => c.EmailAddress)
.ToResult("Customer not found for notification.");
if (!emailResult.IsSuccess)
return Result.Failure(emailResult.Error!);
var email = emailResult.Value!;
context.CustomerEmail = email;
await _email.SendRenewalConfirmationAsync(
email,
context.SubscriptionId,
context.NewPlan.Name,
context.NewExpiration,
context.RenewalType,
ct);
return Result.Success();
}
public Task<Result> CompensateAsync(
RenewalSagaContext context, CancellationToken ct)
{
// Emails cannot be unsent.
// This step is placed last in the saga specifically
// because it is irreversible.
// If this step fails, the preceding steps are compensated.
// If this step succeeds, nothing after it can fail.
return Task.FromResult(Result.Success());
}
}The notification step is placed last because it is irreversible -- you cannot unsend an email. This ordering is deliberate, as the Saga chapter (Part IX) explained: irreversible steps go last so that compensation is never needed for them. If the email step fails (email service down), the orchestrator compensates steps 2 and 1 in reverse order: revert the subscription, refund the payment. If the email step succeeds, the saga is complete -- there are no further steps that could fail and trigger compensation.
Notice that Option appears again here: the customer lookup returns Option<Customer>, which is mapped to extract the email address and converted to a Result for error handling. The Option pattern is not limited to one layer. It appears wherever data retrieval may return nothing: subscription lookup, plan lookup, customer lookup. The pattern is consistent across all retrieval operations.
The Orchestrator Assembly
The saga orchestrator is assembled from the three steps:
var orchestrator = new SagaOrchestrator<RenewalSagaContext>(
new ISagaStep<RenewalSagaContext>[]
{
processPaymentStep, // Step 1: reversible
updateSubscriptionStep, // Step 2: reversible
sendConfirmationStep // Step 3: irreversible (last)
});var orchestrator = new SagaOrchestrator<RenewalSagaContext>(
new ISagaStep<RenewalSagaContext>[]
{
processPaymentStep, // Step 1: reversible
updateSubscriptionStep, // Step 2: reversible
sendConfirmationStep // Step 3: irreversible (last)
});The order matters. Steps execute in array order (1, 2, 3). Compensation runs in reverse order (2, 1 -- step 3's compensation is a no-op). The orchestrator handles the state machine transitions. The handler calls orchestrator.ExecuteAsync(context, ct) and receives a Result -- success if all steps completed, failure if any step failed and compensation ran.
Layer 6: Event Delivery (Outbox + Reactive)
The subscription has been updated. The SubscriptionRenewed domain event has been captured by the OutboxInterceptor and stored as an OutboxMessage in the same database transaction. But the event has not been published to downstream systems yet. It is sitting in the outbox table, waiting for the background processor.
This is where the Outbox and Reactive patterns compose.
The Outbox Processor
The OutboxProcessor is a background service that polls the outbox table for pending messages, deserializes them, publishes them to the event bus, and marks them as processed.
[Injectable(Lifetime.Singleton)]
public sealed class SubscriptionOutboxProcessor : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IEventStream<SubscriptionEvent> _stream;
private readonly OutboxProcessorOptions _options;
private readonly ILogger<SubscriptionOutboxProcessor> _logger;
// _stream is the EventStream<SubscriptionEvent> singleton.
// It is the "producer" side -- Publish method available.
// Subscribers hold IEventStream<SubscriptionEvent> references
// (the "consumer" side -- Subscribe only).
private readonly EventStream<SubscriptionEvent> _publisher;
public SubscriptionOutboxProcessor(
IServiceScopeFactory scopeFactory,
EventStream<SubscriptionEvent> publisher,
OutboxProcessorOptions options,
ILogger<SubscriptionOutboxProcessor> logger)
{
_scopeFactory = scopeFactory;
_publisher = publisher;
_options = options;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try
{
await ProcessPendingAsync(ct);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex,
"Error processing outbox messages");
}
await Task.Delay(_options.PollingInterval, ct);
}
}
private async Task ProcessPendingAsync(CancellationToken ct)
{
using var scope = _scopeFactory.CreateScope();
var outbox = scope.ServiceProvider
.GetRequiredService<IOutbox>();
var pending = await outbox.GetPendingAsync(
_options.BatchSize, ct);
foreach (var message in pending)
{
try
{
var eventType = Type.GetType(message.Type);
if (eventType is null)
{
_logger.LogWarning(
"Unknown event type {Type} in outbox message {Id}",
message.Type, message.Id);
continue;
}
var domainEvent = System.Text.Json.JsonSerializer
.Deserialize(message.Payload, eventType);
if (domainEvent is SubscriptionEvent subscriptionEvent)
{
// Publish to the reactive event stream.
// All subscribers receive the event.
_publisher.Publish(subscriptionEvent);
}
message.ProcessedAt = DateTimeOffset.UtcNow;
await outbox.MarkProcessedAsync(message.Id, ct);
_logger.LogInformation(
"Published outbox message {Id} of type {Type}",
message.Id, message.Type);
}
catch (Exception ex)
{
message.Attempts++;
message.LastError = ex.Message;
await outbox.UpdateAsync(message, ct);
_logger.LogError(ex,
"Failed to process outbox message {Id}, " +
"attempt {Attempts}",
message.Id, message.Attempts);
}
}
}
}[Injectable(Lifetime.Singleton)]
public sealed class SubscriptionOutboxProcessor : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IEventStream<SubscriptionEvent> _stream;
private readonly OutboxProcessorOptions _options;
private readonly ILogger<SubscriptionOutboxProcessor> _logger;
// _stream is the EventStream<SubscriptionEvent> singleton.
// It is the "producer" side -- Publish method available.
// Subscribers hold IEventStream<SubscriptionEvent> references
// (the "consumer" side -- Subscribe only).
private readonly EventStream<SubscriptionEvent> _publisher;
public SubscriptionOutboxProcessor(
IServiceScopeFactory scopeFactory,
EventStream<SubscriptionEvent> publisher,
OutboxProcessorOptions options,
ILogger<SubscriptionOutboxProcessor> logger)
{
_scopeFactory = scopeFactory;
_publisher = publisher;
_options = options;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try
{
await ProcessPendingAsync(ct);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex,
"Error processing outbox messages");
}
await Task.Delay(_options.PollingInterval, ct);
}
}
private async Task ProcessPendingAsync(CancellationToken ct)
{
using var scope = _scopeFactory.CreateScope();
var outbox = scope.ServiceProvider
.GetRequiredService<IOutbox>();
var pending = await outbox.GetPendingAsync(
_options.BatchSize, ct);
foreach (var message in pending)
{
try
{
var eventType = Type.GetType(message.Type);
if (eventType is null)
{
_logger.LogWarning(
"Unknown event type {Type} in outbox message {Id}",
message.Type, message.Id);
continue;
}
var domainEvent = System.Text.Json.JsonSerializer
.Deserialize(message.Payload, eventType);
if (domainEvent is SubscriptionEvent subscriptionEvent)
{
// Publish to the reactive event stream.
// All subscribers receive the event.
_publisher.Publish(subscriptionEvent);
}
message.ProcessedAt = DateTimeOffset.UtcNow;
await outbox.MarkProcessedAsync(message.Id, ct);
_logger.LogInformation(
"Published outbox message {Id} of type {Type}",
message.Id, message.Type);
}
catch (Exception ex)
{
message.Attempts++;
message.LastError = ex.Message;
await outbox.UpdateAsync(message, ct);
_logger.LogError(ex,
"Failed to process outbox message {Id}, " +
"attempt {Attempts}",
message.Id, message.Attempts);
}
}
}
}The processor reads OutboxMessage rows from the database, deserializes them to their original event types, and publishes them to the EventStream<SubscriptionEvent>. This is the bridge between the transactional world (database) and the reactive world (in-memory event stream). The outbox guarantees that every domain event eventually reaches the event stream. The event stream delivers the event to all subscribers in real time.
The Event Flow
The key insight is the separation of concerns between the two phases. Phase 1 (synchronous, within the saga step) writes the subscription update and the outbox message in the same database transaction. Phase 2 (asynchronous, in the background processor) reads the outbox message and publishes it to the event stream. The two phases are decoupled by the database. If the processor crashes, the message stays in the database and is picked up on the next cycle. If the database crashes, both the subscription update and the outbox message are rolled back -- there is no inconsistency.
Reactive Subscribers
Downstream systems subscribe to the event stream to react to subscription events in real time. The Reactive pattern provides the subscription mechanism and the operator vocabulary for filtering, transforming, and batching events.
[Injectable(Lifetime.Singleton)]
public sealed class AnalyticsSubscriber : IHostedService, IDisposable
{
private readonly IEventStream<SubscriptionEvent> _stream;
private readonly IAnalyticsService _analytics;
private readonly ILogger<AnalyticsSubscriber> _logger;
private IDisposable? _subscription;
public AnalyticsSubscriber(
IEventStream<SubscriptionEvent> stream,
IAnalyticsService analytics,
ILogger<AnalyticsSubscriber> logger)
{
_stream = stream;
_analytics = analytics;
_logger = logger;
}
public Task StartAsync(CancellationToken ct)
{
_subscription = _stream
.OfType<SubscriptionRenewed>()
.Subscribe(
onNext: async evt =>
{
await _analytics.TrackRenewalAsync(
evt.SubscriptionId,
evt.PreviousPlanId,
evt.NewPlanId,
evt.NewExpiration);
},
onError: ex =>
{
_logger.LogError(ex,
"Analytics subscriber error");
});
_logger.LogInformation("Analytics subscriber started");
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken ct)
{
_subscription?.Dispose();
_logger.LogInformation("Analytics subscriber stopped");
return Task.CompletedTask;
}
public void Dispose() => _subscription?.Dispose();
}[Injectable(Lifetime.Singleton)]
public sealed class AnalyticsSubscriber : IHostedService, IDisposable
{
private readonly IEventStream<SubscriptionEvent> _stream;
private readonly IAnalyticsService _analytics;
private readonly ILogger<AnalyticsSubscriber> _logger;
private IDisposable? _subscription;
public AnalyticsSubscriber(
IEventStream<SubscriptionEvent> stream,
IAnalyticsService analytics,
ILogger<AnalyticsSubscriber> logger)
{
_stream = stream;
_analytics = analytics;
_logger = logger;
}
public Task StartAsync(CancellationToken ct)
{
_subscription = _stream
.OfType<SubscriptionRenewed>()
.Subscribe(
onNext: async evt =>
{
await _analytics.TrackRenewalAsync(
evt.SubscriptionId,
evt.PreviousPlanId,
evt.NewPlanId,
evt.NewExpiration);
},
onError: ex =>
{
_logger.LogError(ex,
"Analytics subscriber error");
});
_logger.LogInformation("Analytics subscriber started");
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken ct)
{
_subscription?.Dispose();
_logger.LogInformation("Analytics subscriber stopped");
return Task.CompletedTask;
}
public void Dispose() => _subscription?.Dispose();
}The OfType<SubscriptionRenewed>() operator narrows the IEventStream<SubscriptionEvent> to only SubscriptionRenewed events. The subscriber does not need to cast, check types, or filter manually. The Reactive pattern handles the type narrowing, and the subscriber receives only the events it cares about.
Real-Time Dashboard Subscriber
[Injectable(Lifetime.Singleton)]
public sealed class DashboardSubscriber : IHostedService, IDisposable
{
private readonly IEventStream<SubscriptionEvent> _stream;
private readonly IHubContext<DashboardHub> _hub;
private readonly ILogger<DashboardSubscriber> _logger;
private IDisposable? _subscription;
public DashboardSubscriber(
IEventStream<SubscriptionEvent> stream,
IHubContext<DashboardHub> hub,
ILogger<DashboardSubscriber> logger)
{
_stream = stream;
_hub = hub;
_logger = logger;
}
public Task StartAsync(CancellationToken ct)
{
// Buffer events in 5-second windows for batch updates.
_subscription = _stream
.Buffer(TimeSpan.FromSeconds(5))
.Filter(batch => batch.Count > 0)
.Subscribe(
onNext: async batch =>
{
var summary = new DashboardUpdate
{
TotalRenewals = batch.Count,
Upgrades = batch.Count(
e => e is SubscriptionRenewed r
&& r.NewPlanId != r.PreviousPlanId),
Timestamp = DateTimeOffset.UtcNow
};
await _hub.Clients.All.SendAsync(
"ReceiveDashboardUpdate", summary);
},
onError: ex =>
{
_logger.LogError(ex,
"Dashboard subscriber error");
});
_logger.LogInformation("Dashboard subscriber started");
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken ct)
{
_subscription?.Dispose();
_logger.LogInformation("Dashboard subscriber stopped");
return Task.CompletedTask;
}
public void Dispose() => _subscription?.Dispose();
}[Injectable(Lifetime.Singleton)]
public sealed class DashboardSubscriber : IHostedService, IDisposable
{
private readonly IEventStream<SubscriptionEvent> _stream;
private readonly IHubContext<DashboardHub> _hub;
private readonly ILogger<DashboardSubscriber> _logger;
private IDisposable? _subscription;
public DashboardSubscriber(
IEventStream<SubscriptionEvent> stream,
IHubContext<DashboardHub> hub,
ILogger<DashboardSubscriber> logger)
{
_stream = stream;
_hub = hub;
_logger = logger;
}
public Task StartAsync(CancellationToken ct)
{
// Buffer events in 5-second windows for batch updates.
_subscription = _stream
.Buffer(TimeSpan.FromSeconds(5))
.Filter(batch => batch.Count > 0)
.Subscribe(
onNext: async batch =>
{
var summary = new DashboardUpdate
{
TotalRenewals = batch.Count,
Upgrades = batch.Count(
e => e is SubscriptionRenewed r
&& r.NewPlanId != r.PreviousPlanId),
Timestamp = DateTimeOffset.UtcNow
};
await _hub.Clients.All.SendAsync(
"ReceiveDashboardUpdate", summary);
},
onError: ex =>
{
_logger.LogError(ex,
"Dashboard subscriber error");
});
_logger.LogInformation("Dashboard subscriber started");
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken ct)
{
_subscription?.Dispose();
_logger.LogInformation("Dashboard subscriber stopped");
return Task.CompletedTask;
}
public void Dispose() => _subscription?.Dispose();
}The dashboard subscriber uses two Reactive operators: Buffer(TimeSpan) to collect events into 5-second batches, and Filter to skip empty batches. Instead of pushing every individual event to the dashboard (which would overwhelm the UI with SignalR messages), it batches them and sends a summary every 5 seconds. This is the Reactive pattern's role in composition: it provides the operator vocabulary for shaping event streams to match consumer requirements.
The Complete DI Registration
All nine patterns compose in the DI container. The composition root -- the single place where all services are wired together -- shows the complete picture.
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddSubscriptionRenewal(
this IServiceCollection services)
{
// ── Source-generated registrations ──────────────────────
// Registers all [Injectable] services in this assembly:
// - SubscriptionRepository (Scoped)
// - RenewSubscriptionHandler (Scoped)
// - ProcessPaymentStep (Scoped)
// - UpdateSubscriptionStep (Scoped)
// - SendConfirmationStep (Scoped)
// - LoggingBehavior<,> (Transient, open generic)
// - ValidationBehavior<,> (Transient, open generic)
// - AnalyticsSubscriber (Singleton)
// - DashboardSubscriber (Singleton)
// - SubscriptionOutboxProcessor (Singleton)
services.AddServices();
// ── Clock ──────────────────────────────────────────────
// SystemClock.Instance delegates to DateTimeOffset.UtcNow.
// Tests replace this with FakeClock.
services.AddSingleton<IClock>(SystemClock.Instance);
// ── Outbox ─────────────────────────────────────────────
// The OutboxInterceptor hooks into EF Core's SaveChanges
// pipeline to capture domain events as OutboxMessages.
services.AddDbContext<AppDbContext>(options =>
options
.UseSqlServer(/* connection string */)
.AddInterceptors(new OutboxInterceptor()));
services.AddScoped<IOutbox, EfCoreOutbox>();
// ── Outbox processor options ───────────────────────────
services.AddSingleton(new OutboxProcessorOptions
{
PollingInterval = TimeSpan.FromSeconds(5),
BatchSize = 100,
MaxRetryAttempts = 3,
RetentionPeriod = TimeSpan.FromDays(7)
});
// ── Saga ───────────────────────────────────────────────
// The orchestrator is assembled from its three steps,
// resolved from the container.
services.AddScoped<SagaOrchestrator<RenewalSagaContext>>(sp =>
new SagaOrchestrator<RenewalSagaContext>(
new ISagaStep<RenewalSagaContext>[]
{
sp.GetRequiredService<ProcessPaymentStep>(),
sp.GetRequiredService<UpdateSubscriptionStep>(),
sp.GetRequiredService<SendConfirmationStep>()
}));
// ── Reactive ───────────────────────────────────────────
// A single EventStream instance shared by the producer
// (OutboxProcessor) and all consumers (subscribers).
var eventStream = new EventStream<SubscriptionEvent>();
services.AddSingleton(eventStream);
services.AddSingleton<IEventStream<SubscriptionEvent>>(
eventStream);
// ── Hosted services ────────────────────────────────────
services.AddHostedService<SubscriptionOutboxProcessor>();
services.AddHostedService<AnalyticsSubscriber>();
services.AddHostedService<DashboardSubscriber>();
return services;
}
}public static class ServiceCollectionExtensions
{
public static IServiceCollection AddSubscriptionRenewal(
this IServiceCollection services)
{
// ── Source-generated registrations ──────────────────────
// Registers all [Injectable] services in this assembly:
// - SubscriptionRepository (Scoped)
// - RenewSubscriptionHandler (Scoped)
// - ProcessPaymentStep (Scoped)
// - UpdateSubscriptionStep (Scoped)
// - SendConfirmationStep (Scoped)
// - LoggingBehavior<,> (Transient, open generic)
// - ValidationBehavior<,> (Transient, open generic)
// - AnalyticsSubscriber (Singleton)
// - DashboardSubscriber (Singleton)
// - SubscriptionOutboxProcessor (Singleton)
services.AddServices();
// ── Clock ──────────────────────────────────────────────
// SystemClock.Instance delegates to DateTimeOffset.UtcNow.
// Tests replace this with FakeClock.
services.AddSingleton<IClock>(SystemClock.Instance);
// ── Outbox ─────────────────────────────────────────────
// The OutboxInterceptor hooks into EF Core's SaveChanges
// pipeline to capture domain events as OutboxMessages.
services.AddDbContext<AppDbContext>(options =>
options
.UseSqlServer(/* connection string */)
.AddInterceptors(new OutboxInterceptor()));
services.AddScoped<IOutbox, EfCoreOutbox>();
// ── Outbox processor options ───────────────────────────
services.AddSingleton(new OutboxProcessorOptions
{
PollingInterval = TimeSpan.FromSeconds(5),
BatchSize = 100,
MaxRetryAttempts = 3,
RetentionPeriod = TimeSpan.FromDays(7)
});
// ── Saga ───────────────────────────────────────────────
// The orchestrator is assembled from its three steps,
// resolved from the container.
services.AddScoped<SagaOrchestrator<RenewalSagaContext>>(sp =>
new SagaOrchestrator<RenewalSagaContext>(
new ISagaStep<RenewalSagaContext>[]
{
sp.GetRequiredService<ProcessPaymentStep>(),
sp.GetRequiredService<UpdateSubscriptionStep>(),
sp.GetRequiredService<SendConfirmationStep>()
}));
// ── Reactive ───────────────────────────────────────────
// A single EventStream instance shared by the producer
// (OutboxProcessor) and all consumers (subscribers).
var eventStream = new EventStream<SubscriptionEvent>();
services.AddSingleton(eventStream);
services.AddSingleton<IEventStream<SubscriptionEvent>>(
eventStream);
// ── Hosted services ────────────────────────────────────
services.AddHostedService<SubscriptionOutboxProcessor>();
services.AddHostedService<AnalyticsSubscriber>();
services.AddHostedService<DashboardSubscriber>();
return services;
}
}DI Lifetime Topology
The lifetime assignments follow a simple rule: shared infrastructure (clock, event stream, processor options) is singleton. Request-scoped services (mediator, repository, saga, handler, DbContext) are scoped. Stateless utilities (mapper, behaviors) are transient.
The critical lifetime constraint is that scoped services must not be injected into singleton services. The outbox processor (singleton) uses IServiceScopeFactory to create a scoped service provider on each polling cycle, which resolves a fresh IOutbox and AppDbContext per cycle. This is the standard pattern for background services that need to access scoped dependencies.
Testing the Composition
Integration testing is where the composition proves its value. Every FrenchExDev pattern ships a .Testing package with test doubles: FakeClock, FakeMediator, InMemorySagaStore, InMemoryOutbox, TestEventStream, OptionAssertions, MapperAssertions. In the integration test, we replace production implementations with test doubles and verify the entire flow end to end.
The Integration Test
public sealed class SubscriptionRenewalIntegrationTests
{
private readonly FakeClock _clock;
private readonly InMemoryOutbox _outbox;
private readonly TestEventStream<SubscriptionEvent> _eventStream;
private readonly FakePaymentGateway _paymentGateway;
private readonly FakeEmailService _emailService;
private readonly InMemorySubscriptionRepository _repository;
public SubscriptionRenewalIntegrationTests()
{
_clock = new FakeClock(
new DateTimeOffset(2026, 1, 15, 0, 0, 0, TimeSpan.Zero));
_outbox = new InMemoryOutbox();
_eventStream = new TestEventStream<SubscriptionEvent>();
_paymentGateway = new FakePaymentGateway();
_emailService = new FakeEmailService();
_repository = new InMemorySubscriptionRepository();
}
[Fact]
public async Task Standard_renewal_extends_expiration_by_term_months()
{
// Arrange
var plan = new Plan(
Guid.NewGuid(), "Professional", PlanTier.Professional,
99.99m, TermMonths: 12);
var subscription = CreateSubscription(
plan, expiresAt: _clock.UtcNow.AddMonths(1));
_repository.Add(subscription);
_repository.AddPlan(plan);
_paymentGateway.NextChargeResult =
Result<Guid>.Success(Guid.NewGuid());
var handler = CreateHandler();
var command = new RenewSubscriptionCommand(
subscription.Id, plan.Id);
// Act
var result = await handler.HandleAsync(
command, CancellationToken.None);
// Assert
Assert.True(result.IsSuccess);
var receipt = result.Value!;
Assert.Equal("Standard", receipt.RenewalType);
Assert.Equal(plan.Name, receipt.PlanName);
// Clock: expiration is exactly 12 months from the fake clock's UtcNow.
Assert.Equal(
_clock.UtcNow.AddMonths(12),
receipt.NewExpiration);
// Payment: charged the full annual price.
Assert.Single(_paymentGateway.Charges);
Assert.Equal(
99.99m * 12,
_paymentGateway.Charges[0].Amount);
// Email: confirmation sent.
Assert.Single(_emailService.SentEmails);
}
[Fact]
public async Task Upgrade_renewal_charges_prorated_difference()
{
// Arrange
var starterPlan = new Plan(
Guid.NewGuid(), "Starter", PlanTier.Starter,
29.99m, TermMonths: 12);
var proPlan = new Plan(
Guid.NewGuid(), "Professional", PlanTier.Professional,
99.99m, TermMonths: 12);
// Subscription expires in 6 months (6 months remaining).
var subscription = CreateSubscription(
starterPlan, expiresAt: _clock.UtcNow.AddMonths(6));
_repository.Add(subscription);
_repository.AddPlan(proPlan);
_paymentGateway.NextChargeResult =
Result<Guid>.Success(Guid.NewGuid());
var handler = CreateHandler();
var command = new RenewSubscriptionCommand(
subscription.Id, proPlan.Id);
// Act
var result = await handler.HandleAsync(
command, CancellationToken.None);
// Assert
Assert.True(result.IsSuccess);
Assert.Equal("Upgrade", result.Value!.RenewalType);
// Price difference: $99.99 - $29.99 = $70.00/month.
// Pro-rated for 6 remaining months: $420.00.
// Plus new 12-month term at $99.99/month: $1,199.88.
// Total: $1,619.88.
var expectedCharge =
(99.99m - 29.99m) * 6 // Pro-rated upgrade
+ 99.99m * 12; // New term
Assert.Equal(
expectedCharge,
_paymentGateway.Charges[0].Amount);
}
[Fact]
public async Task Downgrade_renewal_charges_nothing()
{
// Arrange
var proPlan = new Plan(
Guid.NewGuid(), "Professional", PlanTier.Professional,
99.99m, TermMonths: 12);
var starterPlan = new Plan(
Guid.NewGuid(), "Starter", PlanTier.Starter,
29.99m, TermMonths: 12);
var originalExpiration = _clock.UtcNow.AddMonths(6);
var subscription = CreateSubscription(
proPlan, expiresAt: originalExpiration);
_repository.Add(subscription);
_repository.AddPlan(starterPlan);
var handler = CreateHandler();
var command = new RenewSubscriptionCommand(
subscription.Id, starterPlan.Id);
// Act
var result = await handler.HandleAsync(
command, CancellationToken.None);
// Assert
Assert.True(result.IsSuccess);
Assert.Equal("Downgrade", result.Value!.RenewalType);
Assert.Equal(0m, result.Value!.AmountCharged);
// Expiration unchanged: downgrades keep the current expiration.
Assert.Equal(originalExpiration, result.Value!.NewExpiration);
// No payment was processed.
Assert.Empty(_paymentGateway.Charges);
}
[Fact]
public async Task Missing_subscription_returns_failure()
{
// Arrange -- no subscription in repository.
var handler = CreateHandler();
var command = new RenewSubscriptionCommand(
Guid.NewGuid(), Guid.NewGuid());
// Act
var result = await handler.HandleAsync(
command, CancellationToken.None);
// Assert
Assert.False(result.IsSuccess);
Assert.Equal("Subscription not found", result.Error);
}
[Fact]
public async Task Payment_failure_triggers_compensation()
{
// Arrange
var plan = new Plan(
Guid.NewGuid(), "Professional", PlanTier.Professional,
99.99m, TermMonths: 12);
var subscription = CreateSubscription(
plan, expiresAt: _clock.UtcNow.AddMonths(1));
_repository.Add(subscription);
_repository.AddPlan(plan);
// Simulate payment failure.
_paymentGateway.NextChargeResult =
Result<Guid>.Failure("Card declined");
var handler = CreateHandler();
var command = new RenewSubscriptionCommand(
subscription.Id, plan.Id);
// Act
var result = await handler.HandleAsync(
command, CancellationToken.None);
// Assert
Assert.False(result.IsSuccess);
Assert.Contains("Payment failed", result.Error!);
// No email was sent (saga compensated before step 3).
Assert.Empty(_emailService.SentEmails);
}
[Fact]
public async Task Clock_advance_produces_different_expiration()
{
// Arrange
var plan = new Plan(
Guid.NewGuid(), "Monthly", PlanTier.Starter,
9.99m, TermMonths: 1);
var subscription = CreateSubscription(
plan, expiresAt: _clock.UtcNow.AddDays(5));
_repository.Add(subscription);
_repository.AddPlan(plan);
_paymentGateway.NextChargeResult =
Result<Guid>.Success(Guid.NewGuid());
var handler = CreateHandler();
// Act: renew at the current clock time.
var result1 = await handler.HandleAsync(
new RenewSubscriptionCommand(subscription.Id, plan.Id),
CancellationToken.None);
// Advance the clock by 10 days.
_clock.Advance(TimeSpan.FromDays(10));
// Reset the repository to the original state for a second renewal.
_repository.Clear();
_repository.Add(CreateSubscription(
plan, expiresAt: _clock.UtcNow.AddDays(5)));
_repository.AddPlan(plan);
var result2 = await handler.HandleAsync(
new RenewSubscriptionCommand(subscription.Id, plan.Id),
CancellationToken.None);
// Assert: the two expirations differ by exactly 10 days.
Assert.True(result1.IsSuccess);
Assert.True(result2.IsSuccess);
Assert.Equal(
TimeSpan.FromDays(10),
result2.Value!.NewExpiration - result1.Value!.NewExpiration);
}
// ── Helper methods ──────────────────────────────────────────
private RenewSubscriptionHandler CreateHandler()
{
var mapper = new SubscriptionMapper();
var saga = new SagaOrchestrator<RenewalSagaContext>(
new ISagaStep<RenewalSagaContext>[]
{
new ProcessPaymentStep(_paymentGateway),
new UpdateSubscriptionStep(
_repository, mapper,
new InMemoryDbContext(_outbox)),
new SendConfirmationStep(
new InMemoryCustomerRepository(),
_emailService)
});
return new RenewSubscriptionHandler(
_repository, mapper, _clock, saga);
}
private Subscription CreateSubscription(
Plan plan, DateTimeOffset expiresAt)
{
return new Subscription
{
Id = Guid.NewGuid(),
CustomerId = Guid.NewGuid(),
Plan = plan,
ExpiresAt = expiresAt,
Status = SubscriptionStatus.Active
};
}
}public sealed class SubscriptionRenewalIntegrationTests
{
private readonly FakeClock _clock;
private readonly InMemoryOutbox _outbox;
private readonly TestEventStream<SubscriptionEvent> _eventStream;
private readonly FakePaymentGateway _paymentGateway;
private readonly FakeEmailService _emailService;
private readonly InMemorySubscriptionRepository _repository;
public SubscriptionRenewalIntegrationTests()
{
_clock = new FakeClock(
new DateTimeOffset(2026, 1, 15, 0, 0, 0, TimeSpan.Zero));
_outbox = new InMemoryOutbox();
_eventStream = new TestEventStream<SubscriptionEvent>();
_paymentGateway = new FakePaymentGateway();
_emailService = new FakeEmailService();
_repository = new InMemorySubscriptionRepository();
}
[Fact]
public async Task Standard_renewal_extends_expiration_by_term_months()
{
// Arrange
var plan = new Plan(
Guid.NewGuid(), "Professional", PlanTier.Professional,
99.99m, TermMonths: 12);
var subscription = CreateSubscription(
plan, expiresAt: _clock.UtcNow.AddMonths(1));
_repository.Add(subscription);
_repository.AddPlan(plan);
_paymentGateway.NextChargeResult =
Result<Guid>.Success(Guid.NewGuid());
var handler = CreateHandler();
var command = new RenewSubscriptionCommand(
subscription.Id, plan.Id);
// Act
var result = await handler.HandleAsync(
command, CancellationToken.None);
// Assert
Assert.True(result.IsSuccess);
var receipt = result.Value!;
Assert.Equal("Standard", receipt.RenewalType);
Assert.Equal(plan.Name, receipt.PlanName);
// Clock: expiration is exactly 12 months from the fake clock's UtcNow.
Assert.Equal(
_clock.UtcNow.AddMonths(12),
receipt.NewExpiration);
// Payment: charged the full annual price.
Assert.Single(_paymentGateway.Charges);
Assert.Equal(
99.99m * 12,
_paymentGateway.Charges[0].Amount);
// Email: confirmation sent.
Assert.Single(_emailService.SentEmails);
}
[Fact]
public async Task Upgrade_renewal_charges_prorated_difference()
{
// Arrange
var starterPlan = new Plan(
Guid.NewGuid(), "Starter", PlanTier.Starter,
29.99m, TermMonths: 12);
var proPlan = new Plan(
Guid.NewGuid(), "Professional", PlanTier.Professional,
99.99m, TermMonths: 12);
// Subscription expires in 6 months (6 months remaining).
var subscription = CreateSubscription(
starterPlan, expiresAt: _clock.UtcNow.AddMonths(6));
_repository.Add(subscription);
_repository.AddPlan(proPlan);
_paymentGateway.NextChargeResult =
Result<Guid>.Success(Guid.NewGuid());
var handler = CreateHandler();
var command = new RenewSubscriptionCommand(
subscription.Id, proPlan.Id);
// Act
var result = await handler.HandleAsync(
command, CancellationToken.None);
// Assert
Assert.True(result.IsSuccess);
Assert.Equal("Upgrade", result.Value!.RenewalType);
// Price difference: $99.99 - $29.99 = $70.00/month.
// Pro-rated for 6 remaining months: $420.00.
// Plus new 12-month term at $99.99/month: $1,199.88.
// Total: $1,619.88.
var expectedCharge =
(99.99m - 29.99m) * 6 // Pro-rated upgrade
+ 99.99m * 12; // New term
Assert.Equal(
expectedCharge,
_paymentGateway.Charges[0].Amount);
}
[Fact]
public async Task Downgrade_renewal_charges_nothing()
{
// Arrange
var proPlan = new Plan(
Guid.NewGuid(), "Professional", PlanTier.Professional,
99.99m, TermMonths: 12);
var starterPlan = new Plan(
Guid.NewGuid(), "Starter", PlanTier.Starter,
29.99m, TermMonths: 12);
var originalExpiration = _clock.UtcNow.AddMonths(6);
var subscription = CreateSubscription(
proPlan, expiresAt: originalExpiration);
_repository.Add(subscription);
_repository.AddPlan(starterPlan);
var handler = CreateHandler();
var command = new RenewSubscriptionCommand(
subscription.Id, starterPlan.Id);
// Act
var result = await handler.HandleAsync(
command, CancellationToken.None);
// Assert
Assert.True(result.IsSuccess);
Assert.Equal("Downgrade", result.Value!.RenewalType);
Assert.Equal(0m, result.Value!.AmountCharged);
// Expiration unchanged: downgrades keep the current expiration.
Assert.Equal(originalExpiration, result.Value!.NewExpiration);
// No payment was processed.
Assert.Empty(_paymentGateway.Charges);
}
[Fact]
public async Task Missing_subscription_returns_failure()
{
// Arrange -- no subscription in repository.
var handler = CreateHandler();
var command = new RenewSubscriptionCommand(
Guid.NewGuid(), Guid.NewGuid());
// Act
var result = await handler.HandleAsync(
command, CancellationToken.None);
// Assert
Assert.False(result.IsSuccess);
Assert.Equal("Subscription not found", result.Error);
}
[Fact]
public async Task Payment_failure_triggers_compensation()
{
// Arrange
var plan = new Plan(
Guid.NewGuid(), "Professional", PlanTier.Professional,
99.99m, TermMonths: 12);
var subscription = CreateSubscription(
plan, expiresAt: _clock.UtcNow.AddMonths(1));
_repository.Add(subscription);
_repository.AddPlan(plan);
// Simulate payment failure.
_paymentGateway.NextChargeResult =
Result<Guid>.Failure("Card declined");
var handler = CreateHandler();
var command = new RenewSubscriptionCommand(
subscription.Id, plan.Id);
// Act
var result = await handler.HandleAsync(
command, CancellationToken.None);
// Assert
Assert.False(result.IsSuccess);
Assert.Contains("Payment failed", result.Error!);
// No email was sent (saga compensated before step 3).
Assert.Empty(_emailService.SentEmails);
}
[Fact]
public async Task Clock_advance_produces_different_expiration()
{
// Arrange
var plan = new Plan(
Guid.NewGuid(), "Monthly", PlanTier.Starter,
9.99m, TermMonths: 1);
var subscription = CreateSubscription(
plan, expiresAt: _clock.UtcNow.AddDays(5));
_repository.Add(subscription);
_repository.AddPlan(plan);
_paymentGateway.NextChargeResult =
Result<Guid>.Success(Guid.NewGuid());
var handler = CreateHandler();
// Act: renew at the current clock time.
var result1 = await handler.HandleAsync(
new RenewSubscriptionCommand(subscription.Id, plan.Id),
CancellationToken.None);
// Advance the clock by 10 days.
_clock.Advance(TimeSpan.FromDays(10));
// Reset the repository to the original state for a second renewal.
_repository.Clear();
_repository.Add(CreateSubscription(
plan, expiresAt: _clock.UtcNow.AddDays(5)));
_repository.AddPlan(plan);
var result2 = await handler.HandleAsync(
new RenewSubscriptionCommand(subscription.Id, plan.Id),
CancellationToken.None);
// Assert: the two expirations differ by exactly 10 days.
Assert.True(result1.IsSuccess);
Assert.True(result2.IsSuccess);
Assert.Equal(
TimeSpan.FromDays(10),
result2.Value!.NewExpiration - result1.Value!.NewExpiration);
}
// ── Helper methods ──────────────────────────────────────────
private RenewSubscriptionHandler CreateHandler()
{
var mapper = new SubscriptionMapper();
var saga = new SagaOrchestrator<RenewalSagaContext>(
new ISagaStep<RenewalSagaContext>[]
{
new ProcessPaymentStep(_paymentGateway),
new UpdateSubscriptionStep(
_repository, mapper,
new InMemoryDbContext(_outbox)),
new SendConfirmationStep(
new InMemoryCustomerRepository(),
_emailService)
});
return new RenewSubscriptionHandler(
_repository, mapper, _clock, saga);
}
private Subscription CreateSubscription(
Plan plan, DateTimeOffset expiresAt)
{
return new Subscription
{
Id = Guid.NewGuid(),
CustomerId = Guid.NewGuid(),
Plan = plan,
ExpiresAt = expiresAt,
Status = SubscriptionStatus.Active
};
}
}Count the test doubles:
| Test Double | Pattern | Purpose |
|---|---|---|
FakeClock |
Clock | Deterministic time -- set to 2026-01-15, advance by arbitrary durations |
InMemoryOutbox |
Outbox | Event capture verification without a database |
TestEventStream<T> |
Reactive | Recorded events for assertion |
FakePaymentGateway |
Saga Step 1 | Simulate payment success and failure |
FakeEmailService |
Saga Step 3 | Verify emails were sent (or not sent) |
InMemorySubscriptionRepository |
Option + Mapper | In-memory data store returning Option<T> |
Six test doubles. Six patterns with test doubles. The remaining three patterns (Guard, Union, Mapper) do not need test doubles because they are pure functions: Guard throws or returns, Union matches exhaustively, Mapper transforms deterministically. They are tested through the handler's integration tests, not through dedicated fakes.
What the Tests Verify
The six tests cover the critical paths:
- Standard renewal -- same plan, full charge, 12-month extension.
- Upgrade renewal -- higher tier, pro-rated charge, fresh term.
- Downgrade renewal -- lower tier, no charge, expiration unchanged.
- Missing subscription -- Option returns None, handler returns failure.
- Payment failure -- saga compensates, no email sent.
- Clock advance -- different time produces different expiration.
Each test exercises multiple patterns simultaneously. The standard renewal test verifies Guard (the command is valid), Option (the subscription exists), Mapper (the entity was converted), Union (the renewal type is Standard), Clock (the expiration is 12 months from the fake clock), Saga (all three steps completed), and the overall Result (success with a receipt). A single test covering a single scenario exercises seven of the nine patterns. This is what composition means in practice.
netstandard2.0 Compatibility
Not all patterns require the latest runtime. Some target netstandard2.0 for maximum compatibility, allowing them to be used in .NET Framework 4.6.1+ projects, Xamarin, Unity, and other runtimes that implement the standard.
| Pattern | Package | Target | Reason |
|---|---|---|---|
| Option | FrenchExDev.Net.Options |
netstandard2.0 |
Pure functional types, no runtime-specific APIs |
| Union | FrenchExDev.Net.Union |
netstandard2.0 |
Pure discriminated union, no runtime-specific APIs |
| Guard | FrenchExDev.Net.Guard |
netstandard2.0 |
Uses [CallerArgumentExpression] (polyfill for older targets) |
| Result | FrenchExDev.Net.Result |
netstandard2.0 |
Pure functional type, no runtime-specific APIs |
| Clock | FrenchExDev.Net.Clock |
net8.0 |
Wraps TimeProvider (introduced in .NET 8) |
| Mapper | FrenchExDev.Net.Mapper |
netstandard2.0 |
Attributes only; generator emits target-appropriate code |
| Mediator | FrenchExDev.Net.Mediator |
netstandard2.0 |
Core interfaces; DI integration requires Microsoft.Extensions.DependencyInjection.Abstractions |
| Reactive | FrenchExDev.Net.Reactive |
netstandard2.0 |
Wraps System.Reactive which itself targets netstandard2.0 |
| Saga | FrenchExDev.Net.Saga |
netstandard2.0 |
Pure orchestration logic, no runtime-specific APIs |
| Outbox | FrenchExDev.Net.Outbox |
netstandard2.0 |
Core abstractions; EF Core package targets net8.0 |
Seven of the nine core packages target netstandard2.0. The Clock package requires net8.0 because TimeProvider was introduced in .NET 8. The Outbox EF Core integration package requires net8.0 because EF Core 8+ is the supported version. The core Outbox abstractions (OutboxMessage, IOutbox, IOutboxProcessor) target netstandard2.0 and can be implemented against any persistence mechanism.
This means you can use Option, Union, Guard, Result, Mapper, Mediator, Reactive, and Saga in a .NET Framework 4.8 application. You lose Clock (use a custom IClock implementation) and the EF Core Outbox (use a custom IOutbox implementation), but the other seven patterns work without modification.
Package Size and Dependencies
Each package is small. Deliberately small. The Unix philosophy applied to NuGet.
| Package | Types | Lines of Code | Dependencies |
|---|---|---|---|
FrenchExDev.Net.Result |
2 | ~250 | 0 |
FrenchExDev.Net.Options |
3 | ~400 | Result |
FrenchExDev.Net.Union |
3 | ~350 | 0 |
FrenchExDev.Net.Guard |
3 | ~500 | Result |
FrenchExDev.Net.Clock |
3 | ~200 | 0 |
FrenchExDev.Net.Mapper |
5 attr + generator | ~600 | 0 (generator is a dev dependency) |
FrenchExDev.Net.Mediator |
7 | ~450 | Result |
FrenchExDev.Net.Reactive |
4 | ~350 | System.Reactive |
FrenchExDev.Net.Saga |
6 | ~400 | Result |
FrenchExDev.Net.Outbox |
4 | ~300 | 0 (core), EFCore (EF package) |
The total across all nine packages is roughly 3,800 lines of production code. For comparison, MediatR alone is approximately 2,000 lines. AutoMapper is approximately 15,000 lines. The nine FrenchExDev packages combined are smaller than one typical enterprise library.
The dependency graph is shallow. Five packages depend on Result. One depends on System.Reactive. One depends on EF Core (the EF-specific subpackage only). No package depends on more than two other FrenchExDev packages. There is no diamond dependency. There is no version conflict. There is no transitive dependency that pulls in a framework you did not ask for.
Performance
Performance is a consequence of design decisions, not a separate concern that is bolted on after the fact. The nine patterns are fast because they avoid the things that make code slow:
No reflection. The Mapper pattern uses source-generated code, not runtime reflection. The Mediator pattern resolves handlers through the DI container, not through Assembly.GetTypes(). The Guard pattern uses [CallerArgumentExpression], not nameof() with reflection. The only reflection in the entire stack is Type.GetType() in the outbox processor, which deserializes event types from their assembly-qualified names -- and that happens in a background service, not on the request path.
No expression tree compilation. AutoMapper compiles expression trees at startup. FrenchExDev's Mapper generates plain C# at compile time. The generated code is a method body with constructor calls and property assignments -- the same code you would write by hand. JIT compiles it once. The runtime cost is the cost of a method call.
Immutable types. Option<T>, OneOf<T1,T2,T3>, Result<T>, and all command/query records are immutable. Immutable types are thread-safe by construction. No locks. No Interlocked operations. No ConcurrentDictionary for caching. The types are created, used, and garbage collected. The GC handles immutable short-lived objects efficiently because they never get promoted to Gen 2.
No allocations on the hot path. Guard.Against.Null returns the input value without allocating. Option.Some allocates a single small object. OneOf uses a byte discriminator and object storage -- one allocation for the wrapper plus whatever the variant itself allocates. The mediator pipeline invokes behaviors through delegate chaining, not through a list allocation on every request.
Struct-based where possible. Result<T> and Result are structs, not classes. They live on the stack, not the heap. Returning a Result<RenewalReceipt> from the handler does not allocate a new object -- the struct is returned by value. The RenewalReceipt inside the result is a class allocation, but the wrapper is free.
The Architecture Diagram
Here is the complete layered architecture with all nine patterns mapped to their architectural home.
Each pattern lives in exactly one layer:
- API Layer: Guard defends the boundary.
- Application Layer: Mediator dispatches, Behaviors wrap, Mapper transforms.
- Domain Layer: Option models absence, Union models variants, Aggregates hold state, Events describe facts.
- Infrastructure Layer: Clock provides time, Saga orchestrates, Outbox captures, EF Core persists.
- Event Layer: Reactive streams deliver events to subscribers.
The layers have a strict dependency direction: API depends on Application, Application depends on Domain, Infrastructure depends on Domain (not the other way around -- the Dependency Inversion Principle). The Event Layer depends on Infrastructure (the outbox processor publishes to the stream) and is consumed by Application-layer or external components.
No layer reaches across more than one boundary. The controller does not call the repository directly. The handler does not write SQL. The saga steps do not return HTTP responses. Each layer speaks to the layer immediately below it, and the types that cross boundaries (Result<T>, Option<T>, OneOf<...>) are defined in packages that have no layer affiliation -- they are vocabulary types, shared by all layers, belonging to none.
Pattern Interaction Matrix
Each pattern interacts with a subset of the other patterns. This matrix shows which patterns directly compose in the subscription renewal flow.
| Guard | Option | Union | Clock | Mapper | Mediator | Saga | Outbox | Reactive | |
|---|---|---|---|---|---|---|---|---|---|
| Guard | -- | Validates before dispatch | |||||||
| Option | -- | Maps inner value | Returns Option to handler | ||||||
| Union | -- | Calculates per variant | Matched in handler | ||||||
| Clock | Uses for expiration | -- | Used in handler | ||||||
| Mapper | Transforms Option's inner value | -- | Used in handler | Used in saga step | |||||
| Mediator | Dispatch after guard | Handler uses Option | Handler uses Union | Handler uses Clock | Handler uses Mapper | -- | Handler calls saga | ||
| Saga | Option in step 2 and 3 | Mapper in step 2 | Called by handler | -- | SaveChanges triggers | ||||
| Outbox | Captures events in step 2 | -- | Processor publishes to stream | ||||||
| Reactive | Receives from outbox | -- |
The matrix is not symmetric. Guard interacts with Mediator (guard runs before dispatch) but Mediator does not interact with Guard (the mediator does not know about guards). Option interacts with Mapper (Option.Map applies the mapper) but Mapper does not interact with Option (the mapper does not know about options). The interactions are directional, and the direction matches the dependency direction in the architecture.
Why Not a Monolith?
A reasonable question at this point is: why not bundle all nine patterns into one package? The composition works. The patterns integrate cleanly. The DI registration is a single method call. Why maintain nine separate NuGet packages, nine separate .Testing packages, nine separate version numbers, and nine separate READMEs?
The answer is the same answer that motivates the entire design philosophy: you should not pay for what you do not use.
If your application needs only Guard and Option -- defensive programming and null safety -- you install two packages and get two packages. Your dotnet restore takes less time. Your deployment artifact is smaller. Your dependency audit is shorter. Your upgrade path is simpler. You do not carry the weight of Saga, Outbox, Reactive, Mediator, Clock, Mapper, and Union in your dependency tree, in your startup time, in your security scan, and in your mental model.
If your application needs all nine, you install all nine. The composition works because the packages share protocols (Result<T>, Option<T>, [Injectable]), not because they share a binary. Adding the ninth package does not change the behavior of the first eight. Removing the fifth package does not break the remaining four.
This is composability. Not the aspiration. The implementation.
Consider the alternative. A monolithic FrenchExDev.Net package containing all nine patterns. You install it for Guard and Option. You get Outbox, which depends on EF Core. You get Reactive, which depends on System.Reactive. You get Clock, which requires .NET 8. Your netstandard2.0 library project cannot reference the package. Your minimal API project now has a transitive dependency on EF Core even though it does not use a database. Your Blazor WASM project pulls in System.Reactive even though it has no event streams.
The monolith punishes selective adoption. The nine-package design rewards it.
Error Propagation
One of the most important compositional properties is how errors propagate through the layers. In the subscription renewal flow, an error can originate at any layer, and it must propagate all the way back to the API controller as a meaningful failure message -- not as a stack trace, not as a generic 500, not as a swallowed exception.
Here is how each pattern handles errors:
- Guard -- throws
ArgumentException. The ASP.NET global exception handler converts it to a 400 response. - Option.ToResult -- converts
NonetoResult.Failure("Subscription not found"). The handler returns the failure. - Union.Match -- cannot fail. The match is exhaustive. Every variant has a handler.
- Clock -- cannot fail.
UtcNowalways returns a value. - Mapper -- cannot fail at runtime. The mapping is source-generated. Type mismatches are caught at compile time.
- Mediator -- propagates the handler's return value. If the handler returns
Result.Failure, the mediator returnsResult.Failure. - Saga -- returns
Result.Failureif any step fails after compensation. The handler checks the saga result and returns its own failure. - Outbox -- cannot fail synchronously. Events are captured in the same transaction. Failures in the background processor are logged and retried.
- Reactive -- cannot fail synchronously. Subscribers are decoupled from the producer.
The error propagation path is: Guard throws (synchronous, immediate), or Handler returns Result.Failure (functional, explicit). There are no hidden error channels. There are no callbacks that swallow exceptions. There are no fire-and-forget tasks that fail silently. Every synchronous error is either an exception at the API boundary (Guard) or a Result.Failure in the domain (everything else).
The asynchronous path (Outbox + Reactive) has a different error model: retry with backoff for the outbox processor, and onError callbacks for reactive subscribers. But these are infrastructure concerns that do not affect the API response. The API call succeeds or fails based on the synchronous path. The asynchronous path handles eventual consistency and downstream delivery.
Comparison: With and Without the Patterns
To appreciate what the patterns provide, consider the same subscription renewal flow without them.
Without the Patterns
[HttpPost("renew")]
public async Task<IActionResult> Renew([FromBody] RenewRequest request)
{
if (string.IsNullOrEmpty(request.SubscriptionId))
return BadRequest("SubscriptionId is required.");
if (string.IsNullOrEmpty(request.RequestedPlanId))
return BadRequest("RequestedPlanId is required.");
if (!Guid.TryParse(request.SubscriptionId, out var subId))
return BadRequest("Invalid subscription ID.");
if (!Guid.TryParse(request.RequestedPlanId, out var planId))
return BadRequest("Invalid plan ID.");
var entity = await _db.Subscriptions.FindAsync(subId);
if (entity == null) return NotFound("Subscription not found.");
var plan = await _db.Plans.FindAsync(planId);
if (plan == null) return NotFound("Plan not found.");
// Manual mapping -- 10 property assignments
var subscription = new Subscription
{
Id = entity.Id,
CustomerId = entity.CustomerId,
// ... 8 more properties ...
};
// Inline business logic
string renewalType;
DateTimeOffset newExpiration;
decimal charge;
if (subscription.Plan.Tier == plan.Tier)
{
renewalType = "Standard";
newExpiration = DateTimeOffset.UtcNow.AddMonths(plan.TermMonths);
charge = plan.MonthlyPrice * plan.TermMonths;
}
else if (plan.Tier > subscription.Plan.Tier)
{
renewalType = "Upgrade";
newExpiration = DateTimeOffset.UtcNow.AddMonths(plan.TermMonths);
// Pro-rated calculation inline...
charge = /* complex formula */;
}
else
{
renewalType = "Downgrade";
newExpiration = subscription.ExpiresAt;
charge = 0;
}
// Inline orchestration with try-catch
Guid? paymentId = null;
try
{
if (charge > 0)
{
paymentId = await _paymentGateway.ChargeAsync(
subscription.CustomerId, charge);
}
subscription.Plan = plan;
subscription.ExpiresAt = newExpiration;
await _db.SaveChangesAsync();
// Publish event manually -- dual-write problem!
await _messageBroker.PublishAsync(
new SubscriptionRenewed(/* ... */));
await _emailService.SendConfirmationAsync(/* ... */);
}
catch (Exception ex)
{
// Manual compensation
if (paymentId.HasValue)
{
try { await _paymentGateway.RefundAsync(paymentId.Value); }
catch { /* log and hope */ }
}
return StatusCode(500, ex.Message);
}
return Ok(new { renewalType, newExpiration, charge });
}[HttpPost("renew")]
public async Task<IActionResult> Renew([FromBody] RenewRequest request)
{
if (string.IsNullOrEmpty(request.SubscriptionId))
return BadRequest("SubscriptionId is required.");
if (string.IsNullOrEmpty(request.RequestedPlanId))
return BadRequest("RequestedPlanId is required.");
if (!Guid.TryParse(request.SubscriptionId, out var subId))
return BadRequest("Invalid subscription ID.");
if (!Guid.TryParse(request.RequestedPlanId, out var planId))
return BadRequest("Invalid plan ID.");
var entity = await _db.Subscriptions.FindAsync(subId);
if (entity == null) return NotFound("Subscription not found.");
var plan = await _db.Plans.FindAsync(planId);
if (plan == null) return NotFound("Plan not found.");
// Manual mapping -- 10 property assignments
var subscription = new Subscription
{
Id = entity.Id,
CustomerId = entity.CustomerId,
// ... 8 more properties ...
};
// Inline business logic
string renewalType;
DateTimeOffset newExpiration;
decimal charge;
if (subscription.Plan.Tier == plan.Tier)
{
renewalType = "Standard";
newExpiration = DateTimeOffset.UtcNow.AddMonths(plan.TermMonths);
charge = plan.MonthlyPrice * plan.TermMonths;
}
else if (plan.Tier > subscription.Plan.Tier)
{
renewalType = "Upgrade";
newExpiration = DateTimeOffset.UtcNow.AddMonths(plan.TermMonths);
// Pro-rated calculation inline...
charge = /* complex formula */;
}
else
{
renewalType = "Downgrade";
newExpiration = subscription.ExpiresAt;
charge = 0;
}
// Inline orchestration with try-catch
Guid? paymentId = null;
try
{
if (charge > 0)
{
paymentId = await _paymentGateway.ChargeAsync(
subscription.CustomerId, charge);
}
subscription.Plan = plan;
subscription.ExpiresAt = newExpiration;
await _db.SaveChangesAsync();
// Publish event manually -- dual-write problem!
await _messageBroker.PublishAsync(
new SubscriptionRenewed(/* ... */));
await _emailService.SendConfirmationAsync(/* ... */);
}
catch (Exception ex)
{
// Manual compensation
if (paymentId.HasValue)
{
try { await _paymentGateway.RefundAsync(paymentId.Value); }
catch { /* log and hope */ }
}
return StatusCode(500, ex.Message);
}
return Ok(new { renewalType, newExpiration, charge });
}Count the problems:
- No separation of concerns. The controller does validation, lookup, mapping, business logic, payment, persistence, event publishing, email sending, and error handling -- all in one method.
- Null checks everywhere.
entity == null,plan == null-- manual, repetitive, easy to forget. - No exhaustive matching. The if-else chain for renewal types has no compiler guarantee that all cases are handled. Adding a
LateralRenewaltype does not cause a compilation error. - DateTimeOffset.UtcNow -- hidden dependency, untestable deterministically.
- Manual mapping -- error-prone, no compile-time verification.
- No pipeline behaviors -- logging and validation are inline or absent.
- No saga -- try-catch compensation that misses edge cases (what if SaveChanges succeeds but the email fails? The payment was charged, the subscription was updated, the email was not sent, and no compensation runs).
- Dual-write problem -- SaveChanges and PublishAsync are two separate writes. If PublishAsync fails, the event is lost.
- No reactive streaming -- events are published point-to-point, not to a stream.
With the Patterns
The controller is 15 lines. The handler is 80 lines. The three saga steps are 50 lines each. The total is roughly 250 lines of application code plus the domain model. Every concern is in a separate class. Every failure mode is handled. Every pattern is testable. Every composition point is explicit.
The difference is not about lines of code. The version without patterns might even be shorter for a single endpoint. The difference is about what happens when the system grows. When you add a second renewal path (annual vs monthly). When you add a fourth saga step (tax calculation). When you add a third reactive subscriber (audit logging). When you need to test the payment failure path without calling Stripe. When you need to verify that downgrades do not charge. When a junior developer needs to understand the flow.
Patterns are not about making simple things simpler. They are about keeping complex things manageable.
What Comes Next
This series covered nine patterns and their composition. But patterns are not endpoints -- they are building blocks. Here are the directions that build on this foundation.
Future Patterns Under Consideration
- Specification -- composable business rules as objects, integrating with Guard and Mediator for validation and query filtering.
- Policy -- retry, circuit breaker, and fallback policies as injectable services, integrating with Saga steps and Outbox processing.
- Feature Flag -- compile-time and runtime feature toggles, integrating with Union (enabled/disabled as a closed set) and Mediator (behavior that short-circuits based on flag state).
Related Series
This series builds on and references several other series on this site:
Building a Content Management Framework -- the DDD framework that uses these patterns as infrastructure. The CMF's five M2 DSLs (DDD, Content, Admin, Pages, Workflow) are built on the same source-generation principles as the Mapper and Injectable patterns.
Contention over Convention -- the philosophy behind attribute-driven source generation over runtime convention scanning. Every
[Injectable],[MapFrom],[MapTo], and[MapProperty]attribute in this series is a manifestation of that philosophy.Entity.Dsl -- source-generated entity mapping that uses the Mapper pattern's attributes to generate EF Core entity configurations, constructors, and factory methods from a single attribute-decorated record.
From a Big Ball of Mud to DDD -- migration patterns for introducing Guard, Option, and Mediator into legacy codebases incrementally, without a rewrite.
Summary
Nine patterns. Nine packages. One subscription renewal.
Here is the complete composition, pattern by pattern:
Guard validates input at the API boundary.
Guard.Against.NullOrEmptyrejects null and empty strings before the request enters the domain. The controller is the gate. Guard is the lock.Option models data retrieval.
Option<SubscriptionEntity>makes the possibility of absence explicit.Option.Maptransforms the inner value.Option.ToResultconverts absence to a typed failure. No null checks. NoNullReferenceException.Union models business classification.
OneOf<StandardRenewal, UpgradeRenewal, DowngradeRenewal>represents the closed set of renewal types.Matchforces exhaustive handling. Adding a variant breaks compilation. The type system guarantees completeness.Clock provides deterministic time.
IClock.UtcNowreplacesDateTimeOffset.UtcNowas an injectable dependency.FakeClockmakes time-dependent logic testable without tolerance windows. The handler calculates expiration dates that tests can verify to the millisecond.Mapper bridges persistence and domain.
IMapper<SubscriptionEntity, Subscription>converts flat database rows to rich domain objects. The source generator emits the mapping code at compile time. No reflection. No runtime cost. No silent property mismatches.Mediator dispatches through a pipeline.
IMediator.SendAsyncsends the command throughLoggingBehaviorandValidationBehaviorbefore it reaches the handler. Cross-cutting concerns are applied automatically. The handler focuses on business logic.Saga orchestrates multi-step operations.
SagaOrchestrator<RenewalSagaContext>runs three steps in order: payment, subscription update, notification. If any step fails, compensation runs in reverse. The state machine handles every failure mode. The steps are individually testable.Outbox guarantees event delivery.
OutboxInterceptorcaptures theSubscriptionReneweddomain event in the same database transaction as the subscription update. The background processor publishes it to the event stream. No dual-write problem. No lost events.Reactive delivers events to subscribers.
IEventStream<SubscriptionEvent>feeds analytics and dashboard handlers.OfTypenarrows to specific event types.Bufferbatches events for the dashboard. Subscribers are decoupled from the producer and from each other.
The patterns compose because they share vocabulary (Result<T>, Option<T>), share DI conventions ([Injectable]), and respect layer boundaries. No pattern depends on more than two other FrenchExDev packages. No pattern forces you to use any other pattern. You can use Guard without Saga. You can use Option without Mediator. You can use all nine, and they compose into a coherent architecture -- not because a framework forces them together, but because they were designed to fit.
Nine small tools. One coherent architecture. The Unix philosophy, applied to .NET infrastructure.