Part II: The Six Pathologies
Before prescribing treatment, you need a diagnosis. You need to name the diseases, point at the lesions, and agree on what is broken before anyone will accept that the patient needs surgery.
This part shows six concrete pathologies in SubscriptionHub's codebase. Each gets the full treatment: real code, real consequences, and the real human cost. Who gets paged at 2am. What breaks when you deploy on Friday. How debugging goes when the logic lives in three places and none of them agree.
No solutions yet. That comes in later parts. For now, the job is to feel the pain -- to look at the code and think, "yes, I've seen this." Because you have. Every legacy codebase grows the same tumors.
The pathologies are:
- The God Service -- one class that does everything
- Anemic Domain Models -- entities that are data bags
- Scattered Business Rules -- the same logic in three places, three different answers
- Infrastructure Coupling -- business logic welded to databases, HTTP clients, and SMTP servers
- Leaky Boundaries and Broken ACLs -- every team reaches into every other team's data
- No Aggregate Boundaries -- 31 entities in a flat DbContext, navigation properties to the heat death of the universe
Let's start the autopsy.
Pathology 1: The God Service
Meet BillingService. It started as a simple class that created invoices. Six years and four teams later, it is 2,400 lines of C# that handles billing, proration, tax calculation, notification dispatch, usage metering, payment retries, and -- for reasons nobody remembers -- PDF generation.
Here is ProcessMonthlyBilling(). This is one method. Read it slowly.
public class BillingService
{
private readonly AppDbContext _context;
private readonly HttpClient _httpClient;
private readonly SmtpClient _smtpClient;
private readonly IConfiguration _configuration;
private readonly ILogger<BillingService> _logger;
private readonly SubscriptionService _subscriptionService;
private readonly TaxService _taxService;
private readonly IWebHostEnvironment _env;
// ... constructor with 8 parameters ...
public async Task<MonthlyBillingResultDto> ProcessMonthlyBilling(
int customerId, DateTime billingDate)
{
// Step 1: Load everything
var customer = await _context.Customers
.Include(c => c.Subscriptions)
.ThenInclude(s => s.Plan)
.Include(c => c.Subscriptions)
.ThenInclude(s => s.UsageRecords)
.Include(c => c.PaymentMethods)
.Include(c => c.BillingAddress)
.FirstOrDefaultAsync(c => c.Id == customerId);
if (customer == null)
throw new Exception($"Customer {customerId} not found");
var invoice = new Invoice
{
CustomerId = customerId,
InvoiceDate = billingDate,
Status = "Draft",
Currency = customer.BillingAddress?.Currency ?? "USD",
LineItems = new List<InvoiceLineItem>()
};
// Step 2: Calculate charges for each subscription
foreach (var subscription in customer.Subscriptions
.Where(s => s.Status == "Active" || s.Status == "PastDue"))
{
// Base plan charge
var baseCharge = subscription.Plan.MonthlyPrice;
// Proration if plan changed mid-cycle
if (subscription.PlanChangedDate.HasValue
&& subscription.PlanChangedDate.Value.Month == billingDate.Month)
{
var cycleStart = new DateTime(billingDate.Year, billingDate.Month, 1);
var cycleEnd = cycleStart.AddMonths(1);
var changeDate = subscription.PlanChangedDate.Value;
var totalDays = (cycleEnd - cycleStart).Days;
var remainingDays = (cycleEnd - changeDate).Days;
baseCharge = baseCharge
* (decimal)remainingDays / (decimal)totalDays;
}
invoice.LineItems.Add(new InvoiceLineItem
{
Description = $"Plan: {subscription.Plan.Name}",
Amount = baseCharge,
Type = "PlanCharge"
});
// Usage-based charges (metered billing)
var usageRecords = subscription.UsageRecords
.Where(u => u.RecordedAt.Month == billingDate.Month
&& u.RecordedAt.Year == billingDate.Year);
foreach (var usageGroup in usageRecords.GroupBy(u => u.MetricName))
{
var totalUsage = usageGroup.Sum(u => u.Quantity);
var includedQuantity = subscription.Plan.IncludedQuantities
.FirstOrDefault(q => q.MetricName == usageGroup.Key)
?.Quantity ?? 0;
if (totalUsage > includedQuantity)
{
var overageQuantity = totalUsage - includedQuantity;
var unitPrice = subscription.Plan.OverageRates
.FirstOrDefault(r => r.MetricName == usageGroup.Key)
?.PricePerUnit ?? 0m;
invoice.LineItems.Add(new InvoiceLineItem
{
Description = $"Usage: {usageGroup.Key} "
+ $"({overageQuantity} over {includedQuantity} included)",
Amount = overageQuantity * unitPrice,
Type = "UsageCharge"
});
}
}
}
// Step 3: Tax calculation -- call external API directly
var subtotal = invoice.LineItems.Sum(li => li.Amount);
var taxApiUrl = _configuration["TaxApi:BaseUrl"];
var taxRequest = new
{
amount = subtotal,
country = customer.BillingAddress?.Country ?? "US",
state = customer.BillingAddress?.State,
postalCode = customer.BillingAddress?.PostalCode
};
HttpResponseMessage taxResponse;
try
{
taxResponse = await _httpClient.PostAsJsonAsync(
$"{taxApiUrl}/calculate", taxRequest);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Tax API failed for customer {Id}", customerId);
// Fallback: use cached tax rate from last successful call
var cachedRate = await _context.TaxRateCache
.Where(t => t.Country == (customer.BillingAddress?.Country ?? "US"))
.OrderByDescending(t => t.CachedAt)
.FirstOrDefaultAsync();
taxResponse = null; // will use fallback below
}
decimal taxAmount;
if (taxResponse?.IsSuccessStatusCode == true)
{
var taxResult = await taxResponse.Content
.ReadFromJsonAsync<TaxCalculationResult>();
taxAmount = taxResult!.TaxAmount;
invoice.TaxRate = taxResult.EffectiveRate;
}
else
{
// Fallback: flat 10%
taxAmount = subtotal * 0.10m;
invoice.TaxRate = 0.10m;
invoice.TaxCalculationNote = "Estimated -- tax API unavailable";
}
invoice.TaxAmount = taxAmount;
invoice.TotalAmount = subtotal + taxAmount;
invoice.Status = "Finalized";
// Step 4: Save
_context.Invoices.Add(invoice);
await _context.SaveChangesAsync();
// Step 5: Send notification
try
{
var emailBody = $"Dear {customer.Name},\n\n"
+ $"Your invoice #{invoice.Id} for {invoice.TotalAmount:C} "
+ $"is ready.\n\nThank you.";
var mailMessage = new MailMessage(
_configuration["Email:From"]!,
customer.Email,
$"Invoice #{invoice.Id} Ready",
emailBody);
await _smtpClient.SendMailAsync(mailMessage);
}
catch (SmtpException ex)
{
_logger.LogWarning(ex,
"Failed to send invoice email for customer {Id}", customerId);
// Don't fail the whole billing run for an email
}
// Step 6: Log for audit
var logPath = Path.Combine(
_env.ContentRootPath, "logs", "billing",
$"{billingDate:yyyy-MM}", $"customer-{customerId}.log");
Directory.CreateDirectory(Path.GetDirectoryName(logPath)!);
await File.AppendAllTextAsync(logPath,
$"[{DateTime.UtcNow:O}] Invoice {invoice.Id}: "
+ $"{invoice.TotalAmount:C} ({invoice.LineItems.Count} items)\n");
// Step 7: Return DTO
return new MonthlyBillingResultDto
{
InvoiceId = invoice.Id,
CustomerId = customerId,
Subtotal = subtotal,
TaxAmount = taxAmount,
Total = invoice.TotalAmount,
LineItemCount = invoice.LineItems.Count,
Status = invoice.Status,
SentNotification = true // optimistic
};
}
}public class BillingService
{
private readonly AppDbContext _context;
private readonly HttpClient _httpClient;
private readonly SmtpClient _smtpClient;
private readonly IConfiguration _configuration;
private readonly ILogger<BillingService> _logger;
private readonly SubscriptionService _subscriptionService;
private readonly TaxService _taxService;
private readonly IWebHostEnvironment _env;
// ... constructor with 8 parameters ...
public async Task<MonthlyBillingResultDto> ProcessMonthlyBilling(
int customerId, DateTime billingDate)
{
// Step 1: Load everything
var customer = await _context.Customers
.Include(c => c.Subscriptions)
.ThenInclude(s => s.Plan)
.Include(c => c.Subscriptions)
.ThenInclude(s => s.UsageRecords)
.Include(c => c.PaymentMethods)
.Include(c => c.BillingAddress)
.FirstOrDefaultAsync(c => c.Id == customerId);
if (customer == null)
throw new Exception($"Customer {customerId} not found");
var invoice = new Invoice
{
CustomerId = customerId,
InvoiceDate = billingDate,
Status = "Draft",
Currency = customer.BillingAddress?.Currency ?? "USD",
LineItems = new List<InvoiceLineItem>()
};
// Step 2: Calculate charges for each subscription
foreach (var subscription in customer.Subscriptions
.Where(s => s.Status == "Active" || s.Status == "PastDue"))
{
// Base plan charge
var baseCharge = subscription.Plan.MonthlyPrice;
// Proration if plan changed mid-cycle
if (subscription.PlanChangedDate.HasValue
&& subscription.PlanChangedDate.Value.Month == billingDate.Month)
{
var cycleStart = new DateTime(billingDate.Year, billingDate.Month, 1);
var cycleEnd = cycleStart.AddMonths(1);
var changeDate = subscription.PlanChangedDate.Value;
var totalDays = (cycleEnd - cycleStart).Days;
var remainingDays = (cycleEnd - changeDate).Days;
baseCharge = baseCharge
* (decimal)remainingDays / (decimal)totalDays;
}
invoice.LineItems.Add(new InvoiceLineItem
{
Description = $"Plan: {subscription.Plan.Name}",
Amount = baseCharge,
Type = "PlanCharge"
});
// Usage-based charges (metered billing)
var usageRecords = subscription.UsageRecords
.Where(u => u.RecordedAt.Month == billingDate.Month
&& u.RecordedAt.Year == billingDate.Year);
foreach (var usageGroup in usageRecords.GroupBy(u => u.MetricName))
{
var totalUsage = usageGroup.Sum(u => u.Quantity);
var includedQuantity = subscription.Plan.IncludedQuantities
.FirstOrDefault(q => q.MetricName == usageGroup.Key)
?.Quantity ?? 0;
if (totalUsage > includedQuantity)
{
var overageQuantity = totalUsage - includedQuantity;
var unitPrice = subscription.Plan.OverageRates
.FirstOrDefault(r => r.MetricName == usageGroup.Key)
?.PricePerUnit ?? 0m;
invoice.LineItems.Add(new InvoiceLineItem
{
Description = $"Usage: {usageGroup.Key} "
+ $"({overageQuantity} over {includedQuantity} included)",
Amount = overageQuantity * unitPrice,
Type = "UsageCharge"
});
}
}
}
// Step 3: Tax calculation -- call external API directly
var subtotal = invoice.LineItems.Sum(li => li.Amount);
var taxApiUrl = _configuration["TaxApi:BaseUrl"];
var taxRequest = new
{
amount = subtotal,
country = customer.BillingAddress?.Country ?? "US",
state = customer.BillingAddress?.State,
postalCode = customer.BillingAddress?.PostalCode
};
HttpResponseMessage taxResponse;
try
{
taxResponse = await _httpClient.PostAsJsonAsync(
$"{taxApiUrl}/calculate", taxRequest);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Tax API failed for customer {Id}", customerId);
// Fallback: use cached tax rate from last successful call
var cachedRate = await _context.TaxRateCache
.Where(t => t.Country == (customer.BillingAddress?.Country ?? "US"))
.OrderByDescending(t => t.CachedAt)
.FirstOrDefaultAsync();
taxResponse = null; // will use fallback below
}
decimal taxAmount;
if (taxResponse?.IsSuccessStatusCode == true)
{
var taxResult = await taxResponse.Content
.ReadFromJsonAsync<TaxCalculationResult>();
taxAmount = taxResult!.TaxAmount;
invoice.TaxRate = taxResult.EffectiveRate;
}
else
{
// Fallback: flat 10%
taxAmount = subtotal * 0.10m;
invoice.TaxRate = 0.10m;
invoice.TaxCalculationNote = "Estimated -- tax API unavailable";
}
invoice.TaxAmount = taxAmount;
invoice.TotalAmount = subtotal + taxAmount;
invoice.Status = "Finalized";
// Step 4: Save
_context.Invoices.Add(invoice);
await _context.SaveChangesAsync();
// Step 5: Send notification
try
{
var emailBody = $"Dear {customer.Name},\n\n"
+ $"Your invoice #{invoice.Id} for {invoice.TotalAmount:C} "
+ $"is ready.\n\nThank you.";
var mailMessage = new MailMessage(
_configuration["Email:From"]!,
customer.Email,
$"Invoice #{invoice.Id} Ready",
emailBody);
await _smtpClient.SendMailAsync(mailMessage);
}
catch (SmtpException ex)
{
_logger.LogWarning(ex,
"Failed to send invoice email for customer {Id}", customerId);
// Don't fail the whole billing run for an email
}
// Step 6: Log for audit
var logPath = Path.Combine(
_env.ContentRootPath, "logs", "billing",
$"{billingDate:yyyy-MM}", $"customer-{customerId}.log");
Directory.CreateDirectory(Path.GetDirectoryName(logPath)!);
await File.AppendAllTextAsync(logPath,
$"[{DateTime.UtcNow:O}] Invoice {invoice.Id}: "
+ $"{invoice.TotalAmount:C} ({invoice.LineItems.Count} items)\n");
// Step 7: Return DTO
return new MonthlyBillingResultDto
{
InvoiceId = invoice.Id,
CustomerId = customerId,
Subtotal = subtotal,
TaxAmount = taxAmount,
Total = invoice.TotalAmount,
LineItemCount = invoice.LineItems.Count,
Status = invoice.Status,
SentNotification = true // optimistic
};
}
}Count the concerns in that single method:
- Data access -- EF Core queries with eager loading
- Business logic -- proration calculation, usage metering, overage pricing
- External API integration -- HTTP call to the tax service
- Fallback logic -- cached tax rates when the API is down
- Persistence -- saving the invoice
- Email dispatch -- SMTP directly
- File I/O -- audit logging to disk
- DTO mapping -- assembling the return value
Eight concerns. One method. One file. Two teams (Billing and Subscriptions) edit it regularly. The git log for BillingService.cs shows 47 merge conflicts in the last 12 months. It is the most conflicted file in the repository.
When this method fails -- and it fails weekly, because the tax API has a 99.5% SLA that translates to about 3.6 hours of downtime per month -- the on-call engineer from the Billing team gets paged. They open BillingService.cs, scroll through 2,400 lines, and try to figure out whether the failure was a tax API timeout, a malformed usage record, a null billing address, or an SMTP connection reset. The stack trace says BillingService.ProcessMonthlyBilling, line 187. That's it. Everything is line 187 because everything is one method.
Pathology 2: Anemic Domain Models
Here is the Subscription entity. This is the full class.
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>();
}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>();
}Twenty-two properties, all with public setters. Status is a string -- not an enum, not a Value Object, a string. Valid values include "Active", "Cancelled", "PastDue", "Paused", "Trial", "Expired", and -- in one record from 2022 that still appears in analytics -- "Actve".
The entity has no methods. No behavior. No validation. It cannot protect its own invariants because it doesn't know what its invariants are. It is a data bag. You could replace it with Dictionary<string, object> and nothing would change.
All the business logic that should live on the entity lives in SubscriptionService:
public class SubscriptionService
{
private readonly AppDbContext _context;
private readonly ILogger<SubscriptionService> _logger;
public SubscriptionService(AppDbContext context,
ILogger<SubscriptionService> logger)
{
_context = context;
_logger = logger;
}
public async Task<ChangePlanResultDto> ChangePlan(
int subscriptionId, int newPlanId)
{
var subscription = await _context.Subscriptions
.Include(s => s.Plan)
.FirstOrDefaultAsync(s => s.Id == subscriptionId);
if (subscription == null)
throw new Exception($"Subscription {subscriptionId} not found");
if (subscription.Status != "Active" && subscription.Status != "Trial")
throw new Exception(
$"Cannot change plan: subscription is {subscription.Status}");
if (subscription.PlanId == newPlanId)
throw new Exception("Already on this plan");
var newPlan = await _context.Plans.FindAsync(newPlanId)
?? throw new Exception($"Plan {newPlanId} not found");
// Calculate proration credit
var today = DateTime.UtcNow.Date;
var cycleEnd = subscription.StartDate.AddMonths(
((today.Year - subscription.StartDate.Year) * 12
+ today.Month - subscription.StartDate.Month) + 1);
var remainingDays = (cycleEnd - today).Days;
var totalDays = (cycleEnd - cycleEnd.AddMonths(-1)).Days;
var prorationFactor = (decimal)remainingDays / totalDays;
var credit = subscription.CurrentPrice * prorationFactor;
var charge = newPlan.MonthlyPrice * prorationFactor;
// Mutate the entity from outside
subscription.PreviousPlanId = subscription.PlanId;
subscription.PreviousPlanPrice = subscription.CurrentPrice;
subscription.PlanId = newPlanId;
subscription.Plan = newPlan;
subscription.CurrentPrice = newPlan.MonthlyPrice;
subscription.PlanChangedDate = today;
subscription.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
_logger.LogInformation(
"Subscription {Id} changed from plan {Old} to {New}",
subscriptionId, subscription.PreviousPlanId, newPlanId);
return new ChangePlanResultDto
{
SubscriptionId = subscriptionId,
OldPlanId = subscription.PreviousPlanId!.Value,
NewPlanId = newPlanId,
ProrationCredit = credit,
ProrationCharge = charge,
NetAdjustment = charge - credit
};
}
}public class SubscriptionService
{
private readonly AppDbContext _context;
private readonly ILogger<SubscriptionService> _logger;
public SubscriptionService(AppDbContext context,
ILogger<SubscriptionService> logger)
{
_context = context;
_logger = logger;
}
public async Task<ChangePlanResultDto> ChangePlan(
int subscriptionId, int newPlanId)
{
var subscription = await _context.Subscriptions
.Include(s => s.Plan)
.FirstOrDefaultAsync(s => s.Id == subscriptionId);
if (subscription == null)
throw new Exception($"Subscription {subscriptionId} not found");
if (subscription.Status != "Active" && subscription.Status != "Trial")
throw new Exception(
$"Cannot change plan: subscription is {subscription.Status}");
if (subscription.PlanId == newPlanId)
throw new Exception("Already on this plan");
var newPlan = await _context.Plans.FindAsync(newPlanId)
?? throw new Exception($"Plan {newPlanId} not found");
// Calculate proration credit
var today = DateTime.UtcNow.Date;
var cycleEnd = subscription.StartDate.AddMonths(
((today.Year - subscription.StartDate.Year) * 12
+ today.Month - subscription.StartDate.Month) + 1);
var remainingDays = (cycleEnd - today).Days;
var totalDays = (cycleEnd - cycleEnd.AddMonths(-1)).Days;
var prorationFactor = (decimal)remainingDays / totalDays;
var credit = subscription.CurrentPrice * prorationFactor;
var charge = newPlan.MonthlyPrice * prorationFactor;
// Mutate the entity from outside
subscription.PreviousPlanId = subscription.PlanId;
subscription.PreviousPlanPrice = subscription.CurrentPrice;
subscription.PlanId = newPlanId;
subscription.Plan = newPlan;
subscription.CurrentPrice = newPlan.MonthlyPrice;
subscription.PlanChangedDate = today;
subscription.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
_logger.LogInformation(
"Subscription {Id} changed from plan {Old} to {New}",
subscriptionId, subscription.PreviousPlanId, newPlanId);
return new ChangePlanResultDto
{
SubscriptionId = subscriptionId,
OldPlanId = subscription.PreviousPlanId!.Value,
NewPlanId = newPlanId,
ProrationCredit = credit,
ProrationCharge = charge,
NetAdjustment = charge - credit
};
}
}The service validates the entity's state (Status != "Active"), calculates business values (proration), and then reaches inside the entity and sets its fields one by one. The entity is a passive participant in its own lifecycle. It has no opinion about whether a plan change is valid, no concept of what "Active" means, and no ability to prevent an invalid state transition.
This is the Anemic Domain Model anti-pattern in its natural habitat. Martin Fowler named it in 2003. Twenty-three years later, it is still the default in most .NET codebases. The entity looks like a domain model -- it has domain names, domain relationships, domain data -- but it is not a domain model. It is a row in a database with C# syntax.
For a deeper treatment of this anti-pattern and how DDD addresses it, see Domain-Driven Design & Code Generation.
Pathology 3: Scattered Business Rules
This one is subtle and dangerous. The proration calculation -- the business logic that determines how much a customer pays when they change plans mid-cycle -- exists in three places. Each implementation is slightly different. None of them are documented. Nobody knows which one is correct.
Implementation 1: BillingService.ProcessMonthlyBilling()
From the God Service above. This is what runs during the monthly billing batch:
// BillingService.cs, line 74
var cycleStart = new DateTime(billingDate.Year, billingDate.Month, 1);
var cycleEnd = cycleStart.AddMonths(1);
var changeDate = subscription.PlanChangedDate.Value;
var totalDays = (cycleEnd - cycleStart).Days;
var remainingDays = (cycleEnd - changeDate).Days;
baseCharge = baseCharge * (decimal)remainingDays / (decimal)totalDays;
// Result: truncated decimal division (decimal)int / (decimal)int// BillingService.cs, line 74
var cycleStart = new DateTime(billingDate.Year, billingDate.Month, 1);
var cycleEnd = cycleStart.AddMonths(1);
var changeDate = subscription.PlanChangedDate.Value;
var totalDays = (cycleEnd - cycleStart).Days;
var remainingDays = (cycleEnd - changeDate).Days;
baseCharge = baseCharge * (decimal)remainingDays / (decimal)totalDays;
// Result: truncated decimal division (decimal)int / (decimal)intThis casts to decimal before dividing. The result has up to 28 significant digits. No rounding. A plan change on March 15th of a 31-day month produces a factor of 0.5161290322580645161290322580....
Implementation 2: SubscriptionController.PreviewPlanChange()
This is what the customer sees in the UI when they click "Preview Cost":
// SubscriptionController.cs, line 312
[HttpGet("subscriptions/{id}/preview-plan-change")]
public async Task<IActionResult> PreviewPlanChange(int id, int newPlanId)
{
var subscription = await _context.Subscriptions
.Include(s => s.Plan)
.FirstOrDefaultAsync(s => s.Id == id);
var newPlan = await _context.Plans.FindAsync(newPlanId);
var today = DateTime.UtcNow.Date;
var cycleStart = new DateTime(today.Year, today.Month,
subscription!.StartDate.Day);
if (cycleStart > today) cycleStart = cycleStart.AddMonths(-1);
var cycleEnd = cycleStart.AddMonths(1);
var totalDays = (cycleEnd - cycleStart).Days;
var usedDays = (today - cycleStart).Days;
var remainingDays = totalDays - usedDays;
var credit = Math.Round(
subscription.CurrentPrice * remainingDays / totalDays, 2);
var charge = Math.Round(
newPlan!.MonthlyPrice * remainingDays / totalDays, 2);
return Ok(new
{
credit,
charge,
net = charge - credit,
effectiveDate = today
});
}// SubscriptionController.cs, line 312
[HttpGet("subscriptions/{id}/preview-plan-change")]
public async Task<IActionResult> PreviewPlanChange(int id, int newPlanId)
{
var subscription = await _context.Subscriptions
.Include(s => s.Plan)
.FirstOrDefaultAsync(s => s.Id == id);
var newPlan = await _context.Plans.FindAsync(newPlanId);
var today = DateTime.UtcNow.Date;
var cycleStart = new DateTime(today.Year, today.Month,
subscription!.StartDate.Day);
if (cycleStart > today) cycleStart = cycleStart.AddMonths(-1);
var cycleEnd = cycleStart.AddMonths(1);
var totalDays = (cycleEnd - cycleStart).Days;
var usedDays = (today - cycleStart).Days;
var remainingDays = totalDays - usedDays;
var credit = Math.Round(
subscription.CurrentPrice * remainingDays / totalDays, 2);
var charge = Math.Round(
newPlan!.MonthlyPrice * remainingDays / totalDays, 2);
return Ok(new
{
credit,
charge,
net = charge - credit,
effectiveDate = today
});
}Spot the differences. This version anchors the cycle to the subscription's StartDate.Day, not the first of the month. It uses Math.Round(..., 2) with banker's rounding. And it calculates usedDays then subtracts, instead of computing remainingDays directly. For a plan change on March 15th with a start date of March 5th: the cycle runs March 5 - April 5 (31 days), used days = 10, remaining = 21. Factor: Math.Round(21m / 31m, 2) = 0.68. The billing batch would compute (31 - 15) / 31 = 0.5161.... The customer sees one number in the preview; the invoice shows a different number.
Implementation 3: sp_CalculateProration stored procedure
The Finance team wrote this for their reconciliation reports. It runs nightly against the production database:
-- sp_CalculateProration.sql
CREATE PROCEDURE sp_CalculateProration
@SubscriptionId INT,
@AsOfDate DATE
AS
BEGIN
DECLARE @CycleStart DATE, @CycleEnd DATE, @ChangeDate DATE
DECLARE @TotalDays INT, @RemainingDays INT
DECLARE @Factor FLOAT
SELECT @ChangeDate = PlanChangedDate,
@CycleStart = DATEFROMPARTS(YEAR(@AsOfDate), MONTH(@AsOfDate), 1),
@CycleEnd = DATEADD(MONTH, 1, @CycleStart)
FROM Subscriptions
WHERE Id = @SubscriptionId
SET @TotalDays = DATEDIFF(DAY, @CycleStart, @CycleEnd)
SET @RemainingDays = DATEDIFF(DAY, @ChangeDate, @CycleEnd)
-- FLOAT division: IEEE 754 floating-point arithmetic
SET @Factor = CAST(@RemainingDays AS FLOAT) / CAST(@TotalDays AS FLOAT)
SELECT @Factor AS ProrationFactor,
@RemainingDays AS RemainingDays,
@TotalDays AS TotalDays
END-- sp_CalculateProration.sql
CREATE PROCEDURE sp_CalculateProration
@SubscriptionId INT,
@AsOfDate DATE
AS
BEGIN
DECLARE @CycleStart DATE, @CycleEnd DATE, @ChangeDate DATE
DECLARE @TotalDays INT, @RemainingDays INT
DECLARE @Factor FLOAT
SELECT @ChangeDate = PlanChangedDate,
@CycleStart = DATEFROMPARTS(YEAR(@AsOfDate), MONTH(@AsOfDate), 1),
@CycleEnd = DATEADD(MONTH, 1, @CycleStart)
FROM Subscriptions
WHERE Id = @SubscriptionId
SET @TotalDays = DATEDIFF(DAY, @CycleStart, @CycleEnd)
SET @RemainingDays = DATEDIFF(DAY, @ChangeDate, @CycleEnd)
-- FLOAT division: IEEE 754 floating-point arithmetic
SET @Factor = CAST(@RemainingDays AS FLOAT) / CAST(@TotalDays AS FLOAT)
SELECT @Factor AS ProrationFactor,
@RemainingDays AS RemainingDays,
@TotalDays AS TotalDays
ENDFLOAT. IEEE 754 double-precision floating-point arithmetic. For the same March 15th scenario: 16.0 / 31.0 = 0.516129032258065 -- which looks similar to Implementation 1 but differs at the 15th decimal place. When Finance multiplies this factor by a dollar amount and rounds to cents, they occasionally get a different penny than the billing batch.
Three implementations. Three different answers. Here is what happens when they disagree:
Implementation 2 disagrees with the other two because it uses a different cycle anchor (start date vs first of month) AND a different number of remaining days. Implementations 1 and 3 usually agree but diverge when FLOAT precision causes a different rounding at the penny level. This happens about once per 200 invoices -- roughly 15 times per month.
When Product asks "how is proration calculated?", the honest answer is: "it depends which code path runs."
When a customer files a support ticket saying "the preview said $67.32 but I was charged $51.10", the support engineer escalates to the Billing team. The Billing engineer reads BillingService.cs. The Support engineer reads the controller. Neither sees a bug. Both are correct -- locally. The system as a whole is wrong.
The Finance team discovers the discrepancy during reconciliation, files a JIRA ticket, and it sits in the backlog for three sprints because nobody can explain which implementation is "the right one." The product spec says "prorate based on remaining days in the billing cycle." All three implementations claim to do exactly that.
Pathology 4: Infrastructure Coupling
Let's look at how BillingService is constructed:
public class BillingService
{
private readonly AppDbContext _context;
private readonly HttpClient _httpClient;
private readonly SmtpClient _smtpClient;
private readonly IConfiguration _configuration;
private readonly ILogger<BillingService> _logger;
private readonly SubscriptionService _subscriptionService;
private readonly TaxService _taxService;
private readonly IWebHostEnvironment _env;
public BillingService(
AppDbContext context,
HttpClient httpClient,
SmtpClient smtpClient,
IConfiguration configuration,
ILogger<BillingService> logger,
SubscriptionService subscriptionService,
TaxService taxService,
IWebHostEnvironment env)
{
_context = context;
_httpClient = httpClient;
_smtpClient = smtpClient;
_configuration = configuration;
_logger = logger;
_subscriptionService = subscriptionService;
_taxService = taxService;
_env = env;
}
}public class BillingService
{
private readonly AppDbContext _context;
private readonly HttpClient _httpClient;
private readonly SmtpClient _smtpClient;
private readonly IConfiguration _configuration;
private readonly ILogger<BillingService> _logger;
private readonly SubscriptionService _subscriptionService;
private readonly TaxService _taxService;
private readonly IWebHostEnvironment _env;
public BillingService(
AppDbContext context,
HttpClient httpClient,
SmtpClient smtpClient,
IConfiguration configuration,
ILogger<BillingService> logger,
SubscriptionService subscriptionService,
TaxService taxService,
IWebHostEnvironment env)
{
_context = context;
_httpClient = httpClient;
_smtpClient = smtpClient;
_configuration = configuration;
_logger = logger;
_subscriptionService = subscriptionService;
_taxService = taxService;
_env = env;
}
}Eight constructor parameters. Four of them are infrastructure: AppDbContext, HttpClient, SmtpClient, IWebHostEnvironment. One is a configuration bag (IConfiguration) that the service reaches into for tax API URLs, email sender addresses, and file paths. The business logic -- proration, usage metering, tax calculation -- is entangled with the infrastructure it happens to use today.
Inside the methods, infrastructure is not abstracted. It is used directly:
// Inline HTTP call to third-party API -- no interface, no abstraction
var taxResponse = await _httpClient.PostAsJsonAsync(
$"{_configuration["TaxApi:BaseUrl"]}/calculate", taxRequest);
// Raw file I/O for audit logging
await File.AppendAllTextAsync(logPath,
$"[{DateTime.UtcNow:O}] Invoice {invoice.Id}: ...\n");
// Direct SMTP -- constructor-injected concrete SmtpClient
await _smtpClient.SendMailAsync(mailMessage);
// Sometimes it gets worse -- raw ADO.NET alongside EF Core
using var connection = new SqlConnection(
_configuration.GetConnectionString("Default"));
await connection.OpenAsync();
using var cmd = new SqlCommand(
"SELECT COUNT(*) FROM Invoices WHERE CustomerId = @id "
+ "AND Status = 'Overdue' AND DueDate < GETUTCDATE()", connection);
cmd.Parameters.AddWithValue("@id", customerId);
var overdueCount = (int)(await cmd.ExecuteScalarAsync())!;// Inline HTTP call to third-party API -- no interface, no abstraction
var taxResponse = await _httpClient.PostAsJsonAsync(
$"{_configuration["TaxApi:BaseUrl"]}/calculate", taxRequest);
// Raw file I/O for audit logging
await File.AppendAllTextAsync(logPath,
$"[{DateTime.UtcNow:O}] Invoice {invoice.Id}: ...\n");
// Direct SMTP -- constructor-injected concrete SmtpClient
await _smtpClient.SendMailAsync(mailMessage);
// Sometimes it gets worse -- raw ADO.NET alongside EF Core
using var connection = new SqlConnection(
_configuration.GetConnectionString("Default"));
await connection.OpenAsync();
using var cmd = new SqlCommand(
"SELECT COUNT(*) FROM Invoices WHERE CustomerId = @id "
+ "AND Status = 'Overdue' AND DueDate < GETUTCDATE()", connection);
cmd.Parameters.AddWithValue("@id", customerId);
var overdueCount = (int)(await cmd.ExecuteScalarAsync())!;Raw SQL next to EF Core queries in the same class. SqlCommand and AppDbContext side by side. Two different data access strategies, two different connection pools, two different transaction scopes.
Now look at the test file. This is BillingServiceTests.cs in its entirety:
[TestClass]
public class BillingServiceTests
{
// NOTE: Requires local SQL Server with SubscriptionHub_Test database
// Run scripts/seed-test-data.sql before running tests
// Tax API must be reachable (use staging endpoint)
[TestMethod]
public async Task ProcessMonthlyBilling_ActiveSubscription_CreatesInvoice()
{
// Arrange
var context = new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>()
.UseSqlServer("Server=localhost;Database=SubscriptionHub_Test;...")
.Options);
var httpClient = new HttpClient();
var smtpClient = new SmtpClient("localhost", 25);
var config = new ConfigurationBuilder()
.AddJsonFile("appsettings.Test.json")
.Build();
// ... 20 more lines of setup ...
var service = new BillingService(
context, httpClient, smtpClient, config,
NullLogger<BillingService>.Instance,
new SubscriptionService(context, NullLogger<SubscriptionService>.Instance),
new TaxService(httpClient, config),
Mock.Of<IWebHostEnvironment>());
// Act
var result = await service.ProcessMonthlyBilling(1, DateTime.UtcNow);
// Assert
Assert.IsNotNull(result);
Assert.IsTrue(result.Total > 0);
}
[TestMethod]
[Ignore] // TODO: fix after DB migration -- column PlanChangedDate renamed
public async Task ProcessMonthlyBilling_WithProration_CalculatesCorrectly()
{
// ...
}
[TestMethod]
[Ignore] // Flaky: depends on tax API staging availability
public async Task ProcessMonthlyBilling_TaxApiDown_UsesFallback()
{
// ...
}
[TestMethod]
[Ignore] // Takes 45 seconds -- needs local SMTP server
public async Task ProcessMonthlyBilling_SendsNotificationEmail()
{
// ...
}
[TestMethod]
[Ignore] // Broken since usage metering was added in Sprint 47
public async Task ProcessMonthlyBilling_NoActiveSubscriptions_ReturnsEmpty()
{
// ...
}
[TestMethod]
[Ignore] // Needs appsettings.Test.json with valid Stripe keys
public async Task ProcessMonthlyBilling_MultipleSubscriptions_AggregatesTotal()
{
// ...
}
[TestMethod]
public async Task ChangePlan_ValidTransition_UpdatesSubscription()
{
// This test actually passes but only because it hits the real DB
// and the test data happens to be in the right state
// ...
}
// 5 more tests omitted -- all [Ignore]d
}[TestClass]
public class BillingServiceTests
{
// NOTE: Requires local SQL Server with SubscriptionHub_Test database
// Run scripts/seed-test-data.sql before running tests
// Tax API must be reachable (use staging endpoint)
[TestMethod]
public async Task ProcessMonthlyBilling_ActiveSubscription_CreatesInvoice()
{
// Arrange
var context = new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>()
.UseSqlServer("Server=localhost;Database=SubscriptionHub_Test;...")
.Options);
var httpClient = new HttpClient();
var smtpClient = new SmtpClient("localhost", 25);
var config = new ConfigurationBuilder()
.AddJsonFile("appsettings.Test.json")
.Build();
// ... 20 more lines of setup ...
var service = new BillingService(
context, httpClient, smtpClient, config,
NullLogger<BillingService>.Instance,
new SubscriptionService(context, NullLogger<SubscriptionService>.Instance),
new TaxService(httpClient, config),
Mock.Of<IWebHostEnvironment>());
// Act
var result = await service.ProcessMonthlyBilling(1, DateTime.UtcNow);
// Assert
Assert.IsNotNull(result);
Assert.IsTrue(result.Total > 0);
}
[TestMethod]
[Ignore] // TODO: fix after DB migration -- column PlanChangedDate renamed
public async Task ProcessMonthlyBilling_WithProration_CalculatesCorrectly()
{
// ...
}
[TestMethod]
[Ignore] // Flaky: depends on tax API staging availability
public async Task ProcessMonthlyBilling_TaxApiDown_UsesFallback()
{
// ...
}
[TestMethod]
[Ignore] // Takes 45 seconds -- needs local SMTP server
public async Task ProcessMonthlyBilling_SendsNotificationEmail()
{
// ...
}
[TestMethod]
[Ignore] // Broken since usage metering was added in Sprint 47
public async Task ProcessMonthlyBilling_NoActiveSubscriptions_ReturnsEmpty()
{
// ...
}
[TestMethod]
[Ignore] // Needs appsettings.Test.json with valid Stripe keys
public async Task ProcessMonthlyBilling_MultipleSubscriptions_AggregatesTotal()
{
// ...
}
[TestMethod]
public async Task ChangePlan_ValidTransition_UpdatesSubscription()
{
// This test actually passes but only because it hits the real DB
// and the test data happens to be in the right state
// ...
}
// 5 more tests omitted -- all [Ignore]d
}Twelve tests. Six with [Ignore]. The reasons are a museum of infrastructure coupling:
- "fix after DB migration" -- the test is coupled to the schema
- "depends on tax API staging availability" -- the test calls a real HTTP endpoint
- "takes 45 seconds -- needs local SMTP server" -- the test sends a real email
- "broken since usage metering was added" -- the test is coupled to unrelated features
- "needs appsettings.Test.json with valid Stripe keys" -- the test requires production secrets
To run the one test that passes, you need: a running SQL Server instance with the test database seeded, internet access to the tax API staging endpoint, and the right entries in appsettings.Test.json. So in practice, nobody runs the tests. The CI pipeline has a step that runs them, but it has been commented out since Sprint 34 with the note # TODO: re-enable when test infrastructure is fixed.
The business logic is correct -- proration works, usage metering works, tax calculation works. But it is impossible to verify because the business logic is welded to the infrastructure. You cannot test proration without booting SQL Server. You cannot test tax fallback without a flaky HTTP connection. You cannot test anything without everything.
Pathology 5: Leaky Boundaries and Broken ACLs
This is the pathology that separates a messy codebase from a Big Ball of Mud. Individual classes can be messy. Individual methods can be too long. But when every team's code reaches into every other team's internals -- when there are no boundaries at all -- you have a mud ball.
SubscriptionHub has four teams: Billing, Subscriptions, Notifications, and Analytics. Each team has an implicit boundary around "their" domain. But the code does not respect those boundaries. Let's look at four specific violations.
The PaymentGateway Leak
The Billing team wrapped Stripe behind a PaymentGateway class. Good intention. But the wrapper doesn't translate:
public class PaymentGateway
{
private readonly StripeClient _stripeClient;
public PaymentGateway(StripeClient stripeClient)
{
_stripeClient = stripeClient;
}
public async Task<Stripe.Charge> CreateCharge(
string stripeCustomerId, decimal amount, string currency)
{
var options = new ChargeCreateOptions
{
Amount = (long)(amount * 100),
Currency = currency.ToLower(),
Customer = stripeCustomerId
};
var service = new ChargeService(_stripeClient);
return await service.CreateAsync(options);
}
public async Task<Stripe.Refund> RefundCharge(string chargeId, decimal amount)
{
var options = new RefundCreateOptions
{
Charge = chargeId,
Amount = (long)(amount * 100)
};
var service = new RefundService(_stripeClient);
return await service.CreateAsync(options);
}
}public class PaymentGateway
{
private readonly StripeClient _stripeClient;
public PaymentGateway(StripeClient stripeClient)
{
_stripeClient = stripeClient;
}
public async Task<Stripe.Charge> CreateCharge(
string stripeCustomerId, decimal amount, string currency)
{
var options = new ChargeCreateOptions
{
Amount = (long)(amount * 100),
Currency = currency.ToLower(),
Customer = stripeCustomerId
};
var service = new ChargeService(_stripeClient);
return await service.CreateAsync(options);
}
public async Task<Stripe.Refund> RefundCharge(string chargeId, decimal amount)
{
var options = new RefundCreateOptions
{
Charge = chargeId,
Amount = (long)(amount * 100)
};
var service = new RefundService(_stripeClient);
return await service.CreateAsync(options);
}
}The return type is Stripe.Charge. Not a domain type -- the Stripe SDK type. Every caller now depends on the Stripe NuGet package. And they use it:
// In BillingService.cs -- Billing team
var charge = await _paymentGateway.CreateCharge(
customer.StripeCustomerId, invoice.TotalAmount, invoice.Currency);
invoice.StripeChargeId = charge.Id;
invoice.PaymentStatus = charge.Status;
invoice.BalanceTransactionId = charge.BalanceTransactionId;
invoice.PaymentMethodDetails = charge.PaymentMethodDetails?.Card?.Brand;
// In SubscriptionController.cs -- Subscriptions team
var charge = await _paymentGateway.CreateCharge(
customer.StripeCustomerId, setupFee, "usd");
if (charge.Status == "succeeded")
{
subscription.Status = "Active";
subscription.StripeChargeId = charge.Id;
}
else if (charge.Status == "pending")
{
subscription.Status = "PendingPayment";
}// In BillingService.cs -- Billing team
var charge = await _paymentGateway.CreateCharge(
customer.StripeCustomerId, invoice.TotalAmount, invoice.Currency);
invoice.StripeChargeId = charge.Id;
invoice.PaymentStatus = charge.Status;
invoice.BalanceTransactionId = charge.BalanceTransactionId;
invoice.PaymentMethodDetails = charge.PaymentMethodDetails?.Card?.Brand;
// In SubscriptionController.cs -- Subscriptions team
var charge = await _paymentGateway.CreateCharge(
customer.StripeCustomerId, setupFee, "usd");
if (charge.Status == "succeeded")
{
subscription.Status = "Active";
subscription.StripeChargeId = charge.Id;
}
else if (charge.Status == "pending")
{
subscription.Status = "PendingPayment";
}Both teams reach into Stripe.Charge to read .Id, .Status, .BalanceTransactionId, .PaymentMethodDetails. When Stripe releases a new SDK version that changes the Charge model -- and they do, regularly -- both teams break. When the company decides to add PayPal as a second payment provider, the Stripe.Charge return type is embedded in 23 call sites across 4 projects. The "gateway" gates nothing.
The NotificationService Reach-Around
The Notifications team needs to send invoice emails. To do that, they query the main database directly:
public class NotificationService
{
private readonly AppDbContext _context;
private readonly SmtpClient _smtpClient;
private readonly ITemplateEngine _templateEngine;
public async Task SendInvoiceNotification(int invoiceId)
{
var invoice = await _context.Invoices
.Include(i => i.Customer)
.ThenInclude(c => c.NotificationPreferences)
.Include(i => i.LineItems)
.Include(i => i.Subscription)
.ThenInclude(s => s.Plan)
.FirstOrDefaultAsync(i => i.Id == invoiceId);
if (invoice == null) return;
// Check notification preferences
if (!invoice.Customer.NotificationPreferences
.Any(p => p.Channel == "Email" && p.Category == "Billing"
&& p.IsEnabled))
return;
var model = new InvoiceEmailModel
{
CustomerName = invoice.Customer.Name,
InvoiceNumber = invoice.Id.ToString("D8"),
Amount = invoice.TotalAmount,
Currency = invoice.Currency,
PlanName = invoice.Subscription.Plan.Name,
LineItems = invoice.LineItems.Select(li => new LineItemModel
{
Description = li.Description,
Amount = li.Amount
}).ToList(),
DueDate = invoice.InvoiceDate.AddDays(30)
};
// ...send email...
}
}public class NotificationService
{
private readonly AppDbContext _context;
private readonly SmtpClient _smtpClient;
private readonly ITemplateEngine _templateEngine;
public async Task SendInvoiceNotification(int invoiceId)
{
var invoice = await _context.Invoices
.Include(i => i.Customer)
.ThenInclude(c => c.NotificationPreferences)
.Include(i => i.LineItems)
.Include(i => i.Subscription)
.ThenInclude(s => s.Plan)
.FirstOrDefaultAsync(i => i.Id == invoiceId);
if (invoice == null) return;
// Check notification preferences
if (!invoice.Customer.NotificationPreferences
.Any(p => p.Channel == "Email" && p.Category == "Billing"
&& p.IsEnabled))
return;
var model = new InvoiceEmailModel
{
CustomerName = invoice.Customer.Name,
InvoiceNumber = invoice.Id.ToString("D8"),
Amount = invoice.TotalAmount,
Currency = invoice.Currency,
PlanName = invoice.Subscription.Plan.Name,
LineItems = invoice.LineItems.Select(li => new LineItemModel
{
Description = li.Description,
Amount = li.Amount
}).ToList(),
DueDate = invoice.InvoiceDate.AddDays(30)
};
// ...send email...
}
}The Notifications team queries Invoices, Customers, NotificationPreferences, LineItems, Subscriptions, and Plans. Six tables owned by two other teams. When the Billing team renames InvoiceLineItem.Description to InvoiceLineItem.Label -- a harmless column rename from their perspective -- the Notification service breaks. When the Subscriptions team adds a required column to Plans, the notification queries start returning incomplete data.
The InvoiceEmailModel is a hand-copied subset of Invoice plus Subscription plus Customer. It drifted out of sync two sprints ago when the Billing team added a TaxBreakdown property to Invoice. The notification email still shows the old flat tax amount. Nobody noticed because the Notifications team doesn't read the Billing team's pull requests.
The Analytics ETL
The Analytics team runs a nightly ETL job. They wrote raw SQL because "EF Core was too slow for aggregation queries":
public class AnalyticsEtlJob : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
using var connection = new SqlConnection(_connectionString);
await connection.OpenAsync(ct);
// Revenue by plan -- hardcoded column names
var revenueQuery = @"
SELECT
p.Name AS PlanName,
p.MonthlyPrice AS PlanPrice,
COUNT(s.Id) AS ActiveSubscriptions,
SUM(s.CurrentPrice) AS MonthlyRevenue,
s.Currency
FROM Subscriptions s
INNER JOIN Plans p ON s.PlanId = p.Id
WHERE s.Status = 'Active'
GROUP BY p.Name, p.MonthlyPrice, s.Currency";
using var cmd = new SqlCommand(revenueQuery, connection);
using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
var planName = reader.GetString(0);
var planPrice = reader.GetDecimal(1);
var count = reader.GetInt32(2);
var revenue = reader.GetDecimal(3);
var currency = reader.GetString(4);
// Write to analytics warehouse...
}
}
}public class AnalyticsEtlJob : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
using var connection = new SqlConnection(_connectionString);
await connection.OpenAsync(ct);
// Revenue by plan -- hardcoded column names
var revenueQuery = @"
SELECT
p.Name AS PlanName,
p.MonthlyPrice AS PlanPrice,
COUNT(s.Id) AS ActiveSubscriptions,
SUM(s.CurrentPrice) AS MonthlyRevenue,
s.Currency
FROM Subscriptions s
INNER JOIN Plans p ON s.PlanId = p.Id
WHERE s.Status = 'Active'
GROUP BY p.Name, p.MonthlyPrice, s.Currency";
using var cmd = new SqlCommand(revenueQuery, connection);
using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
var planName = reader.GetString(0);
var planPrice = reader.GetDecimal(1);
var count = reader.GetInt32(2);
var revenue = reader.GetDecimal(3);
var currency = reader.GetString(4);
// Write to analytics warehouse...
}
}
}Hardcoded column names. Hardcoded table names. Hardcoded status string 'Active'. When the Subscriptions team renamed MonthlyPrice to BasePrice in Sprint 41 -- a migration that EF Core handled cleanly for all the C# code -- the analytics ETL started throwing SqlException: Invalid column name 'MonthlyPrice' at 3am. The Analytics team found out when their dashboard went blank the next morning. The on-call engineer from the Subscriptions team had no idea the analytics job existed.
The Common Project
And then there is SubscriptionHub.Common, the project that was supposed to keep things organized. It contains DTOs shared between all four teams:
// SubscriptionHub.Common/Models/SubscriptionDto.cs
// Used by: Web, Services, Notifications, Analytics
// Last modified: 47 different PRs from 4 different teams
public class SubscriptionDto
{
public int Id { get; set; }
public int CustomerId { get; set; }
public string CustomerName { get; set; } = "";
public string CustomerEmail { get; set; } = "";
public int PlanId { get; set; }
public string PlanName { get; set; } = "";
public decimal PlanPrice { get; set; }
public string Status { get; set; } = "";
public DateTime StartDate { get; set; }
public DateTime? EndDate { get; set; }
public decimal CurrentPrice { get; set; }
public string Currency { get; set; } = "";
public string BillingCycle { get; set; } = "";
public bool IsTrialActive { get; set; }
public DateTime? TrialEndDate { get; set; }
public DateTime? PlanChangedDate { get; set; }
public int? PreviousPlanId { get; set; }
public string? PreviousPlanName { get; set; }
public decimal? PreviousPlanPrice { get; set; }
public string? CancellationReason { get; set; }
public DateTime? CancelledAt { get; set; }
public DateTime? PausedAt { get; set; }
public string? PauseReason { get; set; }
public DateTime? ResumedAt { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
// Added Sprint 38 for notifications
public string? LastNotificationSent { get; set; }
public DateTime? LastNotificationDate { get; set; }
// Added Sprint 40 for analytics
public decimal? LifetimeValue { get; set; }
public int? InvoiceCount { get; set; }
// Added Sprint 41 for support dashboard
public int? OpenTicketCount { get; set; }
public string? AccountManagerEmail { get; set; }
// Added Sprint 43 for mobile app
public string? AvatarUrl { get; set; }
public bool? HasMobileApp { get; set; }
// Added Sprint 45 for compliance
public string? DataRegion { get; set; }
public bool? GdprConsentGiven { get; set; }
public DateTime? GdprConsentDate { get; set; }
// Added Sprint 47 for partner integrations
public string? PartnerReferralCode { get; set; }
public decimal? PartnerCommissionRate { get; set; }
public string? ExternalSystemId { get; set; }
// Added Sprint 49 for self-service portal
public bool? CanSelfCancel { get; set; }
public bool? CanSelfUpgrade { get; set; }
public bool? CanSelfDowngrade { get; set; }
public List<string>? AvailableActions { get; set; }
}// SubscriptionHub.Common/Models/SubscriptionDto.cs
// Used by: Web, Services, Notifications, Analytics
// Last modified: 47 different PRs from 4 different teams
public class SubscriptionDto
{
public int Id { get; set; }
public int CustomerId { get; set; }
public string CustomerName { get; set; } = "";
public string CustomerEmail { get; set; } = "";
public int PlanId { get; set; }
public string PlanName { get; set; } = "";
public decimal PlanPrice { get; set; }
public string Status { get; set; } = "";
public DateTime StartDate { get; set; }
public DateTime? EndDate { get; set; }
public decimal CurrentPrice { get; set; }
public string Currency { get; set; } = "";
public string BillingCycle { get; set; } = "";
public bool IsTrialActive { get; set; }
public DateTime? TrialEndDate { get; set; }
public DateTime? PlanChangedDate { get; set; }
public int? PreviousPlanId { get; set; }
public string? PreviousPlanName { get; set; }
public decimal? PreviousPlanPrice { get; set; }
public string? CancellationReason { get; set; }
public DateTime? CancelledAt { get; set; }
public DateTime? PausedAt { get; set; }
public string? PauseReason { get; set; }
public DateTime? ResumedAt { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
// Added Sprint 38 for notifications
public string? LastNotificationSent { get; set; }
public DateTime? LastNotificationDate { get; set; }
// Added Sprint 40 for analytics
public decimal? LifetimeValue { get; set; }
public int? InvoiceCount { get; set; }
// Added Sprint 41 for support dashboard
public int? OpenTicketCount { get; set; }
public string? AccountManagerEmail { get; set; }
// Added Sprint 43 for mobile app
public string? AvatarUrl { get; set; }
public bool? HasMobileApp { get; set; }
// Added Sprint 45 for compliance
public string? DataRegion { get; set; }
public bool? GdprConsentGiven { get; set; }
public DateTime? GdprConsentDate { get; set; }
// Added Sprint 47 for partner integrations
public string? PartnerReferralCode { get; set; }
public decimal? PartnerCommissionRate { get; set; }
public string? ExternalSystemId { get; set; }
// Added Sprint 49 for self-service portal
public bool? CanSelfCancel { get; set; }
public bool? CanSelfUpgrade { get; set; }
public bool? CanSelfDowngrade { get; set; }
public List<string>? AvailableActions { get; set; }
}Forty-seven properties. Every sprint, someone from some team adds a field. Nobody removes fields because nobody knows who else depends on them. The Notifications team only uses CustomerName, CustomerEmail, PlanName, and Status. They serialize and deserialize the other forty-three properties on every request. The Analytics team only uses PlanPrice, Currency, Status, and LifetimeValue. The mobile app uses AvatarUrl and HasMobileApp. Every team carries every other team's baggage.
This is what it looks like when boundaries exist only in org charts:
Every red arrow is a boundary violation. Every boundary violation is a place where one team's change breaks another team's code. Every break is a production incident, a support ticket, or a 3am page.
Pathology 6: No Aggregate Boundaries
Here is the AppDbContext. This is the single entry point for all data access in SubscriptionHub:
public class AppDbContext : DbContext
{
public DbSet<Customer> Customers => Set<Customer>();
public DbSet<Subscription> Subscriptions => Set<Subscription>();
public DbSet<Plan> Plans => Set<Plan>();
public DbSet<PlanTier> PlanTiers => Set<PlanTier>();
public DbSet<PlanFeature> PlanFeatures => Set<PlanFeature>();
public DbSet<IncludedQuantity> IncludedQuantities => Set<IncludedQuantity>();
public DbSet<OverageRate> OverageRates => Set<OverageRate>();
public DbSet<Invoice> Invoices => Set<Invoice>();
public DbSet<InvoiceLineItem> InvoiceLineItems => Set<InvoiceLineItem>();
public DbSet<Payment> Payments => Set<Payment>();
public DbSet<PaymentMethod> PaymentMethods => Set<PaymentMethod>();
public DbSet<Refund> Refunds => Set<Refund>();
public DbSet<UsageRecord> UsageRecords => Set<UsageRecord>();
public DbSet<UsageMetric> UsageMetrics => Set<UsageMetric>();
public DbSet<Discount> Discounts => Set<Discount>();
public DbSet<DiscountRule> DiscountRules => Set<DiscountRule>();
public DbSet<Coupon> Coupons => Set<Coupon>();
public DbSet<TaxRate> TaxRates => Set<TaxRate>();
public DbSet<TaxRateCache> TaxRateCache => Set<TaxRateCache>();
public DbSet<BillingAddress> BillingAddresses => Set<BillingAddress>();
public DbSet<SubscriptionEvent> SubscriptionEvents => Set<SubscriptionEvent>();
public DbSet<NotificationPreference> NotificationPreferences => Set<NotificationPreference>();
public DbSet<NotificationLog> NotificationLogs => Set<NotificationLog>();
public DbSet<NotificationTemplate> NotificationTemplates => Set<NotificationTemplate>();
public DbSet<Webhook> Webhooks => Set<Webhook>();
public DbSet<WebhookDelivery> WebhookDeliveries => Set<WebhookDelivery>();
public DbSet<AuditEntry> AuditEntries => Set<AuditEntry>();
public DbSet<ApiKey> ApiKeys => Set<ApiKey>();
public DbSet<Feature> Features => Set<Feature>();
public DbSet<FeatureFlag> FeatureFlags => Set<FeatureFlag>();
public DbSet<TenantSetting> TenantSettings => Set<TenantSetting>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// 400 lines of Fluent API configuration
// Every entity configured in one method
// Navigation properties in every direction
modelBuilder.Entity<Customer>(e =>
{
e.HasMany(c => c.Subscriptions).WithOne(s => s.Customer);
e.HasMany(c => c.PaymentMethods).WithOne(pm => pm.Customer);
e.HasMany(c => c.Invoices).WithOne(i => i.Customer);
e.HasOne(c => c.BillingAddress).WithOne(ba => ba.Customer);
e.HasMany(c => c.NotificationPreferences)
.WithOne(np => np.Customer);
e.HasMany(c => c.AuditEntries).WithOne(ae => ae.Customer);
});
modelBuilder.Entity<Subscription>(e =>
{
e.HasOne(s => s.Customer).WithMany(c => c.Subscriptions);
e.HasOne(s => s.Plan).WithMany(p => p.Subscriptions);
e.HasMany(s => s.UsageRecords).WithOne(ur => ur.Subscription);
e.HasMany(s => s.Invoices).WithOne(i => i.Subscription);
e.HasMany(s => s.Events).WithOne(se => se.Subscription);
e.HasMany(s => s.Discounts).WithOne(d => d.Subscription);
});
// ... 350 more lines ...
}
}public class AppDbContext : DbContext
{
public DbSet<Customer> Customers => Set<Customer>();
public DbSet<Subscription> Subscriptions => Set<Subscription>();
public DbSet<Plan> Plans => Set<Plan>();
public DbSet<PlanTier> PlanTiers => Set<PlanTier>();
public DbSet<PlanFeature> PlanFeatures => Set<PlanFeature>();
public DbSet<IncludedQuantity> IncludedQuantities => Set<IncludedQuantity>();
public DbSet<OverageRate> OverageRates => Set<OverageRate>();
public DbSet<Invoice> Invoices => Set<Invoice>();
public DbSet<InvoiceLineItem> InvoiceLineItems => Set<InvoiceLineItem>();
public DbSet<Payment> Payments => Set<Payment>();
public DbSet<PaymentMethod> PaymentMethods => Set<PaymentMethod>();
public DbSet<Refund> Refunds => Set<Refund>();
public DbSet<UsageRecord> UsageRecords => Set<UsageRecord>();
public DbSet<UsageMetric> UsageMetrics => Set<UsageMetric>();
public DbSet<Discount> Discounts => Set<Discount>();
public DbSet<DiscountRule> DiscountRules => Set<DiscountRule>();
public DbSet<Coupon> Coupons => Set<Coupon>();
public DbSet<TaxRate> TaxRates => Set<TaxRate>();
public DbSet<TaxRateCache> TaxRateCache => Set<TaxRateCache>();
public DbSet<BillingAddress> BillingAddresses => Set<BillingAddress>();
public DbSet<SubscriptionEvent> SubscriptionEvents => Set<SubscriptionEvent>();
public DbSet<NotificationPreference> NotificationPreferences => Set<NotificationPreference>();
public DbSet<NotificationLog> NotificationLogs => Set<NotificationLog>();
public DbSet<NotificationTemplate> NotificationTemplates => Set<NotificationTemplate>();
public DbSet<Webhook> Webhooks => Set<Webhook>();
public DbSet<WebhookDelivery> WebhookDeliveries => Set<WebhookDelivery>();
public DbSet<AuditEntry> AuditEntries => Set<AuditEntry>();
public DbSet<ApiKey> ApiKeys => Set<ApiKey>();
public DbSet<Feature> Features => Set<Feature>();
public DbSet<FeatureFlag> FeatureFlags => Set<FeatureFlag>();
public DbSet<TenantSetting> TenantSettings => Set<TenantSetting>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// 400 lines of Fluent API configuration
// Every entity configured in one method
// Navigation properties in every direction
modelBuilder.Entity<Customer>(e =>
{
e.HasMany(c => c.Subscriptions).WithOne(s => s.Customer);
e.HasMany(c => c.PaymentMethods).WithOne(pm => pm.Customer);
e.HasMany(c => c.Invoices).WithOne(i => i.Customer);
e.HasOne(c => c.BillingAddress).WithOne(ba => ba.Customer);
e.HasMany(c => c.NotificationPreferences)
.WithOne(np => np.Customer);
e.HasMany(c => c.AuditEntries).WithOne(ae => ae.Customer);
});
modelBuilder.Entity<Subscription>(e =>
{
e.HasOne(s => s.Customer).WithMany(c => c.Subscriptions);
e.HasOne(s => s.Plan).WithMany(p => p.Subscriptions);
e.HasMany(s => s.UsageRecords).WithOne(ur => ur.Subscription);
e.HasMany(s => s.Invoices).WithOne(i => i.Subscription);
e.HasMany(s => s.Events).WithOne(se => se.Subscription);
e.HasMany(s => s.Discounts).WithOne(d => d.Subscription);
});
// ... 350 more lines ...
}
}Thirty-one DbSet<> properties. One OnModelCreating method. Navigation properties in every direction. Customer navigates to Subscriptions, PaymentMethods, Invoices, NotificationPreferences, and AuditEntries. Subscription navigates to Customer, Plan, UsageRecords, Invoices, Events, and Discounts. Invoice navigates back to Customer and Subscription. The object graph is fully connected.
This means any query can accidentally load the entire database:
// "I just need the subscription with its plan name"
var subscription = await _context.Subscriptions
.Include(s => s.Plan)
.Include(s => s.Customer)
.ThenInclude(c => c.PaymentMethods)
.Include(s => s.Customer)
.ThenInclude(c => c.Invoices)
.ThenInclude(i => i.LineItems)
.Include(s => s.Customer)
.ThenInclude(c => c.NotificationPreferences)
.Include(s => s.UsageRecords)
.Include(s => s.Invoices)
.ThenInclude(i => i.LineItems)
.Include(s => s.Events)
.Include(s => s.Discounts)
.ThenInclude(d => d.DiscountRule)
.ThenInclude(dr => dr.Coupon)
.FirstOrDefaultAsync(s => s.Id == subscriptionId);// "I just need the subscription with its plan name"
var subscription = await _context.Subscriptions
.Include(s => s.Plan)
.Include(s => s.Customer)
.ThenInclude(c => c.PaymentMethods)
.Include(s => s.Customer)
.ThenInclude(c => c.Invoices)
.ThenInclude(i => i.LineItems)
.Include(s => s.Customer)
.ThenInclude(c => c.NotificationPreferences)
.Include(s => s.UsageRecords)
.Include(s => s.Invoices)
.ThenInclude(i => i.LineItems)
.Include(s => s.Events)
.Include(s => s.Discounts)
.ThenInclude(d => d.DiscountRule)
.ThenInclude(dr => dr.Coupon)
.FirstOrDefaultAsync(s => s.Id == subscriptionId);That query generates 11 JOINs. For a customer with 24 monthly invoices, each with 5 line items, plus 3 payment methods, 4 notification preferences, 200 usage records, and 30 events: the Cartesian product returns approximately 24 * 5 * 3 * 4 * 200 * 30 = 8,640,000 rows that EF Core has to materialize and deduplicate.
In practice, nobody writes a query that bad on purpose. But without aggregate boundaries, there is nothing to stop it. Developers add .Include() calls because they need one more piece of data, and another, and another. The query grows incrementally. Each individual .Include() seems reasonable. The compound effect is a query that brings the database to its knees during peak hours.
The real damage is subtler. Without aggregates, there is no unit of consistency. When BillingService creates an invoice, it also modifies Subscription.Status, Customer.LastInvoiceDate, and PaymentMethod.LastUsedAt in the same SaveChangesAsync() call. If the invoice save succeeds but the notification fails, is the system in a consistent state? Nobody knows, because nobody has defined what "consistent" means for these entities.
A single entity change ripples through the entire codebase:
One column rename. Thirteen files. Four teams. And those are just the ones we know about -- the raw SQL in AnalyticsEtlJob and the stored procedure sp_CalculateProration won't show up in a C# compiler error. They fail at runtime, in production, at 3am.
Without aggregate boundaries, the blast radius of any change is the entire codebase. There is no safe change. There is no local change. Every modification is global.
The Diagnosis
These six pathologies are not independent. They are a system. Each one enables and reinforces the others.
Anemic Domain Models create the vacuum that God Services fill. When entities have no behavior, the behavior has to live somewhere -- so it accumulates in service classes that grow without bound.
God Services scatter business rules. When one class handles billing, proration, tax, notifications, and audit logging, the proration logic ends up in the billing method, the controller preview, and the SQL stored procedure. Nobody realizes there are three implementations because they live in different files owned by different teams.
Scattered rules couple to infrastructure. When business logic lives in a service that holds an HttpClient, a DbContext, and an SmtpClient, the logic becomes untestable. When you cannot test, you cannot refactor. When you cannot refactor, the mess grows.
Infrastructure coupling prevents testing. When tests require a running database, a live API, and an SMTP server, the tests get [Ignore]d. When tests are ignored, the code ships without verification. When code ships without verification, bugs ship with it.
No tests mean no confidence. When nobody trusts the test suite, nobody dares refactor. When nobody refactors, boundaries erode. When boundaries erode, every team reaches into every other team's data.
Leaky boundaries make aggregate design impossible. When NotificationService queries six tables and AnalyticsEtlJob uses raw SQL against hardcoded column names, there is no way to define a clean aggregate boundary. The boundary would require every team to change their code, and nobody will agree to that without proof that the new design works. But you can't prove it works without boundaries. Deadlock.
This is the self-reinforcing loop of legacy code. It is why Big Balls of Mud are stable -- not in the engineering sense, but in the ecological sense. The system resists change because every change risks breaking something, and every attempt to improve one pathology runs into three others.
Breaking this loop requires an external force -- a structured technique that bypasses the code entirely and discovers the domain hiding inside the mud. That technique is Event Storming.
In Part III: Discovering Boundaries and Internals, we take the pathologies diagnosed here and run a Big Picture Event Storming session against them. The God Service's 2,400 lines decompose into four bounded contexts. The scattered proration logic converges into one rule, in one place. The 31-entity flat DbContext splits into focused aggregates with clear consistency boundaries.
But first, you have to feel the pain. If you've read this far and recognized your own codebase in these examples, you are ready for the cure.