Part I: The Disease
"A Big Ball of Mud is a haphazardly structured, sprawling, sloppy, duct-tape and baling wire, spaghetti code jungle." -- Brian Foote & Joseph Yoder, Big Ball of Mud, 1997
Every senior developer has met one. You open the solution, see 47 service classes in one project, a Common folder that weighs more than the domain, and a BillingService.cs that has its own gravitational field. You do a git log --oneline | wc -l and the number is in the thousands. You search for "proration" and find three implementations, two of which are wrong, and nobody knows which one runs in production.
This is the Big Ball of Mud. Foote and Yoder named it in 1997, but the pattern is older than the name. It is the default architecture. It is what happens when you ship software successfully for years without ever formalizing the boundaries that emerged organically during those years. The Big Ball of Mud is not a failure of engineering. It is, paradoxically, a success of engineering -- a system that survived long enough to become load-bearing before anyone noticed it had no skeleton.
Nobody designs a Big Ball of Mud.
It grows. Like this:
- Month 1: A single developer ships a working product. The code is clean because there is one person, one mental model, and one feature. CRUD operations, one database, one deployment. This is fine.
- Month 6: A second developer joins. They copy the patterns from month 1. Duplication appears but feels harmless. The codebase is small enough to hold in a single person's head.
- Year 1: A third feature cross-cuts the first two. The developer puts shared logic in a
Commonproject because "we'll need this elsewhere." TheCommonproject is born. It will never die. - Year 2: A second team forms. They own "billing" but need subscription data. Instead of defining an interface, they reference the subscription project directly. Coupling arrives, and it arrives politely -- as a simple project reference.
- Year 3: Compliance requirements add tax calculation. The tax logic is woven into the billing service because "it's billing, right?" The billing service crosses 500 lines.
- Year 4: A platform team builds notifications. They need to know about subscriptions, invoices, and payments. They query the main database directly because the data is right there.
- Year 6: Four teams, 80K lines, 3 databases, circular dependencies between services, and a deploy-everything-or-deploy-nothing pipeline. The Big Ball of Mud is fully mature.
Each decision above was reasonable at the time. Nobody made a mistake. The problem is that reasonable local decisions compound into unreasonable global architecture. Every shortcut was a small debt payment deferred. After six years, the interest is killing you.
Why DDD Is a Migration Strategy
Most DDD literature presents the tactical patterns -- Value Objects, Aggregates, Entities, Domain Events -- as design tools for new systems. That framing is accurate but incomplete. In practice, most DDD adoption happens after the system exists, not before. You do not start a greenfield project and say "we need Domain-Driven Design." You start a greenfield project and say "we need to ship." DDD enters the conversation two years later, when the codebase resists every change and the team is afraid of Tuesdays because that is deploy day.
This is important: DDD is a migration strategy, not just a greenfield architecture. The tactical patterns (Value Objects, Aggregates) are scalpels for extracting clean models from the mud. The strategic patterns (Bounded Contexts, Anti-Corruption Layers, Context Maps) are surgical plans for decomposing a monolith into zones of autonomy. Together, they give you a vocabulary for talking about the decomposition, a sequence for executing it, and a set of constraints that prevent you from rebuilding the mud while you dismantle it.
This series is the migration field guide. We take a concrete system -- SubscriptionHub, described below -- and walk it from Big Ball of Mud to a DDD-structured set of bounded context libraries, one phase at a time, never stopping production, always with tests going green.
This series assumes familiarity with DDD's core concepts. For the full treatment, see Domain-Driven Design & Code Generation. Here we focus on the journey from the mess to the model.
What to Expect
The series follows a strict sequence:
- Diagnose the disease (this article)
- Name the six pathologies (Part II)
- Discover boundaries through Event Storming (Part III)
- Plan the migration with the Strangler Fig (Part IV)
- Write tests first -- characterization tests, golden master, TDD (Part V)
- Extract bounded context libraries (Part VI)
- Fix ACLs -- Anti-Corruption Layers formalized (Part VII)
- Extract Value Objects under TDD (Part VIII)
- Build Aggregates with invariants (Part IX)
- Introduce Domain Events to break circular deps (Part X)
- Arrive at the final state (Part XI)
Each phase delivers independently. You can stop after Phase 2 and still have a better system. You can stop after Phase 5 and have a meaningfully better architecture. But the full journey transforms the system from something you dread touching into something you can reason about.
Let us begin with the patient.
Meet SubscriptionHub
SubscriptionHub is a SaaS billing platform. It manages subscriptions, generates invoices, processes payments through Stripe, applies tax rates, sends notifications, and feeds an analytics warehouse. It is not a toy example. It makes money. It has been making money for six years. That is why it is a Big Ball of Mud -- because it worked, and nobody had time to stop and restructure something that was working.
Here are the numbers:
| Metric | Value |
|---|---|
| Age | 6 years |
| Lines of code | ~80,000 |
| C# projects in solution | 7 |
| Service classes | 47 |
| EF Core entities | 31 |
| Databases | 3 (primary, replica, analytics warehouse) |
| External integrations | 4 (Stripe, tax API, email provider, analytics warehouse) |
| Teams | 4 |
| Tests | 12 (6 [Ignore]d) |
| Last full green test run | 4 months ago |
| Deploy frequency | Weekly (all-or-nothing) |
| Average PR size | 400+ lines changed |
Merge conflicts per week on BillingService.cs |
3 |
Four teams work on this system:
- Subscriptions team (3 developers): plan management, subscription lifecycle, upgrades/downgrades, proration
- Billing team (3 developers): invoicing, payments, Stripe integration, dunning, multi-currency
- Platform team (2 developers): notifications, webhooks, infrastructure, deployment
- Data team (2 developers): analytics ETL, dashboards, reporting, data quality
Ten developers. One solution. One deploy pipeline. One BillingService.cs that all four teams edit.
How We Got Here: A Year-by-Year Narrative
Year 1: The Golden Age
One developer. One plan type (monthly). One price. Customers sign up, get charged monthly via Stripe, and receive a receipt by email. The entire codebase is roughly 5,000 lines. It has a single ASP.NET project with controllers, a service layer, and an EF Core DbContext. The architecture is textbook three-tier: Controller calls Service calls Repository. It is clean. It is fast. It ships.
There is nothing wrong with this codebase. It is appropriately simple for its problem size. This is important to acknowledge because the Big Ball of Mud narrative often implies that the original developers did something wrong. They did not. They built exactly the right thing for the constraints they had.
Year 2: Annual Plans and the Second Developer
Business wants annual billing with proration. The second developer joins, copies the patterns from Year 1, and adds CalculateProration() to BillingService. Annual plans introduce a new concept -- billing periods that span months -- but the data model treats everything as a flat Subscription entity with a BillingInterval enum. Good enough. A second pricing tier appears. The Plan entity gains fields. Someone adds a Common project for shared DTOs.
Lines of code: ~12,000. Still manageable. One person can read the whole codebase in a day.
Year 3: Usage-Based Billing and Tax Compliance
The product expands to usage-based billing. Customers now have three pricing models: flat monthly, flat annual, and usage-based. UsageRecord entities are born. The billing calculation logic forks: CalculateFlatBilling() and CalculateUsageBilling() coexist in BillingService. They share some logic (tax, discounts) and diverge on other logic (metering, proration). Instead of extracting a billing strategy, the developer adds if/else branches. This is faster to ship.
Tax compliance arrives. A third-party tax API is integrated directly into BillingService.CalculateInvoice(). The Stripe payment processing, tax calculation, and invoice generation are now a single 200-line method. A Billing team is formed (2 developers, growing to 3).
Lines of code: ~28,000. The BillingService crosses 500 lines. Nobody is alarmed yet.
Year 4: Notifications and the Platform Team
The business wants email notifications: welcome email, payment receipt, payment failed, subscription expiring, plan upgraded. A Platform team (2 developers) is formed. They create SubscriptionHub.Notifications as a separate project -- a reasonable decision. But the notification templates need customer names, plan details, invoice amounts, and payment status. Rather than defining an interface or a message contract, the Platform team adds a project reference to SubscriptionHub.Data and queries the main DbContext directly.
Webhooks arrive. The WebhookController needs to know about subscription events, billing events, and notification events. It references everything. The first circular dependency warning appears in the build log. Someone suppresses it.
Lines of code: ~45,000. Merge conflicts start appearing weekly.
Year 5: Analytics and the Data Team
Management wants dashboards. Monthly recurring revenue, churn rate, average revenue per user, cohort analysis. A Data team (2 developers) is formed. They create SubscriptionHub.Analytics.ETL -- an Extract-Transform-Load pipeline that reads from a database replica.
The ETL queries use raw SQL against the replica because EF Core is "too slow for analytics." The SQL references internal column names, navigation property join tables, and EF Core shadow properties. When the Subscriptions team renames a column in a migration, the ETL breaks at 3 AM on a Saturday. Nobody finds out until Monday when the dashboard shows blank.
Lines of code: ~62,000. The BillingService crosses 900 lines.
Year 6: Dunning, Multi-Currency, and the Breaking Point
Multi-currency support. Dunning workflows (automated retry of failed payments with escalating actions). Enterprise plan tiers with volume discounts. Every new feature touches at least three projects. The BillingService crosses 1,247 lines. It handles invoicing, payment processing, Stripe communication, tax calculation, dunning logic, multi-currency conversion, proration (now three implementations), discount application, and revenue recognition.
Every sprint planning session becomes a negotiation: "Which team touches BillingService this sprint?" Code reviews are 400-line diffs. The PR author spends more time resolving merge conflicts than writing code. Nobody writes tests because setting up the test environment requires SQL Server, a Stripe test account, a tax API sandbox, and a specific appsettings.json that only works on two developers' machines.
Lines of code: ~80,000. The Big Ball of Mud is fully mature.
What It Feels Like
If you have worked on a system like SubscriptionHub, you know the feeling. If you have not, here is what a typical week looks like.
Monday: "Fix the proration bug."
A customer reports incorrect proration on an annual-to-monthly downgrade. You search the codebase for "proration" and find three implementations:
BillingService.CalculateProration()-- the original, handles monthlyBillingService.CalculateAnnualProration()-- added in Year 2, handles annualInvoiceCalculator.ComputeProRataAmount()-- added in Year 5 when someone noticed the first two methods duplicated logic and tried to unify them, but only some call sites were updated
Which one runs in production for annual-to-monthly downgrades? You check the controller. The controller calls SubscriptionService.Downgrade(). SubscriptionService.Downgrade() calls BillingService.ProcessDowngrade(). BillingService.ProcessDowngrade() calls CalculateProration() for monthly and CalculateAnnualProration() for annual. But wait -- there is also a SubscriptionHub.Web.Helpers.PricingHelper that calculates proration for the preview shown to the customer before they confirm the downgrade, and it uses InvoiceCalculator.ComputeProRataAmount(). The preview and the actual charge use different formulas.
You fix CalculateAnnualProration(), but now the preview disagrees with the charge. You update PricingHelper too. You submit the PR. It touches 4 files across 3 projects. The Billing team lead approves it but asks about the InvoiceCalculator implementation. Nobody knows if anything still calls it. You search for usages -- 2 call sites, both in Year 5 analytics code. You do not touch them. They might break something.
Time spent: 3 days for a one-line formula fix.
Wednesday: "Add a field to Subscription."
Product wants a CancellationReason field on subscriptions. Simple, right? Add a property, add a migration, done. Except:
SubscriptionHub.Data-- add the property to theSubscriptionentity, write the migrationSubscriptionHub.Services-- updateSubscriptionService.Cancel()to accept the reasonSubscriptionHub.Web-- update the cancellation endpoint, add the field to the view modelSubscriptionHub.Notifications-- include the reason in the cancellation email templateSubscriptionHub.Analytics.ETL-- the ETL needs the new column in the replica (after migration runs), and the dashboard query needs updatingSubscriptionHub.Common-- theSubscriptionDtoneeds the new fieldSubscriptionHub.PaymentGateway-- if the reason is "payment_failed", the dunning workflow needs to know
Seven projects for one field. The PR is 180 lines. The analytics ETL will not pick up the new column until the next replica sync. If you forget to update the ETL SQL, the dashboard will show NULL for cancellation reasons. Nobody will notice for two weeks.
Thursday: "Write a test."
You want to test the proration fix. To run the existing test suite, you need:
- SQL Server (LocalDB or Docker)
- A Stripe test account with specific API keys in
appsettings.Test.json - A tax API sandbox account (the free tier allows 100 requests/month; the team burned through those in Week 1 and never upgraded)
- A valid
appsettings.Test.jsonthat matches the current schema (the last person to update it left the company)
You look at the test project. Twelve tests. Six are marked [Ignore] with comments like "// Broken after multi-currency migration, TODO fix." The remaining six pass, but they test trivial things -- CalculateDiscount_WithZeroPercent_ReturnsOriginalAmount. The proration logic has zero test coverage.
You could write an integration test, but you would need to set up the entire dependency tree: DbContext, Stripe client, tax API client, notification service. Mocking all of that would take longer than the fix itself. You skip the test. You add a comment: // TODO: Add test for annual-to-monthly proration. That comment will be there next year.
Friday: Sprint Planning
The product owner presents five stories. The team estimates them. Every story touches at least three projects. The senior developer asks: "Can we parallelize any of this?" The answer is no, because three of the five stories modify BillingService.cs. If two developers edit that file simultaneously, the merge conflict is guaranteed.
One story is estimated at 13 points. Not because the logic is complex, but because the developer needs to trace through 6 layers of indirection to understand the existing behavior, modify 4 projects, manually test 3 scenarios that have no automated tests, and coordinate the deploy with the Platform team because the webhook payload changes.
The team velocity is 21 points per sprint. In Year 1, it was 55.
The Boundaries Nobody Formalized
Here is the thing about SubscriptionHub that makes it interesting as a migration case study: the boundaries already exist. They are implicit. Teams know them instinctively. Nobody wrote them down, and the code does not enforce them, but every developer on the team can tell you: "Billing is over there, notifications are in that project, analytics reads from the replica."
These implicit boundaries are the raw material for DDD's Bounded Contexts. The teams already think in contexts. The code just does not.
Let us look at the four implicit boundaries and how they leak.
The Payment Gateway -- An ACL That Does Not Know It Is One
SubscriptionHub.PaymentGateway wraps Stripe. It has StripePaymentService, StripeCustomerService, and StripeWebhookHandler. If you squint, this is an Anti-Corruption Layer -- it translates between Stripe's domain model (Charges, PaymentIntents, Customers) and SubscriptionHub's domain model (Payments, PaymentMethods, Customers).
But it leaks. The StripePaymentService.ProcessPayment() method returns a Stripe.Charge object -- Stripe's DTO, not a domain type. The Billing team consumes this directly:
// In BillingService.cs — Stripe types leak into the domain
public async Task<Invoice> ProcessInvoicePayment(Invoice invoice)
{
var charge = await _stripePaymentService.ProcessPayment(
invoice.Customer.StripeCustomerId, // Stripe-specific ID on domain entity
invoice.TotalAmount,
invoice.Currency);
// Direct dependency on Stripe.Charge — a third-party DTO
invoice.StripeChargeId = charge.Id;
invoice.PaymentStatus = charge.Status == "succeeded"
? PaymentStatus.Paid
: PaymentStatus.Failed;
invoice.StripeReceiptUrl = charge.ReceiptUrl;
if (charge.Status == "succeeded")
{
// Tax reporting needs the Stripe fee breakdown
invoice.ProcessingFee = charge.BalanceTransaction?.Fee / 100m ?? 0;
}
await _dbContext.SaveChangesAsync();
return invoice;
}// In BillingService.cs — Stripe types leak into the domain
public async Task<Invoice> ProcessInvoicePayment(Invoice invoice)
{
var charge = await _stripePaymentService.ProcessPayment(
invoice.Customer.StripeCustomerId, // Stripe-specific ID on domain entity
invoice.TotalAmount,
invoice.Currency);
// Direct dependency on Stripe.Charge — a third-party DTO
invoice.StripeChargeId = charge.Id;
invoice.PaymentStatus = charge.Status == "succeeded"
? PaymentStatus.Paid
: PaymentStatus.Failed;
invoice.StripeReceiptUrl = charge.ReceiptUrl;
if (charge.Status == "succeeded")
{
// Tax reporting needs the Stripe fee breakdown
invoice.ProcessingFee = charge.BalanceTransaction?.Fee / 100m ?? 0;
}
await _dbContext.SaveChangesAsync();
return invoice;
}The Customer entity has a StripeCustomerId property. The Invoice entity has StripeChargeId and StripeReceiptUrl. Stripe's vocabulary has colonized the domain model. If SubscriptionHub ever switches to a different payment provider (or adds a second one), these properties, plus every method that reads them, must change. The ACL is supposed to prevent exactly this. It exists in project structure but not in behavior.
Notifications -- A Context That Queries the Wrong Database
SubscriptionHub.Notifications has its own service classes, its own retry logic, and its own configuration. It looks like an independent context. But it has a project reference to SubscriptionHub.Data and queries the main DbContext to build notification payloads:
// In NotificationService.cs — querying the main DbContext
public async Task SendPaymentFailedNotification(int invoiceId)
{
// This service lives in the Notifications project
// but reaches directly into the main database
var invoice = await _dbContext.Invoices
.Include(i => i.Customer)
.Include(i => i.Subscription)
.ThenInclude(s => s.Plan)
.Include(i => i.LineItems)
.FirstOrDefaultAsync(i => i.Id == invoiceId);
var model = new PaymentFailedEmailModel
{
CustomerName = invoice.Customer.FullName,
PlanName = invoice.Subscription.Plan.DisplayName,
Amount = invoice.TotalAmount,
Currency = invoice.Currency,
NextRetryDate = invoice.NextRetryDate, // Dunning-specific field
RetryCount = invoice.RetryCount, // Dunning-specific field
PaymentMethodLast4 = invoice.Customer.PaymentMethods
.First(pm => pm.IsDefault).Last4Digits
};
await _emailSender.SendTemplatedEmail("payment-failed", model);
}// In NotificationService.cs — querying the main DbContext
public async Task SendPaymentFailedNotification(int invoiceId)
{
// This service lives in the Notifications project
// but reaches directly into the main database
var invoice = await _dbContext.Invoices
.Include(i => i.Customer)
.Include(i => i.Subscription)
.ThenInclude(s => s.Plan)
.Include(i => i.LineItems)
.FirstOrDefaultAsync(i => i.Id == invoiceId);
var model = new PaymentFailedEmailModel
{
CustomerName = invoice.Customer.FullName,
PlanName = invoice.Subscription.Plan.DisplayName,
Amount = invoice.TotalAmount,
Currency = invoice.Currency,
NextRetryDate = invoice.NextRetryDate, // Dunning-specific field
RetryCount = invoice.RetryCount, // Dunning-specific field
PaymentMethodLast4 = invoice.Customer.PaymentMethods
.First(pm => pm.IsDefault).Last4Digits
};
await _emailSender.SendTemplatedEmail("payment-failed", model);
}The NotificationService knows about invoices, customers, plans, payment methods, dunning retry dates, and subscription tiers. It has intimate knowledge of the billing domain's internal structure. If the Billing team renames NextRetryDate to DunningNextAttempt, the Notifications project breaks. If the Subscriptions team changes the Plan entity's DisplayName to Name, the notification email renders wrong.
This is not a bounded context. It is a window into someone else's context with no glass.
Analytics ETL -- The Invisible Coupling
The Data team's ETL pipeline reads from a database replica using raw SQL:
-- In analytics-etl/extract-mrr.sql
SELECT
s.Id AS SubscriptionId,
s.CustomerId,
c.Email,
c.CompanyName,
p.MonthlyPrice, -- What if this column is renamed?
s.BillingInterval,
s.StartDate,
s.Status,
s.CancelledAt, -- Typo in original migration, never fixed
s.TrialEndsAt,
s.CurrentPeriodEnd,
pm.StripePaymentMethodId -- Stripe ID leaking into analytics
FROM Subscriptions s
JOIN Customers c ON s.CustomerId = c.Id
JOIN Plans p ON s.PlanId = p.Id
LEFT JOIN PaymentMethods pm ON pm.CustomerId = c.Id AND pm.IsDefault = 1
WHERE s.Status IN ('Active', 'Trialing', 'PastDue')-- In analytics-etl/extract-mrr.sql
SELECT
s.Id AS SubscriptionId,
s.CustomerId,
c.Email,
c.CompanyName,
p.MonthlyPrice, -- What if this column is renamed?
s.BillingInterval,
s.StartDate,
s.Status,
s.CancelledAt, -- Typo in original migration, never fixed
s.TrialEndsAt,
s.CurrentPeriodEnd,
pm.StripePaymentMethodId -- Stripe ID leaking into analytics
FROM Subscriptions s
JOIN Customers c ON s.CustomerId = c.Id
JOIN Plans p ON s.PlanId = p.Id
LEFT JOIN PaymentMethods pm ON pm.CustomerId = c.Id AND pm.IsDefault = 1
WHERE s.Status IN ('Active', 'Trialing', 'PastDue')This SQL depends on:
- Column names (which are EF Core conventions, not domain contracts)
- Table names (which are entity class names)
- Enum string values (which are
ToString()of C# enums) - Navigation property join tables
- A typo in a migration (
CancelledAtwith double 'l' -- British spelling in an otherwise American English codebase)
The EF Core model is the analytics team's API contract, and nobody agreed to that. When the Subscriptions team adds a Status enum value or changes a column type, the ETL breaks silently -- it still runs, but produces incorrect numbers. The dashboard shows the wrong MRR. Finance uses the wrong MRR in board reporting. Someone notices three weeks later.
Common -- The Coupling Magnet
SubscriptionHub.Common started as a utility project. Shared DTOs, extension methods, constants. Then it grew:
SubscriptionHub.Common/
├── Constants/
│ ├── StripeConstants.cs ← Stripe-specific
│ ├── TaxConstants.cs ← Billing-specific
│ └── NotificationTemplates.cs ← Notification-specific
├── DTOs/
│ ├── SubscriptionDto.cs ← 34 properties, used by 5 projects
│ ├── InvoiceDto.cs
│ ├── CustomerDto.cs
│ ├── UsageReportDto.cs
│ └── AnalyticsSummaryDto.cs
├── Extensions/
│ ├── DateTimeExtensions.cs
│ ├── StringExtensions.cs
│ ├── DecimalExtensions.cs ← Money formatting, 3 overloads
│ └── EnumExtensions.cs
├── Enums/
│ ├── PaymentStatus.cs
│ ├── SubscriptionStatus.cs
│ ├── BillingInterval.cs
│ ├── PlanTier.cs
│ └── NotificationType.cs
├── Exceptions/
│ ├── PaymentFailedException.cs
│ ├── SubscriptionNotFoundException.cs
│ └── ... 11 more
├── Helpers/
│ ├── PricingHelper.cs ← The third proration implementation
│ ├── CurrencyHelper.cs
│ └── TaxHelper.cs
└── Validators/
├── EmailValidator.cs
└── CurrencyCodeValidator.csSubscriptionHub.Common/
├── Constants/
│ ├── StripeConstants.cs ← Stripe-specific
│ ├── TaxConstants.cs ← Billing-specific
│ └── NotificationTemplates.cs ← Notification-specific
├── DTOs/
│ ├── SubscriptionDto.cs ← 34 properties, used by 5 projects
│ ├── InvoiceDto.cs
│ ├── CustomerDto.cs
│ ├── UsageReportDto.cs
│ └── AnalyticsSummaryDto.cs
├── Extensions/
│ ├── DateTimeExtensions.cs
│ ├── StringExtensions.cs
│ ├── DecimalExtensions.cs ← Money formatting, 3 overloads
│ └── EnumExtensions.cs
├── Enums/
│ ├── PaymentStatus.cs
│ ├── SubscriptionStatus.cs
│ ├── BillingInterval.cs
│ ├── PlanTier.cs
│ └── NotificationType.cs
├── Exceptions/
│ ├── PaymentFailedException.cs
│ ├── SubscriptionNotFoundException.cs
│ └── ... 11 more
├── Helpers/
│ ├── PricingHelper.cs ← The third proration implementation
│ ├── CurrencyHelper.cs
│ └── TaxHelper.cs
└── Validators/
├── EmailValidator.cs
└── CurrencyCodeValidator.csForty-seven classes. Every project references Common. Common references nothing -- it is the bottom of the dependency graph. That sounds clean, but it is the opposite: because every team puts their shared types here, every team is coupled to every other team through Common. When the Billing team adds a field to InvoiceDto, the Notifications team's build picks it up. When the Data team adds AnalyticsSummaryDto, the Subscriptions team's project now transitively depends on analytics concepts.
Common is not a shared kernel. A Shared Kernel in DDD is a deliberate, small, versioned set of types that two contexts agree to share. Common is an accidental, large, unversioned dumping ground for anything that two developers needed in two places.
The Solution Structure
Here is the full solution tree, annotated with line counts, ownership, and commentary:
SubscriptionHub.sln
│
├── SubscriptionHub.Web/ ← ASP.NET Core, all 4 teams edit
│ ├── Controllers/
│ │ ├── SubscriptionController.cs (847 lines, 23 actions)
│ │ ├── InvoiceController.cs (612 lines, 14 actions)
│ │ ├── UsageController.cs (394 lines, 8 actions)
│ │ ├── WebhookController.cs (289 lines, handles Stripe + internal)
│ │ ├── CustomerController.cs (356 lines)
│ │ ├── PlanController.cs (201 lines)
│ │ └── AnalyticsController.cs (445 lines, Data team)
│ ├── ViewModels/ (38 view model classes)
│ ├── Middleware/ (auth, error handling, logging)
│ └── Startup.cs (312 lines, all DI registrations)
│
├── SubscriptionHub.Services/ ← "Business logic" — 1 project, 47 classes
│ ├── BillingService.cs (1,247 lines — the God Service)
│ ├── SubscriptionService.cs (963 lines)
│ ├── InvoiceService.cs (587 lines)
│ ├── CustomerService.cs (412 lines)
│ ├── UsageService.cs (389 lines)
│ ├── PlanService.cs (234 lines)
│ ├── DunningService.cs (445 lines)
│ ├── PricingCalculator.cs (312 lines)
│ ├── InvoiceCalculator.cs (278 lines, the third proration impl)
│ ├── TaxCalculationService.cs (356 lines)
│ ├── RevenueRecognitionService.cs (289 lines)
│ ├── CurrencyConversionService.cs (167 lines)
│ └── ... 35 more service classes
│
├── SubscriptionHub.Data/ ← EF Core, 31 entities, 1 DbContext
│ ├── AppDbContext.cs (489 lines, all entities registered)
│ ├── Entities/ (31 entity classes)
│ │ ├── Subscription.cs (67 lines, all public setters)
│ │ ├── Customer.cs (54 lines)
│ │ ├── Plan.cs (38 lines)
│ │ ├── Invoice.cs (82 lines, StripeChargeId on entity)
│ │ ├── InvoiceLineItem.cs (29 lines)
│ │ ├── PaymentMethod.cs (31 lines, StripePaymentMethodId)
│ │ ├── UsageRecord.cs (24 lines)
│ │ └── ... 24 more
│ ├── Migrations/ (147 migration files)
│ └── Configurations/ (EF Fluent API, mostly empty)
│
├── SubscriptionHub.PaymentGateway/ ← Stripe wrapper (leaks Stripe types)
│ ├── StripePaymentService.cs (345 lines)
│ ├── StripeCustomerService.cs (198 lines)
│ ├── StripeWebhookHandler.cs (267 lines)
│ └── StripeConfig.cs
│
├── SubscriptionHub.Notifications/ ← Platform team, coupled to main DB
│ ├── NotificationService.cs (423 lines)
│ ├── EmailSender.cs (189 lines)
│ ├── WebhookDispatcher.cs (234 lines)
│ ├── Templates/ (12 email templates)
│ └── RetryPolicy.cs (87 lines)
│
├── SubscriptionHub.Analytics.ETL/ ← Data team, raw SQL on replica
│ ├── MrrExtractor.cs (312 lines)
│ ├── ChurnCalculator.cs (267 lines)
│ ├── CohortBuilder.cs (345 lines)
│ ├── WarehouseLoader.cs (198 lines)
│ └── Queries/ (14 .sql files)
│
├── SubscriptionHub.Common/ ← 47 classes, coupling magnet
│ ├── DTOs/ (12 DTO classes)
│ ├── Enums/ (5 enum types)
│ ├── Constants/ (3 constant classes)
│ ├── Extensions/ (4 extension classes)
│ ├── Exceptions/ (13 exception classes)
│ ├── Helpers/ (3 helper classes)
│ └── Validators/ (2 validator classes)
│
└── SubscriptionHub.Tests/ ← 12 tests, 6 [Ignore]d
├── BillingServiceTests.cs (3 tests, 2 [Ignore]d)
├── SubscriptionServiceTests.cs (4 tests, 2 [Ignore]d)
├── PricingCalculatorTests.cs (3 tests, 1 [Ignore]d)
├── InvoiceCalculatorTests.cs (2 tests, 1 [Ignore]d)
└── TestHelpers/
└── DatabaseFixture.cs (234 lines, SQL Server required)SubscriptionHub.sln
│
├── SubscriptionHub.Web/ ← ASP.NET Core, all 4 teams edit
│ ├── Controllers/
│ │ ├── SubscriptionController.cs (847 lines, 23 actions)
│ │ ├── InvoiceController.cs (612 lines, 14 actions)
│ │ ├── UsageController.cs (394 lines, 8 actions)
│ │ ├── WebhookController.cs (289 lines, handles Stripe + internal)
│ │ ├── CustomerController.cs (356 lines)
│ │ ├── PlanController.cs (201 lines)
│ │ └── AnalyticsController.cs (445 lines, Data team)
│ ├── ViewModels/ (38 view model classes)
│ ├── Middleware/ (auth, error handling, logging)
│ └── Startup.cs (312 lines, all DI registrations)
│
├── SubscriptionHub.Services/ ← "Business logic" — 1 project, 47 classes
│ ├── BillingService.cs (1,247 lines — the God Service)
│ ├── SubscriptionService.cs (963 lines)
│ ├── InvoiceService.cs (587 lines)
│ ├── CustomerService.cs (412 lines)
│ ├── UsageService.cs (389 lines)
│ ├── PlanService.cs (234 lines)
│ ├── DunningService.cs (445 lines)
│ ├── PricingCalculator.cs (312 lines)
│ ├── InvoiceCalculator.cs (278 lines, the third proration impl)
│ ├── TaxCalculationService.cs (356 lines)
│ ├── RevenueRecognitionService.cs (289 lines)
│ ├── CurrencyConversionService.cs (167 lines)
│ └── ... 35 more service classes
│
├── SubscriptionHub.Data/ ← EF Core, 31 entities, 1 DbContext
│ ├── AppDbContext.cs (489 lines, all entities registered)
│ ├── Entities/ (31 entity classes)
│ │ ├── Subscription.cs (67 lines, all public setters)
│ │ ├── Customer.cs (54 lines)
│ │ ├── Plan.cs (38 lines)
│ │ ├── Invoice.cs (82 lines, StripeChargeId on entity)
│ │ ├── InvoiceLineItem.cs (29 lines)
│ │ ├── PaymentMethod.cs (31 lines, StripePaymentMethodId)
│ │ ├── UsageRecord.cs (24 lines)
│ │ └── ... 24 more
│ ├── Migrations/ (147 migration files)
│ └── Configurations/ (EF Fluent API, mostly empty)
│
├── SubscriptionHub.PaymentGateway/ ← Stripe wrapper (leaks Stripe types)
│ ├── StripePaymentService.cs (345 lines)
│ ├── StripeCustomerService.cs (198 lines)
│ ├── StripeWebhookHandler.cs (267 lines)
│ └── StripeConfig.cs
│
├── SubscriptionHub.Notifications/ ← Platform team, coupled to main DB
│ ├── NotificationService.cs (423 lines)
│ ├── EmailSender.cs (189 lines)
│ ├── WebhookDispatcher.cs (234 lines)
│ ├── Templates/ (12 email templates)
│ └── RetryPolicy.cs (87 lines)
│
├── SubscriptionHub.Analytics.ETL/ ← Data team, raw SQL on replica
│ ├── MrrExtractor.cs (312 lines)
│ ├── ChurnCalculator.cs (267 lines)
│ ├── CohortBuilder.cs (345 lines)
│ ├── WarehouseLoader.cs (198 lines)
│ └── Queries/ (14 .sql files)
│
├── SubscriptionHub.Common/ ← 47 classes, coupling magnet
│ ├── DTOs/ (12 DTO classes)
│ ├── Enums/ (5 enum types)
│ ├── Constants/ (3 constant classes)
│ ├── Extensions/ (4 extension classes)
│ ├── Exceptions/ (13 exception classes)
│ ├── Helpers/ (3 helper classes)
│ └── Validators/ (2 validator classes)
│
└── SubscriptionHub.Tests/ ← 12 tests, 6 [Ignore]d
├── BillingServiceTests.cs (3 tests, 2 [Ignore]d)
├── SubscriptionServiceTests.cs (4 tests, 2 [Ignore]d)
├── PricingCalculatorTests.cs (3 tests, 1 [Ignore]d)
├── InvoiceCalculatorTests.cs (2 tests, 1 [Ignore]d)
└── TestHelpers/
└── DatabaseFixture.cs (234 lines, SQL Server required)Who "owns" each project vs. who actually commits:
| Project | Nominal Owner | Actually Commits |
|---|---|---|
Web |
Platform team | All 4 teams |
Services |
Subscriptions + Billing | All 4 teams |
Data |
Subscriptions team | All 4 teams (migrations) |
PaymentGateway |
Billing team | Billing + Platform |
Notifications |
Platform team | Platform + Billing |
Analytics.ETL |
Data team | Data team (but breaks when others change schema) |
Common |
Nobody | All 4 teams |
Tests |
Nobody | Nobody (last meaningful commit: 4 months ago) |
The ownership table tells the story. The code does not respect the team boundaries because it was never structured to. The Services project is a shared commons where all logic lives, and every team has to edit it. Common has no owner because everyone owns it. Tests has no owner because nobody writes them.
The Dependency Graph
The following diagram shows the project-level dependencies. Every arrow is a project reference in a .csproj file. The colors indicate team ownership. The red arrows indicate dependencies that cross team boundaries in ways that create coupling pain.
Notice the shape. Common sits at the bottom, referenced by everything. Services sits in the middle, concentrating all logic. Data is referenced by both Services and Notifications -- two different teams querying the same DbContext. The dashed lines show runtime dependencies that the project graph does not capture: BillingService calls NotificationService through DI, creating a circular dependency that the compiler allows but the architecture should not.
Implicit Boundaries vs. Actual Coupling
The teams think they own distinct areas. The code tells a different story. The following diagram overlays team mental models (dotted boundaries) with actual code dependencies (solid arrows):
Every solid arrow crosses a dotted boundary. That is the problem. The teams have mental boundaries, but the code has no enforcement. Any developer can reach into any other team's domain objects through navigation properties, direct DbContext queries, or shared DTOs in Common.
The Entity Relationships
The 31-entity DbContext has no concept of aggregate boundaries. Every entity is a top-level DbSet. Navigation properties create a fully connected graph where any entity can reach any other entity through Include() chains:
Notice the Stripe-specific fields scattered across entities: StripeCustomerId on Customer, StripePaymentMethodId on PaymentMethod, StripeChargeId and StripeReceiptUrl on Invoice. The payment gateway's vocabulary lives inside the core domain entities. There are no aggregate boundaries -- Customer, Subscription, Invoice, and PaymentMethod are all top-level DbSet<T> entries, and any code path can load any combination through navigation properties.
The Invoice entity is especially telling. It contains:
- Billing concepts:
TotalAmount,TaxAmount,DueDate - Payment processing concepts:
StripeChargeId,StripeReceiptUrl,ProcessingFee - Dunning concepts:
RetryCount,NextRetryDate - Subscription concepts:
SubscriptionId(direct FK) - Customer concepts:
CustomerId(direct FK)
Five different concerns on one entity. In a DDD model, these would live in different contexts with different lifecycle rules. Here they coexist, and any code that loads an Invoice gets all of them.
The Commit Heatmap
Which files do which teams touch? A git log --format='%an' -- <file> | sort -u across the codebase reveals the overlap:
BillingService.cs and SubscriptionDto.cs are touched by all four teams. They are the highest-contention files in the repository. Every merge conflict, every unexpected regression, every "who changed this and why" investigation starts in one of these two files.
MrrExtractor.cs is the only file touched by a single team. That is not because the Data team is disciplined -- it is because nobody else understands the analytics SQL. When the Data team member who wrote it goes on vacation, the MRR dashboard cannot be updated.
The pattern is clear: the files with the most team overlap are the files with the most bugs, the most merge conflicts, and the slowest velocity. This is not coincidence. It is Conway's Law in action -- or rather, Conway's Law being violated. The communication structure of the organization (4 teams) does not match the code structure (1 monolithic Services project). The result is friction at every boundary that the code refuses to acknowledge.
The Diagnosis
SubscriptionHub is a textbook Big Ball of Mud. It exhibits every classic symptom:
- No aggregate boundaries: 31 entities in a flat
DbContext, all publicly settable, no invariant enforcement - God Services:
BillingService(1,247 lines) handles invoicing, payments, tax, dunning, proration, multi-currency, and revenue recognition - Implicit boundaries: teams know their areas, code does not enforce them
- Leaky ACLs: the Stripe wrapper returns Stripe types, notifications query the main database, analytics reads raw SQL
- The Common coupling magnet: 47 classes that every project depends on, owned by nobody
- No tests: 12 tests, 6 ignored, last green run 4 months ago, prohibitive test setup cost
And yet -- it works. It processes payments. It generates invoices. It sends notifications. It makes money. That is the insidious thing about the Big Ball of Mud: it works until it does not, and by the time it does not, you cannot fix it without rewriting it.
Except you can. DDD gives you a vocabulary for naming what is wrong (the six pathologies in Part II), a technique for discovering what is hiding inside (Event Storming in Part III), and a sequence of surgical operations for extracting it (Parts IV through X).
The disease is diagnosed. In Part II, we name the six specific pathologies and show their symptoms in code. No treatment yet -- just a thorough examination. You have to understand the disease before you can cure it.
Summary
| What We Covered | Key Takeaway |
|---|---|
| Big Ball of Mud definition | Not a design -- an emergent outcome of reasonable local decisions |
| DDD as migration strategy | Tactical + strategic patterns are surgical tools for legacy decomposition |
| SubscriptionHub introduction | 6-year-old SaaS, 4 teams, 80K lines, 47 services, 12 tests |
| Year-by-year growth | Each year added complexity; no year added structure |
| Developer experience | 3-day fix for a one-line change, 7 projects for one field, untestable |
| Implicit boundaries | Teams think in contexts; code does not enforce them |
| Leaky ACLs | Stripe types in domain, notifications query main DB, ETL reads raw SQL |
| Common project | 47-class coupling magnet, owned by nobody, referenced by everyone |
| Dependency graph | Circular deps, every team commits to shared files |
| Commit heatmap | Highest-contention files = most bugs, most conflicts, slowest velocity |
Next: Part II: The Six Pathologies -- diagnosis before treatment. We name the six specific diseases inside SubscriptionHub and show their symptoms in code.