Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Part IX: Build Aggregates

In Part VIII, we extracted Value Objects -- Money, SubscriptionPeriod, EmailAddress, TaxRate, PlanTier. Three proration implementations collapsed to one method. EF Core owned entity mapping gave us zero schema changes. The safest refactoring with the highest ROI.

But the entities that use those Value Objects are still data bags. They have domain names and domain relationships, but they have no behavior, no invariants, no opinion about their own lifecycle. The Subscription entity from Part II has twenty-two public setters and zero methods. SubscriptionService.ChangePlan() reaches inside and mutates fields one by one, like a surgeon operating on a patient who has no immune system.

This part fixes that. We transform anemic entities into aggregates -- rich domain models that enforce their own invariants, expose behavior through methods, and produce domain events when state changes. TDD first: we write the invariant tests, watch them fail against the anemic entity, then build the aggregate that makes them pass.

And we do it without breaking production. The Strangler Fig pattern gives us feature flags: old and new code coexist in the same service, rollback is a flag flip, and the characterization tests from Part V verify that the new path produces identical results.


What Is an Aggregate

An aggregate is a cluster of entities and Value Objects treated as a single unit for data changes. It has an Aggregate Root -- the entry point through which all mutations flow. External code never reaches inside the aggregate to modify a child entity or a Value Object directly. It goes through the root, and the root decides whether the operation is valid.

The aggregate is a consistency boundary. Everything inside the boundary is consistent after every operation. If the aggregate accepts the command, the business rule is satisfied. If it rejects the command, it returns a failure -- not an exception, a Result (see The Result Pattern). The caller never needs to validate the aggregate's internal state because the aggregate does not allow invalid internal state to exist.

This is the fundamental inversion from the anemic model. In the mud:

  • The entity is a data bag.
  • The service checks preconditions, mutates fields, checks postconditions.
  • Invariants are scattered across services, controllers, and stored procedures.
  • Nothing prevents an entity from entering an invalid state -- you just hope nobody makes a mistake.

In DDD:

  • The aggregate root is the guardian of its invariants.
  • The aggregate exposes methods that represent domain operations.
  • If the aggregate accepts the command, the invariant holds. Period.
  • Invalid state is unrepresentable -- private setters, no public constructors, factory methods with validation.

Three rules define aggregates:

  1. External references by ID only. An aggregate holds the ID of another aggregate, never a navigation property. CustomerId, not Customer. This is how you break the 31-entity navigation chain from Pathology 6.
  2. Modify one aggregate per transaction. If you need to coordinate two aggregates, use domain events (Part X). If you find yourself loading two aggregates and modifying both in the same SaveChanges(), you have your boundary wrong.
  3. Keep them small. An aggregate is not a God Object at the domain level. It contains exactly the entities and Value Objects that must be consistent with each other in a single transaction. Nothing more.
Diagram

Green nodes are aggregate roots. Gray nodes are child entities (accessed only through the root). Purple nodes are Value Objects. External code interacts only with roots. The red arrows (crossed out) represent what the mud does -- reaching inside to mutate children directly -- and what aggregates forbid.

For a thorough treatment of aggregates, entities, and roots, see DDD & Code Generation: Aggregates and Aggregate Roots.


Identifying Aggregate Boundaries

In Part III, the yellow stickies on the Event Storming wall represented aggregate candidates. Now we formalize them. The rule is simple: what must be consistent in a single transaction belongs in one aggregate. If two things can be eventually consistent -- updated a few seconds apart -- they belong in separate aggregates.

Look at the yellow stickies from the Subscriptions context:

  • Subscription handles ChangePlan, Cancel, Suspend, Resume. It produces PlanChangedEvent, SubscriptionCancelledEvent, SubscriptionSuspendedEvent.
  • A plan change must update the status, record the new plan, and create a proration record atomically. If any piece fails, the whole operation fails.
  • That means Subscription, PlanAssignment, and TrialPeriod must be in the same aggregate.

Now look at the Billing context:

  • Invoice handles AddLineItem, ApplyTax, Finalize. It produces InvoiceGeneratedEvent.
  • An invoice and its line items must be consistent -- you cannot have an invoice with a total that does not match the sum of its line items.
  • That means Invoice, InvoiceLineItem, and TaxLineItem are one aggregate.
  • Payment handles Process, Refund. It produces PaymentProcessedEvent, PaymentFailedEvent.
  • A payment has its own lifecycle: it can be pending, settled, refunded. It does not need to be transactionally consistent with the invoice -- the invoice is finalized before payment is attempted.
  • That means Payment is a separate aggregate from Invoice.

What is not an aggregate:

  • Customer is not an aggregate in the Subscriptions context. It is a reference: CustomerId. The Subscriptions context does not own customer data -- it receives what it needs through events or query endpoints.
  • UsageRecord is not part of the Subscription aggregate. Usage records are created asynchronously by a metering service. They do not need to be transactionally consistent with the subscription. They belong to the Billing context, and they are their own aggregate (or, if they are append-only and never mutated, they are not aggregates at all -- they are event streams).
  • Plan is not an aggregate in the Subscriptions context. It is a catalog entry managed by the Product context. The Subscription stores the plan information it needs as Value Objects (PlanTier, Money), not as a navigation property.

Here is what happened to the 31-entity flat AppDbContext:

Diagram

Thirty-one entities connected to everything became six aggregates, each in its own bounded context, each with clear rules about what it owns and what it references by ID. The navigation property chain that let Subscription.Customer.PaymentMethods.First().BillingAddress.Country compile -- and that caused lazy-loading N+1 queries in production -- is gone. Replaced by CustomerId.

A common mistake here: making aggregates too large. If you put Invoice, Payment, and UsageRecord into the Subscription aggregate because "they're all related to a subscription," you have recreated the God Object at the domain level. Related does not mean transactionally consistent. The litmus test: can these two things be updated independently, a few seconds apart, without breaking a business rule? If yes, they are separate aggregates.


Subscription -- Test First

This is Phase 5 of the migration. TDD. Write the invariant tests first. Watch them fail against the anemic entity. Then build the aggregate that makes them pass.

The invariant tests express what the Subscription aggregate must guarantee. These are the business rules that currently live in SubscriptionService.ChangePlan() and its siblings -- rules that nobody formally specified, rules that emerged from reading the service code and interviewing the Subscriptions team.

The Tests

public class SubscriptionAggregateTests
{
    private readonly DateOnly _today = new(2026, 3, 25);
    private readonly PlanTier _starterPlan = PlanTier.Starter;
    private readonly PlanTier _proPlan = PlanTier.Professional;
    private readonly Money _starterPrice = Money.From(29.00m, "USD");
    private readonly Money _proPrice = Money.From(99.00m, "USD");

    private Subscription CreateActiveSubscription()
    {
        var result = Subscription.Create(
            CustomerId.From(1),
            _starterPlan,
            _starterPrice,
            billingCycle: BillingCycle.Monthly,
            startDate: _today.AddMonths(-3));

        result.IsSuccess.Should().BeTrue();
        return result.Value;
    }

    // --- ChangePlan ---

    [Fact]
    public void ChangePlan_WhenActive_Succeeds_And_RaisesEvent()
    {
        var subscription = CreateActiveSubscription();

        var result = subscription.ChangePlan(_proPlan, _proPrice, _today);

        result.IsSuccess.Should().BeTrue();
        subscription.CurrentPlan.Should().Be(_proPlan);
        subscription.CurrentPrice.Should().Be(_proPrice);
        subscription.DomainEvents.Should().ContainSingle()
            .Which.Should().BeOfType<PlanChangedEvent>()
            .Which.NewPlan.Should().Be(_proPlan);
    }

    [Fact]
    public void ChangePlan_WhenCancelled_ReturnsFailure()
    {
        var subscription = CreateActiveSubscription();
        subscription.Cancel(CancellationReason.CustomerRequest, _today);

        var result = subscription.ChangePlan(_proPlan, _proPrice, _today);

        result.IsFailure.Should().BeTrue();
        result.Error.Should().Contain("cancelled");
    }

    [Fact]
    public void ChangePlan_ToSamePlan_ReturnsFailure()
    {
        var subscription = CreateActiveSubscription();

        var result = subscription.ChangePlan(_starterPlan, _starterPrice, _today);

        result.IsFailure.Should().BeTrue();
        result.Error.Should().Contain("already on this plan");
    }

    // --- Cancel ---

    [Fact]
    public void Cancel_WhenActive_Succeeds_And_RaisesEvent()
    {
        var subscription = CreateActiveSubscription();

        var result = subscription.Cancel(
            CancellationReason.CustomerRequest, _today);

        result.IsSuccess.Should().BeTrue();
        subscription.Status.Should().Be(SubscriptionStatus.Cancelled);
        subscription.DomainEvents.Should().ContainSingle()
            .Which.Should().BeOfType<SubscriptionCancelledEvent>();
    }

    [Fact]
    public void Cancel_WhenAlreadyCancelled_ReturnsFailure()
    {
        var subscription = CreateActiveSubscription();
        subscription.Cancel(CancellationReason.CustomerRequest, _today);
        subscription.ClearDomainEvents();

        var result = subscription.Cancel(
            CancellationReason.NonPayment, _today);

        result.IsFailure.Should().BeTrue();
        result.Error.Should().Contain("already cancelled");
        subscription.DomainEvents.Should().BeEmpty();
    }

    // --- Suspend ---

    [Fact]
    public void Suspend_OnlyFromActiveState()
    {
        var subscription = CreateActiveSubscription();

        var result = subscription.Suspend(SuspensionReason.PaymentFailure);

        result.IsSuccess.Should().BeTrue();
        subscription.Status.Should().Be(SubscriptionStatus.Suspended);
    }

    [Fact]
    public void Suspend_WhenSuspended_ReturnsFailure()
    {
        var subscription = CreateActiveSubscription();
        subscription.Suspend(SuspensionReason.PaymentFailure);

        var result = subscription.Suspend(SuspensionReason.PaymentFailure);

        result.IsFailure.Should().BeTrue();
    }

    [Fact]
    public void Suspend_WhenCancelled_ReturnsFailure()
    {
        var subscription = CreateActiveSubscription();
        subscription.Cancel(CancellationReason.CustomerRequest, _today);

        var result = subscription.Suspend(SuspensionReason.PaymentFailure);

        result.IsFailure.Should().BeTrue();
        result.Error.Should().Contain("Only active subscriptions");
    }

    // --- Create ---

    [Fact]
    public void Create_WithValidInputs_Succeeds()
    {
        var result = Subscription.Create(
            CustomerId.From(42),
            _starterPlan,
            _starterPrice,
            BillingCycle.Monthly,
            _today);

        result.IsSuccess.Should().BeTrue();
        result.Value.Status.Should().Be(SubscriptionStatus.Active);
        result.Value.CustomerId.Should().Be(CustomerId.From(42));
    }

    [Fact]
    public void Create_WithZeroPrice_ReturnsFailure()
    {
        var result = Subscription.Create(
            CustomerId.From(42),
            _starterPlan,
            Money.Zero("USD"),
            BillingCycle.Monthly,
            _today);

        result.IsFailure.Should().BeTrue();
        result.Error.Should().Contain("price");
    }

    // --- State machine ---

    [Fact]
    public void Resume_FromSuspended_ReturnsToActive()
    {
        var subscription = CreateActiveSubscription();
        subscription.Suspend(SuspensionReason.PaymentFailure);

        var result = subscription.Resume();

        result.IsSuccess.Should().BeTrue();
        subscription.Status.Should().Be(SubscriptionStatus.Active);
    }

    [Fact]
    public void Resume_FromActive_ReturnsFailure()
    {
        var subscription = CreateActiveSubscription();

        var result = subscription.Resume();

        result.IsFailure.Should().BeTrue();
        result.Error.Should().Contain("not suspended");
    }
}

Thirteen tests. Every one expresses a business rule: "you cannot change the plan on a cancelled subscription," "you cannot suspend what is already suspended," "creation with zero price is invalid." These tests do not test implementation details. They test invariants -- statements that must always be true about the aggregate.

Run them now against the anemic Subscription entity from Part II. They do not compile. The anemic entity has no ChangePlan() method, no Cancel() method, no factory Create(), no Result returns, no domain events. The entity is a data bag. It cannot participate in these tests because it has no behavior to test.

That is the point. The tests define the contract. Now we build the aggregate to satisfy it.

The Anemic Entity (Before)

This is what we start with -- the Subscription entity from Part II, unchanged:

public class Subscription
{
    public int Id { get; set; }
    public int CustomerId { get; set; }
    public int PlanId { get; set; }
    public string Status { get; set; } = "Active";
    public DateTime StartDate { get; set; }
    public DateTime? EndDate { get; set; }
    public DateTime? PlanChangedDate { get; set; }
    public int? PreviousPlanId { get; set; }
    public decimal? PreviousPlanPrice { get; set; }
    public string? CancellationReason { get; set; }
    public DateTime? CancelledAt { get; set; }
    public string? PauseReason { get; set; }
    public DateTime? PausedAt { get; set; }
    public DateTime? ResumedAt { get; set; }
    public bool IsTrialActive { get; set; }
    public DateTime? TrialEndDate { get; set; }
    public string BillingCycle { get; set; } = "Monthly";
    public decimal CurrentPrice { get; set; }
    public string Currency { get; set; } = "USD";
    public DateTime CreatedAt { get; set; }
    public DateTime UpdatedAt { get; set; }

    // Navigation properties
    public Customer Customer { get; set; } = null!;
    public Plan Plan { get; set; } = null!;
    public Plan? PreviousPlan { get; set; }
    public ICollection<UsageRecord> UsageRecords { get; set; }
        = new List<UsageRecord>();
    public ICollection<Invoice> Invoices { get; set; }
        = new List<Invoice>();
    public ICollection<SubscriptionEvent> Events { get; set; }
        = new List<SubscriptionEvent>();
    public ICollection<Discount> Discounts { get; set; }
        = new List<Discount>();
}

Everything wrong with this entity:

  • All setters are public. Any code, anywhere, can set Status = "Actve" (yes, with a typo -- there is one in the database from 2022).
  • Status is a string. No enum, no Value Object. Valid values are scattered across if statements in three services.
  • Navigation properties cross boundaries. Customer belongs to a different context. Invoices belong to Billing. UsageRecords belong to Billing. The entity is a portal into every other context.
  • No methods. Zero behavior. The entity has no opinion about anything.
  • int IDs. CustomerId is an int. PlanId is an int. You can pass a customer ID where a plan ID is expected, and the compiler says nothing.
  • No factory. Anyone can new Subscription() and set arbitrary fields. There is no guaranteed initial state.
  • No events. State changes are invisible. Other contexts learn about changes by polling the database.

The Rich Aggregate (After)

public sealed class Subscription : AggregateRoot<SubscriptionId>
{
    // --- State (all private setters) ---
    public CustomerId CustomerId { get; private set; }
    public PlanTier CurrentPlan { get; private set; }
    public Money CurrentPrice { get; private set; }
    public SubscriptionStatus Status { get; private set; }
    public BillingCycle BillingCycle { get; private set; }
    public SubscriptionPeriod CurrentPeriod { get; private set; }
    public TrialPeriod? Trial { get; private set; }
    public DateOnly? CancelledAt { get; private set; }
    public CancellationReason? CancellationReason { get; private set; }
    public SuspensionReason? SuspensionReason { get; private set; }

    // --- No navigation properties. No ICollection<Invoice>. No Customer. ---

    // --- Private constructor (EF Core + factory only) ---
    private Subscription() { }  // EF Core

    // --- Factory method: the ONLY way to create a Subscription ---
    public static Result<Subscription> Create(
        CustomerId customerId,
        PlanTier plan,
        Money price,
        BillingCycle billingCycle,
        DateOnly startDate)
    {
        if (price <= Money.Zero(price.Currency))
            return Result.Failure<Subscription>(
                "Subscription price must be greater than zero.");

        var subscription = new Subscription
        {
            Id = SubscriptionId.New(),
            CustomerId = customerId,
            CurrentPlan = plan,
            CurrentPrice = price,
            Status = SubscriptionStatus.Active,
            BillingCycle = billingCycle,
            CurrentPeriod = SubscriptionPeriod.StartingOn(
                startDate, billingCycle),
        };

        subscription.AddDomainEvent(new SubscriptionCreatedEvent(
            subscription.Id, customerId, plan, price, startDate));

        return Result.Success(subscription);
    }

    // --- Domain operations ---

    public Result<PlanChangedEvent> ChangePlan(
        PlanTier newPlan, Money newPrice, DateOnly effectiveDate)
    {
        if (Status == SubscriptionStatus.Cancelled)
            return Result.Failure<PlanChangedEvent>(
                "Cannot change plan: subscription is cancelled.");

        if (CurrentPlan == newPlan)
            return Result.Failure<PlanChangedEvent>(
                "Cannot change plan: already on this plan.");

        var previousPlan = CurrentPlan;
        var previousPrice = CurrentPrice;

        CurrentPlan = newPlan;
        CurrentPrice = newPrice;
        CurrentPeriod = SubscriptionPeriod.StartingOn(
            effectiveDate, BillingCycle);

        var @event = new PlanChangedEvent(
            Id, previousPlan, newPlan, previousPrice, newPrice,
            effectiveDate);

        AddDomainEvent(@event);
        return Result.Success(@event);
    }

    public Result<SubscriptionCancelledEvent> Cancel(
        CancellationReason reason, DateOnly cancelDate)
    {
        if (Status == SubscriptionStatus.Cancelled)
            return Result.Failure<SubscriptionCancelledEvent>(
                "Subscription is already cancelled.");

        Status = SubscriptionStatus.Cancelled;
        CancelledAt = cancelDate;
        CancellationReason = reason;

        var @event = new SubscriptionCancelledEvent(
            Id, reason, cancelDate);

        AddDomainEvent(@event);
        return Result.Success(@event);
    }

    public Result<SubscriptionSuspendedEvent> Suspend(
        SuspensionReason reason)
    {
        if (Status != SubscriptionStatus.Active)
            return Result.Failure<SubscriptionSuspendedEvent>(
                "Only active subscriptions can be suspended.");

        Status = SubscriptionStatus.Suspended;
        SuspensionReason = reason;

        var @event = new SubscriptionSuspendedEvent(Id, reason);

        AddDomainEvent(@event);
        return Result.Success(@event);
    }

    public Result<SubscriptionResumedEvent> Resume()
    {
        if (Status != SubscriptionStatus.Suspended)
            return Result.Failure<SubscriptionResumedEvent>(
                "Cannot resume: subscription is not suspended.");

        Status = SubscriptionStatus.Active;
        SuspensionReason = null;

        var @event = new SubscriptionResumedEvent(Id);

        AddDomainEvent(@event);
        return Result.Success(@event);
    }
}

Run the tests. They pass. All thirteen.

Let's walk through what changed, line by line.

What was removed:

Anemic Entity Rich Aggregate Why
public int Id { get; set; } SubscriptionId Id (from base class) Typed ID prevents customerId where subscriptionId is expected
public int CustomerId { get; set; } public CustomerId CustomerId { get; private set; } Typed ID + private setter
public string Status { get; set; } public SubscriptionStatus Status { get; private set; } Enum, not string. Private setter -- only aggregate methods change it
public Customer Customer { get; set; } (removed) ID reference only. Subscription does not own Customer
public Plan Plan { get; set; } (removed) Replaced by PlanTier Value Object + Money price
public ICollection<Invoice> Invoices (removed) Invoices belong to Billing context
public ICollection<UsageRecord> UsageRecords (removed) Usage records belong to Billing context
public ICollection<Discount> Discounts (removed) Discounts are a separate aggregate or a value in PlanAssignment
All public setters All private setters State changes only through domain methods
No methods ChangePlan, Cancel, Suspend, Resume Behavior lives on the entity
throw new Exception(...) Result.Failure<T>(...) No exceptions for business rule violations
No events AddDomainEvent(...) State changes produce events for other contexts

What was added:

  • Factory method Create(): the only way to construct a Subscription. Validates inputs. Returns Result. Produces SubscriptionCreatedEvent. No more new Subscription { Status = "Actve" }.
  • Value Object properties: PlanTier, Money, SubscriptionPeriod, BillingCycle, CancellationReason, SuspensionReason. Each carries its own validation. See Part VIII.
  • Domain events: every state change produces an event. PlanChangedEvent carries the old plan, new plan, old price, new price. The Billing context reacts by generating a prorated invoice. The Notifications context reacts by sending a confirmation email. Neither context loads the Subscription entity.
  • State machine: SubscriptionStatus transitions are enforced by the methods. Cancel only works from non-cancelled states. Suspend only works from Active. Resume only works from Suspended. For a deeper treatment of state machines in domain models, see Finite State Machines.
Diagram

The entity went from a bag of twenty-two publicly writable fields to a domain model with five methods, each returning a Result, each producing a domain event on success, each enforcing invariants that the compiler and the tests verify. The anemic entity could not protect itself. The aggregate can.


Invoice Aggregate

The Invoice aggregate has a different shape. The Subscription aggregate is command-driven -- a user issues ChangePlan, Cancel, Suspend. The Invoice aggregate is event-driven -- it is created by a handler reacting to PlanChangedEvent or a scheduled billing run. No user ever directly says "create an invoice." The system does.

The aggregate still follows the same pattern: factory method, private setters, Result returns, domain events. But the internal structure is different because an Invoice has a collection of child entities (line items) that must be consistent with the root's totals.

The Tests

public class InvoiceAggregateTests
{
    private readonly SubscriptionId _subscriptionId = SubscriptionId.New();
    private readonly DateOnly _invoiceDate = new(2026, 3, 25);

    [Fact]
    public void Create_ProducesEmptyInvoice()
    {
        var result = Invoice.Create(
            _subscriptionId, _invoiceDate, Currency.USD);

        result.IsSuccess.Should().BeTrue();
        result.Value.Status.Should().Be(InvoiceStatus.Draft);
        result.Value.LineItems.Should().BeEmpty();
        result.Value.Subtotal.Should().Be(Money.Zero("USD"));
    }

    [Fact]
    public void AddLineItem_UpdatesSubtotal()
    {
        var invoice = Invoice.Create(
            _subscriptionId, _invoiceDate, Currency.USD).Value;

        var result = invoice.AddLineItem(
            "Pro Plan - Monthly", Money.From(99.00m, "USD"));

        result.IsSuccess.Should().BeTrue();
        invoice.LineItems.Should().HaveCount(1);
        invoice.Subtotal.Should().Be(Money.From(99.00m, "USD"));
    }

    [Fact]
    public void AddLineItem_WhenFinalized_ReturnsFailure()
    {
        var invoice = Invoice.Create(
            _subscriptionId, _invoiceDate, Currency.USD).Value;
        invoice.AddLineItem("Plan", Money.From(99.00m, "USD"));
        invoice.ApplyTax(TaxRate.From(0.10m));
        invoice.Finalize();

        var result = invoice.AddLineItem(
            "Extra", Money.From(10.00m, "USD"));

        result.IsFailure.Should().BeTrue();
        result.Error.Should().Contain("finalized");
    }

    [Fact]
    public void Finalize_CalculatesTotal_RaisesEvent()
    {
        var invoice = Invoice.Create(
            _subscriptionId, _invoiceDate, Currency.USD).Value;
        invoice.AddLineItem("Plan", Money.From(100.00m, "USD"));
        invoice.ApplyTax(TaxRate.From(0.20m));

        var result = invoice.Finalize();

        result.IsSuccess.Should().BeTrue();
        invoice.Status.Should().Be(InvoiceStatus.Finalized);
        invoice.TaxAmount.Should().Be(Money.From(20.00m, "USD"));
        invoice.Total.Should().Be(Money.From(120.00m, "USD"));
        invoice.DomainEvents.Should().ContainSingle()
            .Which.Should().BeOfType<InvoiceGeneratedEvent>();
    }

    [Fact]
    public void Finalize_WithNoLineItems_ReturnsFailure()
    {
        var invoice = Invoice.Create(
            _subscriptionId, _invoiceDate, Currency.USD).Value;

        var result = invoice.Finalize();

        result.IsFailure.Should().BeTrue();
        result.Error.Should().Contain("no line items");
    }
}

The Implementation

public sealed class Invoice : AggregateRoot<InvoiceId>
{
    private readonly List<InvoiceLineItem> _lineItems = [];

    public SubscriptionId SubscriptionId { get; private set; }
    public DateOnly InvoiceDate { get; private set; }
    public Currency Currency { get; private set; }
    public InvoiceStatus Status { get; private set; }
    public Money Subtotal { get; private set; }
    public TaxRate? AppliedTaxRate { get; private set; }
    public Money TaxAmount { get; private set; }
    public Money Total { get; private set; }
    public IReadOnlyList<InvoiceLineItem> LineItems
        => _lineItems.AsReadOnly();

    private Invoice() { }

    public static Result<Invoice> Create(
        SubscriptionId subscriptionId,
        DateOnly invoiceDate,
        Currency currency)
    {
        return Result.Success(new Invoice
        {
            Id = InvoiceId.New(),
            SubscriptionId = subscriptionId,
            InvoiceDate = invoiceDate,
            Currency = currency,
            Status = InvoiceStatus.Draft,
            Subtotal = Money.Zero(currency.Code),
            TaxAmount = Money.Zero(currency.Code),
            Total = Money.Zero(currency.Code),
        });
    }

    public Result AddLineItem(string description, Money amount)
    {
        if (Status == InvoiceStatus.Finalized)
            return Result.Failure(
                "Cannot add line items to a finalized invoice.");

        _lineItems.Add(new InvoiceLineItem(description, amount));
        Subtotal = _lineItems
            .Aggregate(Money.Zero(Currency.Code),
                (sum, item) => sum + item.Amount);

        return Result.Success();
    }

    public void ApplyTax(TaxRate rate)
    {
        AppliedTaxRate = rate;
    }

    public Result<InvoiceGeneratedEvent> Finalize()
    {
        if (_lineItems.Count == 0)
            return Result.Failure<InvoiceGeneratedEvent>(
                "Cannot finalize invoice with no line items.");

        if (Status == InvoiceStatus.Finalized)
            return Result.Failure<InvoiceGeneratedEvent>(
                "Invoice is already finalized.");

        TaxAmount = AppliedTaxRate is not null
            ? Subtotal * AppliedTaxRate.Value
            : Money.Zero(Currency.Code);
        Total = Subtotal + TaxAmount;
        Status = InvoiceStatus.Finalized;

        var @event = new InvoiceGeneratedEvent(
            Id, SubscriptionId, Total, InvoiceDate);

        AddDomainEvent(@event);
        return Result.Success(@event);
    }
}

Same pattern, different shape. The Invoice aggregate owns its line items -- they are child entities inside the consistency boundary. The _lineItems field is a private List<InvoiceLineItem>, exposed as IReadOnlyList<InvoiceLineItem> through the property. External code cannot add, remove, or modify line items except through AddLineItem(). The subtotal is recalculated on every add. The total is calculated on Finalize(). The invariant -- "total equals subtotal plus tax" -- is enforced by the aggregate, not by a service.

Notice: SubscriptionId is an ID reference, not a navigation property. The Invoice aggregate does not load the Subscription entity. It does not know what a Subscription looks like. It received the subscription ID, plan name, and price from the PlanChangedEvent payload. This is aggregate isolation in practice.


The Strangler Fig in Action

The aggregate is built. The tests pass. Now the question: how do we ship this without breaking production?

The Strangler Fig pattern. The SubscriptionService -- the same service from Pathology 2 that currently holds all the business logic -- becomes a thin Anti-Corruption Layer. It delegates to the aggregate for migrated operations and keeps the legacy code for operations that have not been migrated yet. A feature flag controls which path executes. Both paths coexist in the same deployment. Rollback is a flag flip.

The ACL Service

public class SubscriptionService
{
    private readonly ISubscriptionRepository _repository;
    private readonly AppDbContext _legacyContext;
    private readonly IFeatureFlags _featureFlags;
    private readonly ILogger<SubscriptionService> _logger;

    public SubscriptionService(
        ISubscriptionRepository repository,
        AppDbContext legacyContext,
        IFeatureFlags featureFlags,
        ILogger<SubscriptionService> logger)
    {
        _repository = repository;
        _legacyContext = legacyContext;
        _featureFlags = featureFlags;
        _logger = logger;
    }

    // =============================================
    // MIGRATED — delegates to aggregate
    // =============================================

    public async Task<Result<ChangePlanResultDto>> ChangePlan(
        int subscriptionId, int newPlanId)
    {
        if (_featureFlags.UseNewSubscriptionAggregate)
        {
            // --- New path: aggregate ---
            var id = SubscriptionId.From(subscriptionId);
            var subscription = await _repository.GetByIdAsync(id);
            if (subscription is null)
                return Result.Failure<ChangePlanResultDto>(
                    $"Subscription {subscriptionId} not found.");

            var plan = PlanTier.FromId(newPlanId);
            var price = await _repository.GetPlanPriceAsync(plan);

            var result = subscription.ChangePlan(
                plan, price, DateOnly.FromDateTime(DateTime.UtcNow));

            if (result.IsFailure)
                return Result.Failure<ChangePlanResultDto>(
                    result.Error);

            await _repository.SaveAsync(subscription);

            return Result.Success(new ChangePlanResultDto
            {
                SubscriptionId = subscriptionId,
                OldPlanId = result.Value.PreviousPlan.ToId(),
                NewPlanId = newPlanId,
                // Proration handled by Billing context
                // reacting to PlanChangedEvent
            });
        }
        else
        {
            // --- Legacy path: original service logic ---
            return await ChangePlanLegacy(subscriptionId, newPlanId);
        }
    }

    public async Task<Result<CancelResultDto>> Cancel(
        int subscriptionId, string reason)
    {
        if (_featureFlags.UseNewSubscriptionAggregate)
        {
            var id = SubscriptionId.From(subscriptionId);
            var subscription = await _repository.GetByIdAsync(id);
            if (subscription is null)
                return Result.Failure<CancelResultDto>(
                    $"Subscription {subscriptionId} not found.");

            var cancellationReason =
                CancellationReason.FromString(reason);
            var result = subscription.Cancel(
                cancellationReason,
                DateOnly.FromDateTime(DateTime.UtcNow));

            if (result.IsFailure)
                return Result.Failure<CancelResultDto>(
                    result.Error);

            await _repository.SaveAsync(subscription);

            return Result.Success(new CancelResultDto
            {
                SubscriptionId = subscriptionId,
                CancelledAt = DateTime.UtcNow,
            });
        }
        else
        {
            return await CancelLegacy(subscriptionId, reason);
        }
    }

    // =============================================
    // NOT YET MIGRATED — legacy logic stays here
    // =============================================

    public async Task ProcessDunning(int subscriptionId)
    {
        // 200 lines of legacy dunning logic.
        // This method has not been migrated to the aggregate yet.
        // It still uses AppDbContext directly, sets public properties,
        // and sends emails from within the service.
        //
        // It will be migrated in a future iteration.
        // For now, it stays as-is. The Strangler Fig is patient.

        var subscription = await _legacyContext.Subscriptions
            .Include(s => s.Customer)
            .Include(s => s.Plan)
            .FirstOrDefaultAsync(s => s.Id == subscriptionId);

        // ... 200 lines of legacy code ...
    }

    // --- Private legacy methods ---

    private async Task<Result<ChangePlanResultDto>> ChangePlanLegacy(
        int subscriptionId, int newPlanId)
    {
        // Original SubscriptionService.ChangePlan() logic from Part II.
        // Untouched. Characterization tests cover it.
        // This method exists so we can flip back if needed.
        // ...
    }

    private async Task<Result<CancelResultDto>> CancelLegacy(
        int subscriptionId, string reason)
    {
        // Original cancel logic. Untouched.
        // ...
    }
}

This is the Strangler Fig in its natural form. The same class. The same method signatures (adjusted to return Result instead of throwing). But internally, a feature flag splits the world in two:

  • Flag on: the aggregate handles the operation. Invariants are enforced. Domain events are produced. The repository persists the aggregate.
  • Flag off: the legacy code runs. Same behavior as before. Characterization tests from Part V verify it.

You ship this. Both paths coexist. The flag defaults to off. You turn it on for a canary deployment -- 5% of traffic. You monitor. You compare results. If anything goes wrong, you flip the flag back. No rollback deployment. No emergency hotfix. A configuration change.

ProcessDunning() has not been migrated. It stays as legacy code. The Strangler Fig does not demand that you migrate everything at once. You migrate one method at a time, one aggregate behavior at a time. The legacy code shrinks gradually. Eventually, the legacy path is dead code, and you delete it. But "eventually" might be weeks or months from now. There is no rush.

Diagram

Green is the new path. Red is the legacy path. Yellow is the feature flag decision point. Both paths produce the same API response. The caller does not know which path executed. The characterization tests do not know either -- and that is how you know the migration is correct.

The key insight: the old characterization tests still pass on the new path. You wrote those tests in Part V against the legacy behavior. The aggregate produces the same observable results -- same response shape, same database state, same side effects. The new domain tests also pass, verifying the invariants that the legacy code enforced ad-hoc (or forgot to enforce). You have two layers of confidence: behavioral equivalence (characterization tests) and domain correctness (aggregate tests).


Repositories

The aggregate needs to be loaded and saved. That is the repository's job. The repository lives in the Domain layer as an interface and in the Infrastructure layer as an implementation.

The Interface (Domain Layer)

// Subscriptions.Domain/Repositories/ISubscriptionRepository.cs
public interface ISubscriptionRepository
{
    Task<Subscription?> GetByIdAsync(
        SubscriptionId id, CancellationToken ct = default);

    Task SaveAsync(
        Subscription subscription, CancellationToken ct = default);

    Task<Money> GetPlanPriceAsync(
        PlanTier plan, CancellationToken ct = default);
}

Three methods. GetByIdAsync loads the aggregate. SaveAsync persists it (and dispatches domain events). GetPlanPriceAsync is a query -- it belongs here because the aggregate needs the price to execute ChangePlan, and the price comes from the Product context's catalog.

No GetAll(). No IQueryable<Subscription>. No Find(Expression<Func<Subscription, bool>>). Repositories load aggregates by ID. If you need a list of subscriptions, you need a read model and a query handler, not a repository. Repositories are for writes.

The Implementation (Infrastructure Layer)

// Subscriptions.Infrastructure/Repositories/EfSubscriptionRepository.cs
public sealed class EfSubscriptionRepository : ISubscriptionRepository
{
    private readonly SubscriptionsDbContext _context;
    private readonly IDomainEventDispatcher _eventDispatcher;

    public EfSubscriptionRepository(
        SubscriptionsDbContext context,
        IDomainEventDispatcher eventDispatcher)
    {
        _context = context;
        _eventDispatcher = eventDispatcher;
    }

    public async Task<Subscription?> GetByIdAsync(
        SubscriptionId id, CancellationToken ct = default)
    {
        // No lazy loading. No Include chains that span contexts.
        // The Subscription aggregate owns its children —
        // EF Core loads them as owned entities.
        return await _context.Subscriptions
            .FirstOrDefaultAsync(s => s.Id == id, ct);
    }

    public async Task SaveAsync(
        Subscription subscription, CancellationToken ct = default)
    {
        // EF Core tracks changes automatically.
        // Dispatch domain events before SaveChanges
        // so handlers can participate in the same transaction
        // (or after, for eventual consistency — see Part X).
        var events = subscription.DomainEvents.ToList();
        subscription.ClearDomainEvents();

        await _context.SaveChangesAsync(ct);

        foreach (var @event in events)
        {
            await _eventDispatcher.DispatchAsync(@event, ct);
        }
    }

    public async Task<Money> GetPlanPriceAsync(
        PlanTier plan, CancellationToken ct = default)
    {
        // Queries the Product context's plan catalog.
        // In a fully decomposed system, this would be an ACL call
        // to the Product context's API. For now, it reads from
        // a shared read model table.
        var planEntity = await _context.PlanCatalog
            .FirstOrDefaultAsync(p => p.Tier == plan, ct)
            ?? throw new InvalidOperationException(
                $"Plan {plan} not found in catalog.");

        return planEntity.Price;
    }
}

Notice what is not here:

  • No .Include(s => s.Customer). The aggregate does not own the Customer entity. CustomerId is a Value Object stored as a column, not a navigation property.
  • No .Include(s => s.Invoices). Invoices belong to the Billing context. They have their own aggregate and their own DbContext.
  • No .Include(s => s.UsageRecords). Usage records belong to Billing.
  • No lazy loading. The aggregate loads completely in one query. Its children (if any, like PlanAssignment and TrialPeriod) are EF Core owned entities -- they load automatically as part of the root. No N+1 surprise.

The repository loads the aggregate. The aggregate enforces invariants. The repository persists the result. Domain events are dispatched after persistence. This is the complete write-side lifecycle for a single aggregate operation.

For a deeper treatment of how aggregates, repositories, and persistence ignorance work together, see DDD & Code Generation: Aggregates and Aggregate Roots.


What We Have Now

Five phases complete.

Phase What Changed Test Layer
Phase 1 (Part V) Characterization tests Golden master
Phase 2 (Part VI) Bounded context libraries Structural / compilation
Phase 3 (Part VII) ACLs formalized Contract tests
Phase 4 (Part VIII) Value Objects extracted TDD unit tests
Phase 5 (Part IX) Aggregates built TDD invariant tests

The entities have behavior. Invariants are enforced by the aggregate, not scattered across services. The domain model is rich -- it has methods that return Results, produce events, and protect their own state. The Subscription entity went from a 22-property data bag with string Status and cross-context navigation properties to a sealed aggregate root with typed IDs, private setters, a factory method, four domain operations, and a domain event collection.

The characterization tests from Phase 1 still pass -- the Strangler Fig ACL delegates to the aggregate on the new path and to the legacy code on the old path, and both produce the same observable results. The new domain tests prove correctness at a level the characterization tests never could: they verify invariants, state machine transitions, and event production.

The legacy code has not been deleted. ProcessDunning() still lives in SubscriptionService, still uses the old AppDbContext, still sets public properties on the anemic entity. That is fine. The Strangler Fig is patient. Every iteration migrates one more method, one more behavior. The legacy surface area shrinks. The aggregate surface area grows. Eventually, the legacy code is dead code -- and deleting dead code is the easiest refactoring there is.

One piece is still missing. The aggregates produce domain events -- PlanChangedEvent, SubscriptionCancelledEvent, InvoiceGeneratedEvent -- but nobody is listening yet. The events are dispatched but not handled. The Billing context does not yet react to PlanChangedEvent by generating a prorated invoice. The Notifications context does not yet react by sending a confirmation email.

That is Part X: Introduce Domain Events. The events are the glue that replaces the circular dependencies between contexts. Instead of SubscriptionService calling BillingService calling NotificationService calling SubscriptionService (the circular dependency from Part I), each context publishes events and subscribes to the events it cares about. No direct calls. No shared databases. No circular dependencies. Events.