Part III: Discovering Boundaries and Internals
"It is not the domain expert's job to know the solution. It is our job to discover what they already know." -- Alberto Brandolini
In Part II, we diagnosed six pathologies: God Services, anemic models, scattered rules, infrastructure coupling, leaky boundaries, and no aggregate boundaries. We felt the pain. We named the diseases. Now we need to discover the structure hiding inside the mud -- the boundaries that already exist implicitly but have never been formalized, and the aggregates, commands, policies, and read models that live inside those boundaries.
The technique is Event Storming. Invented by Alberto Brandolini in 2012 and refined over a decade of practice, it is a collaborative workshop that discovers the domain by exploring what happens in the system rather than what the system is. It bypasses the code entirely. It bypasses the database schema. It bypasses the architecture diagrams that nobody updates. It starts from the one thing that everyone in the room -- developers, product owners, domain experts -- can agree on: something happened.
This part covers two levels of Event Storming, applied to SubscriptionHub:
- Big Picture Event Storming -- discovers the bounded contexts. Where are the boundaries? What events belong to which context? Where do contexts communicate?
- Design-Level Event Storming -- discovers what lives inside each bounded context. Aggregates, commands, read models, policies, Value Objects, domain services, external integrations.
Together, these two workshops produce the complete blueprint for the migration. Not a vague direction -- a specific map: these events, in these contexts, handled by these aggregates, triggered by these commands, enforced by these policies. The same map that drives Parts IV through X.
What Is Event Storming
Event Storming is a workshop technique. Not a methodology. Not a framework. A technique -- like TDD is a technique. You need a large wall (or a very long whiteboard), unlimited sticky notes in specific colors, markers, and a room full of people who know the domain. The output is a shared mental model of the system, expressed as a timeline of domain events with associated commands, aggregates, policies, and read models.
Why does it work for legacy systems specifically? Because legacy systems have a paradoxical property: the people understand the domain far better than the code expresses it. SubscriptionHub's BillingService is 2,400 lines of tangled logic, but the Billing team can explain the dunning process in two minutes on a whiteboard. The knowledge is there. It is in people's heads. Event Storming extracts it and makes it visible.
The technique uses colored sticky notes, each representing a different concept. The colors are not arbitrary -- they create a visual grammar that everyone in the room learns in ten minutes.
The Sticky Note Vocabulary
| Color | Concept | Grammar | SubscriptionHub Example |
|---|---|---|---|
| Orange | Domain Event | Something that happened. Past tense. | SubscriptionCreated |
| Blue | Command | An action triggered by a user or policy. Imperative. | ChangePlan |
| Yellow | Aggregate | The thing that handles the command and produces the event. | Subscription |
| Pink | Hot Spot | Pain point, confusion, disagreement. A question mark. | "Who owns proration?" |
| Green | Read Model | What users need to see to make a decision. | InvoiceHistory |
| Purple | Policy / Reaction | When X happens, automatically do Y. Automation. | PaymentFailed 3x --> SuspendSubscription |
| Lilac | External System | Something outside the domain that we integrate with. | Stripe, Tax API |
Workshop Logistics
For SubscriptionHub, the workshop needs:
- All four teams in the room. Subscriptions (3), Billing (3), Platform (2), Data (2). Plus the product owner. That is 11 people. If you cannot get everyone, you absolutely must have at least one representative from each team. The whole point is to surface where knowledge overlaps, conflicts, and gaps.
- A large wall. Eight to twelve meters of horizontal space. Butcher paper taped to the wall works. A conference room whiteboard does not -- it is too small by a factor of four.
- Unlimited sticky notes. Not a metaphor. You will use 150-300 stickies in a full-day session. Buy the large (76x76mm) ones.
- Thick markers. Everyone writes. Nobody types. The physicality matters -- standing, walking, arguing in front of the wall. This is not a meeting where one person presents and others nod.
- A full day. Two half-days work if you cannot get a full day, but the momentum suffers. The morning is Big Picture. The afternoon is Design-Level.
- A facilitator. Someone who knows the technique. Not someone who knows the domain -- that is the room's job. The facilitator keeps the process moving, prevents premature design, and asks the right questions at the right time.
We run the workshop in two passes. Big Picture Event Storming discovers the bounded contexts -- the where. Design-Level Event Storming discovers what lives inside each context -- the what. Both happen in the same day.
Starting from What Exists
Before the sticky notes hit the wall, acknowledge what the room already knows. The four teams in SubscriptionHub already sense the boundaries. They say things like "that's a billing concern" and "notifications should handle that." These instincts are not wrong. They are the raw material.
But instincts are imprecise. When you ask each team to draw the boundaries, you get four different maps:
- Subscriptions team thinks they own: subscription lifecycle, plan management, trials, proration.
- Billing team thinks they own: invoicing, payments, proration, tax, dunning, multi-currency.
- Platform team thinks they own: notifications, webhooks, infrastructure.
- Data team thinks they own: analytics, reporting, ETL.
Notice the overlap. Both the Subscriptions team and the Billing team claim proration. Neither team claims trial-to-paid conversion -- the logic is split between SubscriptionService and BillingService and nobody knows who is responsible. Nobody claims the stored procedure sp_CalculateProration -- the Finance team wrote it and left no ownership trail.
The overlaps are hot spots -- areas where two teams both think they own the logic, which means neither team fully owns it. The unclaimed areas are gaps -- logic that exists in the codebase but belongs to nobody's mental model. Both are symptoms of missing boundaries.
Event Storming resolves these ambiguities not by debating ownership in the abstract, but by tracing the events. When you lay out the timeline of what happens in the system, the ownership becomes obvious. Proration is triggered by a plan change (Subscriptions) but calculated as part of invoice generation (Billing). That is not overlap -- that is a context boundary. The plan change produces an event; the billing context consumes it. Once you see it on the wall, the argument dissolves.
Big Picture Event Storming
This is the morning session. The goal is to discover the bounded contexts by exploring what happens in SubscriptionHub, end to end, from customer sign-up to churn.
Phase 1: Chaotic Exploration
The facilitator says: "Write down everything that happens in the system. One event per sticky note. Past tense. No filtering. No ordering. Just events."
Eleven people write for fifteen minutes. The wall fills up. Some events are duplicates (three people write "InvoiceGenerated"). Some are vague ("Payment processed" -- processed how? succeeded? failed?). Some reveal knowledge that only one team has ("DunningEscalated" -- nobody outside the Billing team knew this event existed).
Here is what the wall looks like after Phase 1, roughly categorized by the domain area they touch:
Subscription lifecycle:
- SubscriptionCreated
- TrialStarted
- TrialExpired
- PlanSelected
- PlanChanged
- SubscriptionUpgraded
- SubscriptionDowngraded
- SubscriptionActivated
- SubscriptionSuspended
- SubscriptionCancelled
- SubscriptionResumed
Usage:
- UsageRecorded
- UsageThresholdReached
- UsageCalculated
- UsageQuotaExceeded
Billing:
- InvoiceGenerated
- InvoiceLineItemAdded
- TaxCalculated
- InvoiceFinalized
- PaymentAttempted
- PaymentSucceeded
- PaymentFailed
- RefundIssued
- CreditApplied
- DunningStarted
- DunningEscalated
- DunningExhausted
Notifications:
- NotificationRequested
- EmailSent
- EmailBounced
- WebhookDispatched
- WebhookFailed
- ReminderScheduled
Analytics:
- DataExported
- ReportGenerated
- MRRCalculated
- ChurnRateUpdated
That is 31 events in the first pass. Some will be merged ("SubscriptionUpgraded" and "SubscriptionDowngraded" are both "PlanChanged" with direction). Some will be split ("PaymentAttempted" becomes three distinct events depending on the payment method). But this raw list is the starting point.
Phase 2: Enforce the Timeline
The facilitator says: "Now put them in order. Left to right. Time flows left to right. The first thing that happens goes on the left. The last thing goes on the right."
This is where the arguments start -- and that is the point. The arguments reveal domain knowledge that no single person holds.
"Wait -- does UsageCalculated happen before or after InvoiceGenerated?"
The Billing team says: "Usage is calculated during invoice generation. It is part of the billing batch."
The Subscriptions team says: "No, usage is recorded continuously. UsageCalculated is a nightly aggregation job. The invoice reads the aggregated totals."
The Data team says: "The ETL picks up usage records every hour. Are we talking about the same thing?"
Three teams. Three different understandings of when usage data becomes billable. This is exactly what Event Storming is designed to surface. The facilitator puts a pink hot spot sticky on the UsageCalculated event: "When does usage become billable? Continuous vs. batch vs. hourly?"
More conflicts emerge:
TaxCalculated-- does it happen during invoice generation (the Billing team's implementation) or as a separate step (what the Finance team expected)?SubscriptionActivated-- does it happen immediately afterPlanSelected(the API flow) or afterPaymentSucceeded(the business rule that you don't activate until paid)?TrialExpired-- is it a cron job that runs daily (the current implementation) or a scheduled event per subscription (what the Subscriptions team assumed)?
Each conflict becomes a pink hot spot. After Phase 2, there are nine pink stickies on the wall. Nine areas where the teams disagree about how the system works. These map almost perfectly to the six pathologies from Part II -- scattered rules, leaky boundaries, and infrastructure coupling all manifest as timeline disagreements.
Phase 3: Mark Hot Spots and Pivotal Events
The facilitator asks: "Where does the system hurt? Where do bugs live? Where do teams block each other?"
More pink stickies go up:
- "Proration: 3 implementations, 3 different answers" (Pathology 3)
- "BillingService.ProcessMonthlyBilling() -- 2,400 lines, 4 teams edit it" (Pathology 1)
- "Notifications query main DB directly" (Pathology 5)
- "Analytics ETL breaks when columns rename" (Pathology 5)
- "Cannot test billing without Stripe + Tax API + SMTP" (Pathology 4)
Then the facilitator introduces a critical concept: pivotal events. These are the events that mark a major state transition in the domain. They are the natural boundary markers. In SubscriptionHub, the pivotal events are:
- SubscriptionActivated -- the subscription transitions from "prospective" to "active." Everything before this is onboarding. Everything after is lifecycle management.
- InvoiceFinalized -- the invoice transitions from "draft" to "payable." This is the handoff from billing calculation to payment processing.
- PaymentFailed -- the system transitions from "happy path" to "recovery." Dunning, retries, and suspension logic all trigger from this event.
Pivotal events are significant because they often sit exactly at the boundary between two bounded contexts. InvoiceFinalized is where the Billing context finishes its work and the Payment processing begins. PaymentFailed is where Billing's recovery logic (dunning) connects to Subscriptions (suspension) and Notifications (alerts).
Phase 4: Identify Swimlanes
Now the room has a timeline of events with hot spots and pivotal events marked. The facilitator draws horizontal lines to separate the events into swimlanes -- groups of events that belong together.
The criteria: events in the same swimlane share vocabulary, are maintained by the same team, change for the same business reasons, and use the same data. Events in different swimlanes have different vocabularies, different change rhythms, and different data needs.
The swimlanes emerge naturally. You do not design them. You look at the wall and say: "These events cluster together. Those events cluster together. There is a gap here."
Four swimlanes appear:
Top lane: SubscriptionCreated, TrialStarted, TrialExpired, PlanSelected, PlanChanged, SubscriptionActivated, SubscriptionSuspended, SubscriptionCancelled, SubscriptionResumed. All lifecycle events. All owned by the Subscriptions team.
Second lane: InvoiceGenerated, InvoiceLineItemAdded, TaxCalculated, InvoiceFinalized, PaymentAttempted, PaymentSucceeded, PaymentFailed, RefundIssued, CreditApplied, DunningStarted, DunningEscalated. All financial events. All owned by the Billing team.
Third lane: NotificationRequested, EmailSent, EmailBounced, WebhookDispatched, WebhookFailed, ReminderScheduled. All communication events. Owned by the Platform team.
Bottom lane: DataExported, ReportGenerated, MRRCalculated, ChurnRateUpdated. All analytics events. Owned by the Data team.
The swimlanes ARE the bounded contexts. We just discovered them -- not by analyzing code, not by debating architecture, but by asking "what happens?" and grouping the answers.
The dashed arrows between swimlanes are the context boundaries. They represent events that cross from one context to another. These are exactly the places where Anti-Corruption Layers (ACLs) need to live. The arrow from PlanChanged to InvoiceGenerated crosses from Subscriptions to Billing -- this is where the proration logic belongs, and it belongs in the Billing context, consuming the PlanChangedEvent from Subscriptions. Not in a God Service that straddles both.
Four Bounded Contexts
The Big Picture session produced four bounded contexts. Let us formalize them.
1. Subscriptions Context
Team: Subscriptions (3 developers)
Responsibility: Subscription lifecycle management. Everything from "a customer decides to subscribe" through "the subscription is active" through "the customer cancels."
Events owned:
SubscriptionCreated-- a new subscription record existsTrialStarted-- a free trial period beginsTrialExpired-- the trial period ended (clock ran out)PlanSelected-- customer chose a specific planPlanChanged-- customer upgraded, downgraded, or switched plansSubscriptionActivated-- subscription is live and billableSubscriptionSuspended-- subscription paused (non-payment, admin action)SubscriptionCancelled-- subscription terminatedSubscriptionResumed-- previously suspended subscription reactivated
External systems: None directly. Subscriptions is a pure domain context.
Key insight from the workshop: Proration is NOT owned by Subscriptions. The Subscriptions context publishes PlanChanged with the old plan, new plan, and change date. The Billing context consumes this event and calculates the prorated amounts. This resolves the overlap that both teams claimed. Subscriptions says what happened. Billing decides what it costs.
2. Billing Context
Team: Billing (3 developers)
Responsibility: Financial operations. Invoice generation, tax calculation, payment processing, dunning, refunds, credits, multi-currency.
Events owned:
InvoiceGenerated-- a draft invoice was created with line itemsTaxCalculated-- tax amounts were computed for an invoiceInvoiceFinalized-- the invoice is ready for paymentPaymentAttempted-- a charge was submitted to the payment gatewayPaymentSucceeded-- the charge was approvedPaymentFailed-- the charge was declinedRefundIssued-- money was returned to the customerCreditApplied-- a credit was applied to the accountDunningStarted-- automated payment recovery initiatedDunningEscalated-- dunning moved to next severity levelDunningExhausted-- all retry attempts failed
Events consumed:
PlanChanged(from Subscriptions) -- triggers prorated invoice generationSubscriptionActivated(from Subscriptions) -- triggers first invoiceSubscriptionCancelled(from Subscriptions) -- triggers final invoice and stops recurring billing
External systems: Stripe (payment gateway), Tax API (tax calculation)
Key insight from the workshop: The three proration implementations collapse to ONE. It lives in the Billing context, in the handler that processes PlanChangedEvent. The controller preview calls the same logic through a query. The stored procedure is deleted.
3. Notifications Context
Team: Platform (2 developers)
Responsibility: All outbound communication. Email, webhooks, push notifications, scheduled reminders.
Events consumed:
InvoiceFinalized(from Billing) -- triggers invoice emailPaymentFailed(from Billing) -- triggers payment alertPaymentSucceeded(from Billing) -- triggers receipt emailSubscriptionCancelled(from Subscriptions) -- triggers cancellation confirmationTrialExpired(from Subscriptions) -- triggers "your trial ended" emailDunningEscalated(from Billing) -- triggers increasingly urgent payment reminders
Events owned:
EmailSent-- an email was dispatchedEmailBounced-- delivery failedWebhookDispatched-- a webhook payload was sentWebhookFailed-- webhook delivery failed
External systems: Email provider, webhook endpoints
Key insight from the workshop: Notifications does not need the full Subscription entity with 15 properties. It needs a NotificationRecipient -- name, email, and plan tier. It does not need to query the main database. It receives everything it needs in the event payload. This kills Pathology 5 (the NotificationService reach-around) completely.
4. Analytics Context
Team: Data (2 developers)
Responsibility: Reporting, dashboards, data export. Consumes events from all other contexts. Builds denormalized views for business intelligence.
Events consumed: Everything. Analytics is a downstream consumer of all domain events from Subscriptions and Billing.
Events owned:
DataExported-- a batch export completedReportGenerated-- a scheduled report was produced
External systems: Data warehouse
Key insight from the workshop: Analytics never writes back to the domain. It is purely read-only. It does not need aggregates, commands, or domain logic. It needs event handlers that project events into denormalized views. This is the simplest context, and it does not need DDD at all -- a set of event handlers and read models is sufficient.
The red ACL boxes are the critical points. Every arrow that crosses a context boundary must pass through an ACL that translates between the upstream context's vocabulary and the downstream context's vocabulary. Today, SubscriptionHub has zero proper ACLs. The PaymentGateway leaks Stripe types. The NotificationService queries the main database. The Analytics ETL uses raw SQL. Building these ACLs is Phase 3 of the migration -- covered in Part VII.
Notice the arrow from Billing back to Subscriptions: PaymentFailed 3x (DunningExhausted). This is the only upstream dependency where Billing tells Subscriptions to do something. In the current codebase, this is a circular dependency -- BillingService calls SubscriptionService.Suspend(). In the DDD design, it is an event: Billing publishes DunningExhausted, and Subscriptions' policy handler reacts by suspending the subscription. No circular dependency. No shared service. An event and a handler.
Design-Level: What Lives Inside Each Context
Big Picture told us where the boundaries are. Now we zoom in and discover what lives inside each one. This is the afternoon session. The facilitator picks one context at a time. For each context, the room adds blue (commands), yellow (aggregates), green (read models), and purple (policies) around the orange events.
Subscriptions Context
The Subscriptions context owns the subscription lifecycle. It is the most complex context in terms of state transitions and the highest change frequency in the codebase (most bugs, most PRs, most merge conflicts on BillingService.cs -- which will no longer be involved after migration).
Commands (blue stickies):
| Command | Trigger | Description |
|---|---|---|
CreateSubscription |
API / UI | Customer begins subscription process |
StartTrial |
API / UI | Customer starts a free trial |
SelectPlan |
API / UI | Customer picks a plan tier |
ChangePlan |
API / UI | Customer upgrades or downgrades |
ActivateSubscription |
Policy | Subscription becomes billable |
SuspendSubscription |
Policy | Non-payment or admin action |
CancelSubscription |
API / UI | Customer terminates subscription |
ResumeSubscription |
API / UI | Reactivate a suspended subscription |
Aggregates (yellow stickies):
Subscription (Aggregate Root) -- the core lifecycle entity. Owns: status, current plan reference, trial dates, activation date, cancellation reason. Enforces: valid state transitions (you cannot cancel a subscription that is already cancelled; you cannot resume a subscription that is active). This is the entity that is currently an anemic data bag in
SubscriptionHub.Data. After migration, it becomes a rich domain model with methods likeChangePlan(),Cancel(),Suspend(), andResume()-- each enforcing invariants and producing domain events.Plan (Aggregate Root, reference data) -- pricing tiers, feature flags, included quantities, overage rates. Plans change infrequently (product decisions, not customer actions) and are referenced by Subscriptions. In the current codebase,
Planis an EF Core entity with 12 navigation properties. After migration, it is a separate aggregate that publishesPlanUpdatedwhen pricing changes.
Read Models (green stickies):
- SubscriptionSummary -- what the customer sees on their dashboard: plan name, status, next billing date, trial days remaining
- PlanCatalog -- the list of available plans with pricing, shown during sign-up and plan change
Policies (purple stickies):
- TrialExpired --> AutoRemind -- when a trial expires, schedule a reminder notification (consumed by Notifications context)
- DunningExhausted --> SuspendSubscription -- when Billing exhausts all payment retries, automatically suspend the subscription
- PaymentSucceeded --> ActivateSubscription -- when the first payment succeeds, activate the subscription (if it was pending payment)
Value Objects:
SubscriptionPeriod-- start date + end date + interval (monthly/annual). Replaces the scatteredDateTimearithmetic.PlanTier-- tier identifier (Basic, Professional, Enterprise). Replaces magic strings.TrialDuration-- duration in days with expiry calculation. ReplacesDateTime.AddDays(14)scattered across methods.
Domain Services:
PlanChangePolicy-- validates whether a plan change is allowed (e.g., cannot downgrade during trial, cannot change to same plan, enterprise requires approval). Currently this logic is split betweenSubscriptionServiceandSubscriptionController.
The flow reads left to right: a command arrives, the aggregate handles it and enforces invariants, and an event is produced. Policies listen for events and produce new commands -- creating reactive chains. The DunningExhausted event comes from the Billing context (via ACL) and triggers the AutoSuspend policy, which issues a SuspendSubscription command back to the Subscription aggregate. No circular dependency. No God Service. Each arrow is a single responsibility.
Billing Context
The Billing context owns everything financial. It is the most complex context in terms of external integrations (Stripe, Tax API) and the one with the most ACL work ahead.
Commands (blue stickies):
| Command | Trigger | Description |
|---|---|---|
GenerateInvoice |
Policy / Scheduler | Create a draft invoice with line items |
CalculateTax |
Policy | Compute tax for an invoice via Tax API |
FinalizeInvoice |
Policy | Mark invoice as ready for payment |
ProcessPayment |
Policy | Submit charge to payment gateway |
RetryPayment |
Policy | Retry a failed payment attempt |
StartDunning |
Policy | Begin automated recovery sequence |
IssueRefund |
API / Admin | Return money to customer |
ApplyCredit |
API / Admin | Apply account credit to next invoice |
Aggregates (yellow stickies):
Invoice (Aggregate Root) -- the core financial document. Contains line items (plan charges, usage charges, adjustments), tax line items, totals, status (Draft, Finalized, Paid, Voided). Enforces: cannot finalize an invoice with no line items; cannot modify a finalized invoice; tax must be calculated before finalization. Currently, invoice creation is a 200-line block in
ProcessMonthlyBilling(). After migration, theInvoiceaggregate builds itself from commands and enforces its own invariants.Payment (Aggregate Root, separate lifecycle) -- represents a single payment attempt. Contains amount, status, gateway response, retry metadata. Has its own lifecycle: Pending --> Succeeded | Failed --> Retried --> Succeeded | Failed. Payments are not part of the Invoice aggregate because a single invoice can have multiple payment attempts (dunning retries), and each attempt has its own lifecycle.
Read Models (green stickies):
- InvoiceHistory -- customer's invoice list with status and amounts
- RevenueReport -- aggregated revenue by period, plan, currency
Policies (purple stickies):
- PlanChanged --> GenerateProratedInvoice -- when a subscription changes plans, generate a prorated invoice for the difference. This is where the three proration implementations converge into one.
- SubscriptionActivated --> GenerateFirstInvoice -- trigger the first billing cycle.
- InvoiceGenerated --> CalculateTax -- every new invoice needs tax calculation.
- TaxCalculated --> FinalizeInvoice -- once tax is done, the invoice is ready.
- InvoiceFinalized --> ProcessPayment -- submit the charge immediately.
- PaymentFailed --> RetryPayment -- retry with exponential backoff (1 hour, 24 hours, 72 hours).
- 3 RetryPayment failures --> StartDunning -- escalate to dunning sequence.
Value Objects:
Money-- amount + currency. Replaces nakeddecimalfields. Prevents adding USD to EUR. Handles rounding rules per currency.TaxRate-- rate + jurisdiction + calculation method. Replaces the flat 10% fallback and the scattered tax logic.DunningSchedule-- retry intervals, escalation thresholds, max attempts. Replaces hardcoded retry logic inBillingService.
ACL Adapters:
IPaymentGateway-- domain interface. Implementation wraps Stripe. Returns domain types (PaymentResult), not Stripe types (Stripe.Charge). This fixes Pathology 5.ITaxCalculator-- domain interface. Implementation wraps the Tax API. ReturnsTaxCalculation, not raw HTTP responses. This fixes Pathology 4.
Read this diagram as a reactive chain. An external event arrives (PlanChanged from Subscriptions). A policy reacts by issuing a command. The command goes to an aggregate. The aggregate produces an event. Another policy reacts. The chain continues until it reaches a terminal event (PaymentSucceeded or PaymentFailed) that other contexts consume.
This is the proration problem solved. There is exactly one place where prorated invoices are generated: the GenerateProratedInvoice policy handler in the Billing context. It consumes PlanChanged, extracts the old plan, new plan, and change date, calculates the proration using the Money Value Object, and issues a GenerateInvoice command. One implementation. One test. One truth.
Notifications Context
Notifications is simpler than the other two. It has no complex aggregates, no invariants to enforce, and no state transitions to manage. It is a reactive context -- it listens for events and dispatches communications.
Commands (blue stickies):
| Command | Trigger | Description |
|---|---|---|
SendEmail |
Policy | Dispatch a templated email |
DispatchWebhook |
Policy | Send event payload to customer webhook URL |
ScheduleReminder |
Policy | Queue a notification for future delivery |
Read Models (green stickies):
- NotificationRecipient -- the minimal data needed to send a notification: customer name, email, plan tier. This is NOT the full
Subscriptionentity. It is a tiny projection, populated from event payloads. - NotificationLog -- delivery history: what was sent, when, to whom, delivery status.
Policies (purple stickies):
- InvoiceFinalized --> SendInvoiceEmail -- send the invoice to the customer
- PaymentFailed --> SendPaymentAlert -- alert the customer about a failed payment
- PaymentSucceeded --> SendReceipt -- send a payment receipt
- SubscriptionCancelled --> SendCancellationConfirmation -- confirm cancellation
- TrialExpired --> SendTrialEndedEmail -- remind the customer their trial ended
Value Objects:
EmailAddress-- validated email. Replaces rawstringfields that accept "not-an-email".
Not every context needs aggregates. Notifications is essentially a queue with templates and event handlers. The "aggregate" here is arguably the notification itself (with delivery status tracking), but in practice, a simple event handler that dispatches to the email provider and logs the result is sufficient. DDD does not mandate aggregates everywhere -- it mandates that you think about what each context actually needs and model accordingly.
The critical change from the current design: NotificationService no longer queries the main DbContext. It receives everything it needs in the event payload. When the Billing context publishes InvoiceFinalized, the event carries the customer name, email, invoice amount, and plan name. The Notifications context does not need to know about the Invoice entity, the Customer entity, or the Plan entity. It needs a NotificationRecipient and an amount. That is all.
Analytics Context
Analytics is the simplest context. It is read-only. It consumes events from all other contexts and builds denormalized views for dashboards and reports. It has no commands (nothing writes back to the domain), no aggregates (no consistency boundaries needed), and no policies (no reactive behavior).
Read Models (green stickies):
- SubscriptionAnalyticsView -- one row per subscription: plan, status, start date, MRR contribution, churn risk score
- RevenueByPeriod -- aggregated revenue by month, quarter, year, broken down by plan and currency
- ChurnRate -- percentage of subscriptions that cancelled in each period
- MRR -- monthly recurring revenue, calculated from active subscriptions and their plan prices
Event Handlers:
Analytics subscribes to SubscriptionCreated, SubscriptionActivated, SubscriptionCancelled, PlanChanged, InvoiceFinalized, PaymentSucceeded, and PaymentFailed. Each handler updates the appropriate denormalized view. This replaces the raw SQL ETL that reads from the database replica and breaks when columns are renamed.
DDD is not needed here -- and that is an important point. Not every context in your system needs the full tactical toolkit. Analytics needs event handlers and read models. Trying to force aggregates and invariants onto a read-only reporting context would be over-engineering. The Strategic pattern (Bounded Context) applies; the Tactical patterns (Aggregate, Value Object, Domain Event) do not.
This is the complete blueprint. Four contexts. Five aggregates (Subscription, Plan, Invoice, Payment -- plus the conceptual notification handler). Seven read models. Nine policies. Seven Value Objects. Two ACL interfaces. Every element discovered through sticky notes on a wall, not through code analysis. The code will catch up.
Same Concept, Different Contexts
This is the single most important insight from Event Storming -- the one that most teams resist, and the one that unlocks the entire migration.
A "subscription" appears in all four contexts. In the current codebase, it is one entity: Subscription with 15 properties, 8 navigation properties, and no behavior. Every context reads the same Subscription table through the same AppDbContext. That is the root cause of Pathology 5 (leaky boundaries) and Pathology 6 (no aggregate boundaries).
After the migration, "subscription" means four different things in four different contexts.
In the Subscriptions context, a Subscription is a rich aggregate:
public class Subscription // Subscriptions Context — the full domain model
{
public SubscriptionId Id { get; private set; }
public PlanTier CurrentPlan { get; private set; }
public SubscriptionStatus Status { get; private set; }
public SubscriptionPeriod CurrentPeriod { get; private set; }
public TrialDuration? Trial { get; private set; }
public DateTime? CancelledAt { get; private set; }
public CancellationReason? CancellationReason { get; private set; }
public Result<PlanChangedEvent> ChangePlan(PlanTier newPlan, PlanChangePolicy policy)
{
// Enforces invariants. Returns Result, not exception.
// Produces domain event on success.
}
public Result<SubscriptionCancelledEvent> Cancel(CancellationReason reason)
{
if (Status == SubscriptionStatus.Cancelled)
return Result.Failure("Subscription is already cancelled.");
// ...
}
public Result<SubscriptionSuspendedEvent> Suspend()
{
if (Status != SubscriptionStatus.Active)
return Result.Failure("Only active subscriptions can be suspended.");
// ...
}
}public class Subscription // Subscriptions Context — the full domain model
{
public SubscriptionId Id { get; private set; }
public PlanTier CurrentPlan { get; private set; }
public SubscriptionStatus Status { get; private set; }
public SubscriptionPeriod CurrentPeriod { get; private set; }
public TrialDuration? Trial { get; private set; }
public DateTime? CancelledAt { get; private set; }
public CancellationReason? CancellationReason { get; private set; }
public Result<PlanChangedEvent> ChangePlan(PlanTier newPlan, PlanChangePolicy policy)
{
// Enforces invariants. Returns Result, not exception.
// Produces domain event on success.
}
public Result<SubscriptionCancelledEvent> Cancel(CancellationReason reason)
{
if (Status == SubscriptionStatus.Cancelled)
return Result.Failure("Subscription is already cancelled.");
// ...
}
public Result<SubscriptionSuspendedEvent> Suspend()
{
if (Status != SubscriptionStatus.Active)
return Result.Failure("Only active subscriptions can be suspended.");
// ...
}
}Rich behavior. Typed IDs. Private setters. Result returns. Domain events. This is the aggregate that Part IX builds.
In the Billing context, a "subscription" is an opaque reference:
public class Invoice // Billing Context — subscription is just an ID
{
public InvoiceId Id { get; private set; }
public SubscriptionId SubscriptionId { get; private set; } // Opaque reference
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 InvoiceStatus Status { get; private set; }
public IReadOnlyList<InvoiceLineItem> LineItems => _lineItems.AsReadOnly();
// Billing does NOT load the Subscription entity.
// It received plan name and price in the PlanChangedEvent payload.
// It only stores the SubscriptionId for correlation.
}public class Invoice // Billing Context — subscription is just an ID
{
public InvoiceId Id { get; private set; }
public SubscriptionId SubscriptionId { get; private set; } // Opaque reference
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 InvoiceStatus Status { get; private set; }
public IReadOnlyList<InvoiceLineItem> LineItems => _lineItems.AsReadOnly();
// Billing does NOT load the Subscription entity.
// It received plan name and price in the PlanChangedEvent payload.
// It only stores the SubscriptionId for correlation.
}The Billing context does not know what a Subscription looks like. It does not load it. It does not query it. It receives PlanChangedEvent with the plan name, old price, and new price, and that is all it needs. The SubscriptionId is kept only for correlation -- to link an invoice back to a subscription when a human needs to debug.
In the Notifications context, a "subscription" is a tiny read model:
public record NotificationRecipient( // Notifications Context — 3 fields
string CustomerName,
EmailAddress Email,
string PlanName
);public record NotificationRecipient( // Notifications Context — 3 fields
string CustomerName,
EmailAddress Email,
string PlanName
);Three fields. That is all Notifications needs to send an email. Not the 15-property Subscription entity. Not the Customer entity with PaymentMethods and BillingAddress. A name, an email, and a plan name. These three fields arrive in the event payload. No database query. No cross-context coupling.
In the Analytics context, a "subscription" is a denormalized reporting row:
public class SubscriptionAnalyticsView // Analytics Context — 8 reporting fields
{
public Guid SubscriptionId { get; set; }
public string PlanName { get; set; }
public string PlanTier { get; set; }
public decimal MonthlyPrice { get; set; }
public string Status { get; set; }
public DateTime StartDate { get; set; }
public DateTime? CancelledDate { get; set; }
public decimal MrrContribution { get; set; }
}public class SubscriptionAnalyticsView // Analytics Context — 8 reporting fields
{
public Guid SubscriptionId { get; set; }
public string PlanName { get; set; }
public string PlanTier { get; set; }
public decimal MonthlyPrice { get; set; }
public string Status { get; set; }
public DateTime StartDate { get; set; }
public DateTime? CancelledDate { get; set; }
public decimal MrrContribution { get; set; }
}Eight fields for reporting. No behavior. No invariants. No domain events. Just data, optimized for aggregation queries.
These are NOT the same object. They are four different models of the same real-world concept, each optimized for the concerns of its context. The Subscriptions context needs a rich aggregate with behavior and invariants. The Billing context needs a correlation ID. The Notifications context needs three fields for an email template. The Analytics context needs eight fields for a dashboard.
In the current codebase, all four contexts share the same Subscription entity from AppDbContext. Every team loads the same 15-property object, including properties they do not use, through navigation chains they do not need. When any team changes the entity, every other team risks breaking. When the Notifications team adds NotificationPreferences to the Customer entity (because their service queries Customer through the shared DbContext), the Billing team's migrations now include notification-specific columns.
This is the core DDD insight, stated plainly: the same real-world concept has different representations in different contexts, and forcing them into a single shared model is the root cause of coupling. The proration bug from Part II exists because three different contexts need proration for three different purposes, and someone tried to unify them instead of letting each context own its own calculation.
Entity Migration Map
The current AppDbContext has 31 entities in a flat structure. Event Storming tells us where each one belongs.
| Current Entity | Target Context | Notes |
|---|---|---|
Subscription |
Subscriptions | Rich aggregate root with lifecycle behavior |
Plan |
Subscriptions | Reference data aggregate |
PlanFeature |
Subscriptions | Owned entity of Plan |
PlanTier |
Subscriptions | Value Object (currently an entity) |
IncludedQuantity |
Subscriptions | Owned entity of Plan |
OverageRate |
Subscriptions | Owned entity of Plan |
Trial |
Subscriptions | Value Object (currently an entity) |
UsageRecord |
Subscriptions | Tracks per-subscription usage (aggregated for Billing) |
Invoice |
Billing | Aggregate root |
InvoiceLineItem |
Billing | Owned entity of Invoice |
TaxLineItem |
Billing | Owned entity of Invoice |
Payment |
Billing | Separate aggregate root |
PaymentMethod |
Billing | Owned entity (or separate aggregate) |
Refund |
Billing | Owned entity of Payment |
Credit |
Billing | Value Object (currently an entity) |
DunningAttempt |
Billing | Owned entity of Payment |
TaxRateCache |
Billing | Infrastructure concern (ACL cache) |
Customer |
Split | Identity fields → Subscriptions. Billing address → Billing. Email → Notifications (via event payload). |
BillingAddress |
Billing | Value Object (currently an entity) |
Currency |
Billing (SharedKernel) | Value Object |
Discount |
Billing | Value Object (currently an entity) |
NotificationPreference |
Notifications | Owned data |
NotificationTemplate |
Notifications | Reference data |
NotificationLog |
Notifications | Delivery tracking |
WebhookEndpoint |
Notifications | Customer webhook configuration |
WebhookDeliveryLog |
Notifications | Delivery tracking |
AnalyticsSnapshot |
Analytics | Materialized view |
RevenueReport |
Analytics | Materialized view |
ChurnMetric |
Analytics | Materialized view |
AuditLog |
SharedKernel | Cross-cutting concern |
Configuration |
SharedKernel | System configuration |
Two entities split across contexts. Customer is the most significant: in the current codebase, Customer is a 12-property entity that every context loads. After migration, the Subscriptions context owns the customer's identity (name, account). Billing owns the billing address and payment methods. Notifications receives name and email in event payloads and never queries a Customer table.
The starred entities (marked with ★) become aggregate roots. Entities marked "→ VO" become Value Objects -- they have no identity of their own and are owned by their parent aggregate. This distinction is critical for EF Core mapping, which we cover in Part VIII: Extract Value Objects.
ACL Inventory
The Big Picture Event Storming identified every arrow that crosses a context boundary. Each crossing point needs an Anti-Corruption Layer -- a translation layer that converts between the upstream context's vocabulary and the downstream context's vocabulary. Let us inventory what exists today and what is missing.
Existing ACLs (Broken)
PaymentGateway (Billing <--> Stripe)
- Exists as a class. Returns
Stripe.Chargeinstead of a domain type. Stripe vocabulary has colonized the domain model (StripeCustomerId,StripeChargeId,StripeReceiptUrlon domain entities). The ACL exists structurally but not semantically. - Fix: Return
PaymentResult(domain type). Remove all Stripe-specific properties from domain entities. Map inside the ACL.
NotificationService (Notifications --> Main DB)
- Not actually an ACL. The Notifications project queries
AppDbContextdirectly, loading full entity graphs to extract 3 fields for email templates. - Fix: Notifications receives event payloads that contain the 3 fields it needs. No database query. No cross-context dependency. The "ACL" becomes an event handler.
Missing ACLs
Billing --> Subscriptions (circular calls)
BillingServicecallsSubscriptionService.Suspend()when dunning is exhausted. This creates a circular dependency. Billing depends on Subscriptions, and Subscriptions depends on Billing (for proration previews).- Fix: Billing publishes
DunningExhausted. Subscriptions' policy handler reacts by issuingSuspendSubscription. No circular dependency.
Analytics --> All Contexts (raw SQL)
- The ETL pipeline reads from the database replica using raw SQL that references internal column names. No translation layer. No version tolerance. Breaks silently when schemas change.
- Fix: Analytics subscribes to domain events and builds its own projections. The ETL is replaced by event handlers. The replica is eventually retired.
Tax API (Billing --> External)
- No ACL.
BillingServicecalls the Tax API with rawHttpClient.PostAsJsonAsync(), parses the JSON response inline, and falls back to a hardcoded 10% rate on failure. - Fix:
ITaxCalculatorinterface in the Billing domain. Implementation wraps the HTTP call, handles retries, caches results, and returns aTaxCalculationdomain type.
Five boundary crossings. One ACL exists but leaks. Four ACLs are missing entirely. This is why SubscriptionHub is a Big Ball of Mud -- the boundaries that the teams feel instinctively have no enforcement in the code. Building these ACLs is Phase 3 of the migration, detailed in Part VII: Fix & Formalize ACLs.
Value Objects from the Vocabulary
During both the Big Picture and Design-Level sessions, the room used specific vocabulary repeatedly. "Money," "billing period," "email address," "tax rate." These are concepts that have no identity -- a $49.99 is a $49.99 regardless of which invoice it appears on. They are defined by their attributes, not by a database primary key. They are Value Objects.
Event Storming surfaces them naturally. Every time someone writes "amount" on a sticky note, the facilitator asks: "Amount of what? In what currency? How do you round?" The answer is always: "It's money -- dollars and cents." That is a Value Object.
Here is the vocabulary that emerged, with ownership:
| Value Object | Context | Replaces | Key Behavior |
|---|---|---|---|
Money |
SharedKernel | decimal amount + string currency (scattered) |
Arithmetic with currency safety. Rounding rules. Cannot add USD to EUR. |
SubscriptionPeriod |
Subscriptions | DateTime startDate + DateTime endDate + manual arithmetic |
Contains, overlaps, remaining days calculation. The proration anchor. |
PlanTier |
Subscriptions | string ("Basic", "Professional", "Enterprise") |
Type-safe tier with comparison. No more if (plan.Tier == "Enterprise"). |
TrialDuration |
Subscriptions | int trialDays + DateTime.AddDays() scattered |
Duration with expiry calculation and remaining days. |
EmailAddress |
SharedKernel | string email (unvalidated) |
Validated on construction. Cannot create an invalid email. |
TaxRate |
Billing | decimal taxRate + string country (scattered) |
Rate + jurisdiction + calculation method. Replaces the flat 10% fallback. |
DunningSchedule |
Billing | Hardcoded retry intervals in BillingService |
Retry intervals, escalation thresholds, max attempts. Configurable. |
UsageQuota |
Subscriptions | int includedQuantity + manual threshold checks |
Included quantity + threshold + overage tracking. |
Money and EmailAddress go in the SharedKernel -- a small, zero-dependency library that all contexts can reference. The SharedKernel contains only Value Objects with no domain logic. It is a vocabulary, not a service layer. If it starts growing beyond Value Objects, something is wrong.
The remaining Value Objects are context-specific. SubscriptionPeriod makes no sense in the Billing context -- Billing deals with InvoicePeriod, which has different semantics (it can be partial, prorated, or adjusted). TaxRate makes no sense in the Subscriptions context -- Subscriptions does not know or care about tax. Context-specific Value Objects stay in their context.
Extracting these Value Objects is Phase 4 of the migration, and it is the safest refactoring with the highest ROI. See Part VIII: Extract Value Objects for the full treatment.
The Migration Roadmap
Let us take stock of what Event Storming produced. In one day, with all four teams in the room, we discovered:
From Big Picture:
- 4 bounded contexts with clear boundaries
- 31 events mapped to contexts
- 9 hot spots that align with the 6 pathologies from Part II
- 3 pivotal events that mark the major state transitions
- Cross-context event flows (which arrows cross which boundaries)
From Design-Level:
- 5 aggregates (Subscription, Plan, Invoice, Payment + notification handler)
- 8 commands per context
- 9 policies (reactive chains between events and commands)
- 7 read models
- 8 Value Objects
- 2 ACL interfaces (IPaymentGateway, ITaxCalculator)
- 5 boundary crossings requiring ACLs (1 broken, 4 missing)
From the entity migration map:
- 31 entities assigned to 4 contexts + SharedKernel
- 1 entity (Customer) that splits across contexts
- 5 entities that become Value Objects
This is the blueprint. Not a vague architectural vision -- a specific, enumerated plan. We know which entities go where. We know which services need to be decomposed. We know where the ACLs are missing. We know which Value Objects to extract. We know which policies to implement as event handlers.
Now we need the execution strategy. The blueprint says what the target looks like. The strategy says how to get there without stopping production, without a big-bang rewrite, without betting the company on a migration that might fail.
The priority is clear: Subscriptions context first. It has the highest change frequency, the most bugs, the most merge conflicts, and a willing team. It is also the upstream context -- Billing, Notifications, and Analytics all consume its events. Migrating Subscriptions first means the downstream contexts can start consuming clean events instead of querying the shared database.
In Part IV: The Migration Strategy, we take this blueprint and build the Strangler Fig -- six phases, each independently deployable, each delivering value. Tests first. Bounded context libraries second. ACLs third. Value Objects, Aggregates, and Domain Events fill the structure. The compiler enforces the architecture.
We have the blueprint. Now we need the strategy.