The Industrial Monorepo Nobody Planned
Industrial Monorepo Series — Part 1 of 7 1. The Problem · 2. Physical vs Logical · 3. Requirements as Projects · 4. At Scale · 5. Migration · 6. ROI · 7. Inverted Deps
In most industrial monorepos, the only thing that scales is confusion.
How It Starts
Nobody sets out to build a 50-project monorepo with no architectural boundaries. It happens one .csproj at a time.
Year one: a single ASP.NET project. MegaCorp.Web. It has controllers, services, repositories, and a Startup.cs that wires everything. It works. The team ships features. Life is good.
Year two: the project is too big to compile quickly. Someone splits it. MegaCorp.Core gets the domain logic. MegaCorp.Data gets the repositories. MegaCorp.Web keeps the controllers. Three projects, three DLLs. The build is faster. Nobody questions the boundary lines — they're drawn along technical layers, not business capabilities.
Year three: a second team joins. They need a background worker for order processing. MegaCorp.Worker is born. It references MegaCorp.Core and MegaCorp.Data. Then a third team adds a payment gateway: MegaCorp.PaymentGateway. It also references MegaCorp.Core.
Year five: there are 20 projects. MegaCorp.Core has become a 400-file grab bag. Every team depends on it. Nobody can change it without breaking someone else. The "shared" project is the bottleneck, not the enabler. Someone proposes splitting Core into smaller projects. The team spends a sprint on it and produces MegaCorp.Core.Abstractions — the interfaces extracted from Core. This helps the build graph (projects can reference Abstractions without rebuilding all of Core), but it doesn't help understanding. The interfaces in Abstractions are still a flat list with no feature grouping.
Year six: the Common.Utils project appears. Someone needed a StringExtensions.ToSlug() method and didn't know where to put it. Utils becomes the dumping ground for everything that doesn't obviously belong anywhere else: date helpers, JSON converters, retry policies, enum extensions, file path utilities. By year eight it has 80 files and 15,000 lines. Every project references it. Changing anything in Utils triggers a rebuild of the entire solution.
Year seven: someone introduces CQRS for the reporting module. MegaCorp.Data.ReadModels is born. It references Core.Abstractions and Data. Now there are two data access patterns in the same solution — some services use repositories through Data, others use read models through Data.ReadModels, and a few use both. The data access strategy is per-team, not per-feature.
Year eight: there are 50 projects. The solution file takes 40 seconds to open in Visual Studio. Build times are measured in minutes. There are three different logging abstractions (one in Core, one in Common.Logging, one using ILogger<T> directly), two ORM configurations (EF Core in Data, Dapper in Data.ReadModels), and a Common.Utils project that everyone references but nobody owns. Someone suggests moving to microservices. The CTO says: "We can't afford to rewrite. Make it work."
Year nine: someone on Team E introduces MediatR for the user management module. Now there's a third communication pattern in the monorepo: direct DI (constructor injection), ServiceLocator (runtime resolution), and MediatR (in-process message passing). MegaCorp.UserService uses MediatR handlers. MegaCorp.Core uses direct DI and ServiceLocator. MegaCorp.PaymentGateway uses all three. The "architecture" is now a patchwork of communication styles, chosen per-team and per-era, with no migration plan.
And through all of this, the DI registrations tell the story. Program.cs (or Startup.cs in older projects) has become a 500-line wiring ceremony:
// MegaCorp.Web/Program.cs — year 8
// This file has been touched by every team. No one owns it.
// Every merge conflict happens here.
var builder = WebApplication.CreateBuilder(args);
// --- Core services (Team A) ---
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddScoped<IOrderServiceV2, OrderServiceV2>(); // V2 coexists with V1
builder.Services.AddScoped<IOrderValidator, OrderValidator>();
builder.Services.AddScoped<IOrderValidatorNew, OrderValidatorNew>(); // "New" since 2023
builder.Services.AddScoped<IPricingEngine, PricingEngine>();
builder.Services.AddScoped<IDiscountCalculator, DiscountCalculator>();
builder.Services.AddScoped<IDiscountCalculatorV2, DiscountCalculatorV2>();
// --- Payment (Team B) ---
builder.Services.AddScoped<IPaymentProcessor, StripeProcessor>();
builder.Services.AddScoped<IPaymentValidator, PaymentValidator>();
builder.Services.AddScoped<IRefundProcessor, StripeRefundProcessor>();
builder.Services.AddHttpClient<IStripeClient, StripeHttpClient>();
// --- Inventory (Team C) ---
builder.Services.AddScoped<IInventoryChecker, StockChecker>();
builder.Services.AddScoped<IStockReserver, StockReserver>();
builder.Services.AddScoped<IWarehouseService, WarehouseService>();
// --- Notifications (Team D) ---
builder.Services.AddScoped<INotificationSender, NotificationSender>();
builder.Services.AddScoped<IEmailService, SmtpEmailService>();
builder.Services.AddScoped<ISmsService, TwilioSmsService>();
builder.Services.AddScoped<IPushNotificationService, FirebasePushService>();
builder.Services.AddScoped<ITemplateRenderer, ScribanTemplateRenderer>();
// --- User & Auth (Team E) ---
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<IUserProfileService, UserProfileService>();
builder.Services.AddScoped<IRoleService, RoleService>();
builder.Services.AddScoped<IPermissionService, PermissionService>();
// --- Billing (Team F) ---
builder.Services.AddScoped<IBillingService, BillingService>();
builder.Services.AddScoped<IInvoiceGenerator, InvoiceGenerator>();
builder.Services.AddScoped<ITaxCalculator, TaxCalculator>();
// --- Infrastructure (shared, no owner) ---
builder.Services.AddScoped<ICacheService, RedisCacheService>();
builder.Services.AddScoped<IEventBus, RabbitMQEventBus>();
builder.Services.AddScoped<IAuditLogger, ComplianceAuditLogger>();
builder.Services.AddScoped<ISearchService, ElasticsearchService>();
// --- The ServiceLocator (legacy, "temporary" since 2018) ---
var app = builder.Build();
ServiceLocator.Initialize(app.Services); // <-- still here
// ... 200 more lines of middleware, endpoints, health checks// MegaCorp.Web/Program.cs — year 8
// This file has been touched by every team. No one owns it.
// Every merge conflict happens here.
var builder = WebApplication.CreateBuilder(args);
// --- Core services (Team A) ---
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddScoped<IOrderServiceV2, OrderServiceV2>(); // V2 coexists with V1
builder.Services.AddScoped<IOrderValidator, OrderValidator>();
builder.Services.AddScoped<IOrderValidatorNew, OrderValidatorNew>(); // "New" since 2023
builder.Services.AddScoped<IPricingEngine, PricingEngine>();
builder.Services.AddScoped<IDiscountCalculator, DiscountCalculator>();
builder.Services.AddScoped<IDiscountCalculatorV2, DiscountCalculatorV2>();
// --- Payment (Team B) ---
builder.Services.AddScoped<IPaymentProcessor, StripeProcessor>();
builder.Services.AddScoped<IPaymentValidator, PaymentValidator>();
builder.Services.AddScoped<IRefundProcessor, StripeRefundProcessor>();
builder.Services.AddHttpClient<IStripeClient, StripeHttpClient>();
// --- Inventory (Team C) ---
builder.Services.AddScoped<IInventoryChecker, StockChecker>();
builder.Services.AddScoped<IStockReserver, StockReserver>();
builder.Services.AddScoped<IWarehouseService, WarehouseService>();
// --- Notifications (Team D) ---
builder.Services.AddScoped<INotificationSender, NotificationSender>();
builder.Services.AddScoped<IEmailService, SmtpEmailService>();
builder.Services.AddScoped<ISmsService, TwilioSmsService>();
builder.Services.AddScoped<IPushNotificationService, FirebasePushService>();
builder.Services.AddScoped<ITemplateRenderer, ScribanTemplateRenderer>();
// --- User & Auth (Team E) ---
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<IUserProfileService, UserProfileService>();
builder.Services.AddScoped<IRoleService, RoleService>();
builder.Services.AddScoped<IPermissionService, PermissionService>();
// --- Billing (Team F) ---
builder.Services.AddScoped<IBillingService, BillingService>();
builder.Services.AddScoped<IInvoiceGenerator, InvoiceGenerator>();
builder.Services.AddScoped<ITaxCalculator, TaxCalculator>();
// --- Infrastructure (shared, no owner) ---
builder.Services.AddScoped<ICacheService, RedisCacheService>();
builder.Services.AddScoped<IEventBus, RabbitMQEventBus>();
builder.Services.AddScoped<IAuditLogger, ComplianceAuditLogger>();
builder.Services.AddScoped<ISearchService, ElasticsearchService>();
// --- The ServiceLocator (legacy, "temporary" since 2018) ---
var app = builder.Build();
ServiceLocator.Initialize(app.Services); // <-- still here
// ... 200 more lines of middleware, endpoints, health checksEvery team adds their registrations to this file. Every sprint produces merge conflicts in Program.cs. Nobody removes old registrations because nobody knows if something still depends on them. IOrderService and IOrderServiceV2 both exist because V1 is still called from MegaCorp.Worker and nobody has time to migrate it. The file is a historical record of every architectural decision (and every abandoned refactoring) over 8 years.
This is the DI registration as archaeology. Layer upon layer, team upon team, each adding their services with no organizing principle beyond "put it after the last one." There's no grouping by feature. There's no indication that IOrderService, IPaymentProcessor, IInventoryChecker, and INotificationSender are all part of "order processing." They're just lines in a 500-line file.
Year ten: a new developer joins. They're asked to fix a bug in order processing. They open the solution. They see this:
MegaCorp.sln
├── src/
│ ├── MegaCorp.Web/ ← ASP.NET API (the original project)
│ ├── MegaCorp.Web.Admin/ ← Admin panel (added year 3)
│ ├── MegaCorp.Core/ ← "Domain logic" (400+ files, everything)
│ ├── MegaCorp.Core.Abstractions/ ← Interfaces extracted from Core (year 6)
│ ├── MegaCorp.Data/ ← EF Core repositories
│ ├── MegaCorp.Data.Migrations/ ← Database migrations (split from Data year 4)
│ ├── MegaCorp.Data.ReadModels/ ← CQRS read side (added year 7)
│ ├── MegaCorp.Worker/ ← Background job processor
│ ├── MegaCorp.Worker.Scheduling/ ← Hangfire scheduling (split year 6)
│ ├── MegaCorp.PaymentGateway/ ← Payment processing service
│ ├── MegaCorp.PaymentGateway.Contracts/ ← Payment DTOs and interfaces
│ ├── MegaCorp.NotificationService/ ← Email, SMS, push notifications
│ ├── MegaCorp.NotificationService.Templates/ ← Notification templates
│ ├── MegaCorp.InventoryService/ ← Stock management
│ ├── MegaCorp.InventoryService.Events/ ← Inventory domain events
│ ├── MegaCorp.UserService/ ← User management and auth
│ ├── MegaCorp.UserService.Identity/ ← Identity Server integration
│ ├── MegaCorp.ReportingService/ ← Analytics and reports
│ ├── MegaCorp.ReportingService.Export/ ← PDF/CSV export
│ ├── MegaCorp.BillingService/ ← Invoicing and billing
│ ├── MegaCorp.BillingService.Stripe/ ← Stripe integration
│ ├── MegaCorp.AuditService/ ← Compliance audit logging
│ ├── MegaCorp.SearchService/ ← Elasticsearch integration
│ ├── MegaCorp.CacheService/ ← Redis caching layer
│ ├── MegaCorp.EventBus/ ← Message broker abstraction
│ ├── MegaCorp.EventBus.RabbitMQ/ ← RabbitMQ implementation
│ ├── MegaCorp.EventBus.InMemory/ ← In-memory for testing
│ ├── MegaCorp.Common.Utils/ ← String helpers, date utils, "shared" code
│ ├── MegaCorp.Common.Logging/ ← Logging abstractions (3rd attempt)
│ ├── MegaCorp.Common.Validation/ ← FluentValidation extensions
│ ├── MegaCorp.Contracts/ ← DTOs, shared interfaces (the grab bag)
│ ├── MegaCorp.Contracts.V2/ ← "New" DTOs (V1 still referenced everywhere)
│ ├── MegaCorp.Infrastructure/ ← Cross-cutting concerns
│ ├── MegaCorp.Infrastructure.Health/ ← Health checks
│ ├── MegaCorp.ApiGateway/ ← Ocelot/YARP gateway
│ ├── MegaCorp.ApiGateway.Config/ ← Gateway routing config
│ ├── MegaCorp.SignalR/ ← Real-time notifications hub
│ └── MegaCorp.Jobs/ ← Quartz.NET scheduled jobs
├── test/
│ ├── MegaCorp.Core.Tests/
│ ├── MegaCorp.Web.Tests/
│ ├── MegaCorp.PaymentGateway.Tests/
│ ├── MegaCorp.Worker.Tests/
│ ├── MegaCorp.InventoryService.Tests/
│ ├── MegaCorp.UserService.Tests/
│ ├── MegaCorp.BillingService.Tests/
│ ├── MegaCorp.Integration.Tests/ ← "Integration" tests (actually E2E)
│ ├── MegaCorp.TestHelpers/ ← Shared test utilities
│ └── MegaCorp.TestHelpers.Fakes/ ← Fake implementations
└── tools/
├── MegaCorp.CodeGen/ ← T4/Scriban templates
└── MegaCorp.DbSeeder/ ← Database seeding toolMegaCorp.sln
├── src/
│ ├── MegaCorp.Web/ ← ASP.NET API (the original project)
│ ├── MegaCorp.Web.Admin/ ← Admin panel (added year 3)
│ ├── MegaCorp.Core/ ← "Domain logic" (400+ files, everything)
│ ├── MegaCorp.Core.Abstractions/ ← Interfaces extracted from Core (year 6)
│ ├── MegaCorp.Data/ ← EF Core repositories
│ ├── MegaCorp.Data.Migrations/ ← Database migrations (split from Data year 4)
│ ├── MegaCorp.Data.ReadModels/ ← CQRS read side (added year 7)
│ ├── MegaCorp.Worker/ ← Background job processor
│ ├── MegaCorp.Worker.Scheduling/ ← Hangfire scheduling (split year 6)
│ ├── MegaCorp.PaymentGateway/ ← Payment processing service
│ ├── MegaCorp.PaymentGateway.Contracts/ ← Payment DTOs and interfaces
│ ├── MegaCorp.NotificationService/ ← Email, SMS, push notifications
│ ├── MegaCorp.NotificationService.Templates/ ← Notification templates
│ ├── MegaCorp.InventoryService/ ← Stock management
│ ├── MegaCorp.InventoryService.Events/ ← Inventory domain events
│ ├── MegaCorp.UserService/ ← User management and auth
│ ├── MegaCorp.UserService.Identity/ ← Identity Server integration
│ ├── MegaCorp.ReportingService/ ← Analytics and reports
│ ├── MegaCorp.ReportingService.Export/ ← PDF/CSV export
│ ├── MegaCorp.BillingService/ ← Invoicing and billing
│ ├── MegaCorp.BillingService.Stripe/ ← Stripe integration
│ ├── MegaCorp.AuditService/ ← Compliance audit logging
│ ├── MegaCorp.SearchService/ ← Elasticsearch integration
│ ├── MegaCorp.CacheService/ ← Redis caching layer
│ ├── MegaCorp.EventBus/ ← Message broker abstraction
│ ├── MegaCorp.EventBus.RabbitMQ/ ← RabbitMQ implementation
│ ├── MegaCorp.EventBus.InMemory/ ← In-memory for testing
│ ├── MegaCorp.Common.Utils/ ← String helpers, date utils, "shared" code
│ ├── MegaCorp.Common.Logging/ ← Logging abstractions (3rd attempt)
│ ├── MegaCorp.Common.Validation/ ← FluentValidation extensions
│ ├── MegaCorp.Contracts/ ← DTOs, shared interfaces (the grab bag)
│ ├── MegaCorp.Contracts.V2/ ← "New" DTOs (V1 still referenced everywhere)
│ ├── MegaCorp.Infrastructure/ ← Cross-cutting concerns
│ ├── MegaCorp.Infrastructure.Health/ ← Health checks
│ ├── MegaCorp.ApiGateway/ ← Ocelot/YARP gateway
│ ├── MegaCorp.ApiGateway.Config/ ← Gateway routing config
│ ├── MegaCorp.SignalR/ ← Real-time notifications hub
│ └── MegaCorp.Jobs/ ← Quartz.NET scheduled jobs
├── test/
│ ├── MegaCorp.Core.Tests/
│ ├── MegaCorp.Web.Tests/
│ ├── MegaCorp.PaymentGateway.Tests/
│ ├── MegaCorp.Worker.Tests/
│ ├── MegaCorp.InventoryService.Tests/
│ ├── MegaCorp.UserService.Tests/
│ ├── MegaCorp.BillingService.Tests/
│ ├── MegaCorp.Integration.Tests/ ← "Integration" tests (actually E2E)
│ ├── MegaCorp.TestHelpers/ ← Shared test utilities
│ └── MegaCorp.TestHelpers.Fakes/ ← Fake implementations
└── tools/
├── MegaCorp.CodeGen/ ← T4/Scriban templates
└── MegaCorp.DbSeeder/ ← Database seeding toolFifty projects. The new developer asks: "Which code implements order processing?"
Nobody knows. Order processing touches MegaCorp.Web (the API controller), MegaCorp.Core (the order service, the order validator, the pricing engine, the discount calculator), MegaCorp.Data (the order repository), MegaCorp.PaymentGateway (payment capture), MegaCorp.NotificationService (order confirmation email), MegaCorp.InventoryService (stock reservation), MegaCorp.BillingService (invoice generation), MegaCorp.Worker (async order fulfillment), and MegaCorp.EventBus (order events). That's 10 projects for one business feature. And there's no compile-time proof of this — the relationship exists only in the heads of the three developers who built it, one of whom left last year.
The Dependency Graph Nobody Drew
If you could visualize the actual dependency graph of this solution, it would look like this:
Every project references Core. Every project references Contracts. Half of them reference Utils. The dependency arrows are a web, not a tree. There is no direction, no hierarchy, no layering that maps to business features. The only structure is physical: DLLs.
Reading the Graph
Look at the red node: MegaCorp.Core. It has inbound edges from every service project and outbound edges to Data, Contracts, Utils, EventBus, and Cache. It is the nexus. Changing a class in Core can break any project in the solution. Adding an interface to Core triggers a rebuild of everything downstream. But the Core project has no organizing principle — it's 400 files in 30 folders named after technical concerns (Orders/, Payments/, Users/, Validators/, Helpers/, Extensions/, Legacy/).
Now look at the orange nodes: Contracts, Utils, Core.Abstractions. These are the grab-bag projects — collections of interfaces, DTOs, extension methods, and helper classes that exist because someone needed to share a type between two projects and the easiest thing was to put it in "Contracts." Over time, Contracts grows to 200 interfaces with no cohesion. IOrderDto sits next to IEmailTemplate sits next to IAuditRecord. They have nothing in common except that two or more projects needed them.
The yellow node: EventBus. Half the projects publish or subscribe to events through it, but there's no schema for what events exist, which projects publish them, or which subscribe. The event types are strings:
// MegaCorp.EventBus/Events.cs
// This file has 80 string constants. Some are unused. Nobody knows which.
public static class EventTypes
{
public const string OrderCreated = "order.created";
public const string OrderUpdated = "order.updated";
public const string OrderCancelled = "order.cancelled";
public const string OrderFulfilled = "order.fulfilled";
public const string PaymentCaptured = "payment.captured";
public const string PaymentFailed = "payment.failed";
public const string PaymentRefunded = "payment.refunded";
public const string StockReserved = "stock.reserved";
public const string StockReleased = "stock.released";
public const string UserCreated = "user.created";
public const string UserUpdated = "user.updated";
public const string UserDeactivated = "user.deactivated";
public const string InvoiceGenerated = "invoice.generated";
public const string NotificationSent = "notification.sent";
// ... 60 more constants
// Which of these are still in use? Who publishes them? Who subscribes?
// The only way to know is to grep the entire codebase.
}// MegaCorp.EventBus/Events.cs
// This file has 80 string constants. Some are unused. Nobody knows which.
public static class EventTypes
{
public const string OrderCreated = "order.created";
public const string OrderUpdated = "order.updated";
public const string OrderCancelled = "order.cancelled";
public const string OrderFulfilled = "order.fulfilled";
public const string PaymentCaptured = "payment.captured";
public const string PaymentFailed = "payment.failed";
public const string PaymentRefunded = "payment.refunded";
public const string StockReserved = "stock.reserved";
public const string StockReleased = "stock.released";
public const string UserCreated = "user.created";
public const string UserUpdated = "user.updated";
public const string UserDeactivated = "user.deactivated";
public const string InvoiceGenerated = "invoice.generated";
public const string NotificationSent = "notification.sent";
// ... 60 more constants
// Which of these are still in use? Who publishes them? Who subscribes?
// The only way to know is to grep the entire codebase.
}String-based event routing, just like string-based service resolution. The same problem at a different layer: no compile-time contract, no traceability, no way to answer "what happens when an order is created?" without grepping 50 projects.
What the Graph Doesn't Show
The dependency graph above shows <ProjectReference> relationships — the compile-time edges. But the runtime dependency graph is far worse, because the ServiceLocator calls create edges that don't appear in any .csproj file.
For example, MegaCorp.PaymentGateway has a <ProjectReference> to Core, PaymentGateway.Contracts, EventBus, Contracts, and Utils. That's 5 compile-time dependencies. But at runtime, StripeProcessor resolves IAuditLogger (from AuditService), ICacheService (from CacheService), and IUserService (from UserService) through the ServiceLocator. That's 3 additional runtime dependencies that are invisible to the compiler, invisible to the project graph, and invisible to anyone reading the .csproj file.
Multiply this across 50 projects and 200+ ServiceLocator calls, and the real dependency graph has roughly 3x more edges than the project reference graph shows. The solution explorer in Visual Studio shows a manageable tree. The reality is a fully connected mesh.
The God-Object at the Center
At the heart of every industrial monorepo, there is a mediator. Not a proper Mediator pattern — something worse. A class or set of classes that acts as the inter-inter-mediator between every service and every other service. It has two faces.
Face 1: The Static ServiceLocator
The original sin. Someone, in year two, created this:
// MegaCorp.Infrastructure/ServiceLocator.cs
// Created: 2018. Still here. Still referenced by 30+ files.
public static class ServiceLocator
{
private static IServiceProvider _provider = null!;
public static void Initialize(IServiceProvider provider)
{
_provider = provider;
}
public static T GetService<T>() where T : notnull
{
return _provider.GetRequiredService<T>();
}
public static object GetService(Type serviceType)
{
return _provider.GetRequiredService(serviceType);
}
}// MegaCorp.Infrastructure/ServiceLocator.cs
// Created: 2018. Still here. Still referenced by 30+ files.
public static class ServiceLocator
{
private static IServiceProvider _provider = null!;
public static void Initialize(IServiceProvider provider)
{
_provider = provider;
}
public static T GetService<T>() where T : notnull
{
return _provider.GetRequiredService<T>();
}
public static object GetService(Type serviceType)
{
return _provider.GetRequiredService(serviceType);
}
}It was added to solve a problem: a background job processor that couldn't use constructor injection because the job framework didn't support it. Fair enough. But then someone else used it in a domain service. Then in a validator. Then in a factory. Then everywhere.
// MegaCorp.Core/Orders/OrderService.cs
public class OrderService
{
public async Task<OrderResult> ProcessOrder(CreateOrderCommand cmd)
{
// No constructor injection. No visible dependencies.
// The reader has no idea what this class actually needs.
var validator = ServiceLocator.GetService<IOrderValidator>();
var pricing = ServiceLocator.GetService<IPricingEngine>();
var inventory = ServiceLocator.GetService<IInventoryChecker>();
var payment = ServiceLocator.GetService<IPaymentProcessor>();
var notifications = ServiceLocator.GetService<INotificationSender>();
var audit = ServiceLocator.GetService<IAuditLogger>();
var cache = ServiceLocator.GetService<ICacheService>();
var eventBus = ServiceLocator.GetService<IEventBus>();
// 200 lines of order processing that calls all of the above
// Nobody knows which "feature" this belongs to
// Nobody knows which acceptance criteria this satisfies
// The only traceability is: "it's in MegaCorp.Core/Orders/"
}
}// MegaCorp.Core/Orders/OrderService.cs
public class OrderService
{
public async Task<OrderResult> ProcessOrder(CreateOrderCommand cmd)
{
// No constructor injection. No visible dependencies.
// The reader has no idea what this class actually needs.
var validator = ServiceLocator.GetService<IOrderValidator>();
var pricing = ServiceLocator.GetService<IPricingEngine>();
var inventory = ServiceLocator.GetService<IInventoryChecker>();
var payment = ServiceLocator.GetService<IPaymentProcessor>();
var notifications = ServiceLocator.GetService<INotificationSender>();
var audit = ServiceLocator.GetService<IAuditLogger>();
var cache = ServiceLocator.GetService<ICacheService>();
var eventBus = ServiceLocator.GetService<IEventBus>();
// 200 lines of order processing that calls all of the above
// Nobody knows which "feature" this belongs to
// Nobody knows which acceptance criteria this satisfies
// The only traceability is: "it's in MegaCorp.Core/Orders/"
}
}Eight hidden dependencies. Zero compile-time contracts. The class signature says it needs nothing (OrderService()). The reality is it needs the entire application wired and initialized. You cannot write a unit test for this class without initializing the full DI container. You cannot refactor it without running it to see what blows up. You cannot even read it and know what it depends on without scanning every line for ServiceLocator.GetService<> calls.
And it gets worse. The ServiceLocator doesn't just hide dependencies — it hides feature boundaries. When you look at OrderService, you see it calls IPaymentProcessor and INotificationSender and IInventoryChecker. But which feature do those belong to? Are they part of "order processing" or separate features that order processing consumes? The ServiceLocator doesn't know. The code doesn't say. The only answer is in someone's head — and that someone might have left the company.
Here's what a typical call chain looks like at runtime:
// What actually happens when someone calls ProcessOrder:
//
// 1. Web.OrderController calls OrderService.ProcessOrder()
// 2. OrderService resolves IOrderValidator → Core.OrderValidator
// → OrderValidator resolves IInventoryChecker → InventoryService.StockChecker
// → StockChecker resolves ICacheService → CacheService.RedisCache
// → OrderValidator resolves IPricingEngine → Core.PricingEngine
// → PricingEngine resolves IDiscountCalculator → Core.DiscountCalculator
// → DiscountCalculator resolves IUserService → UserService.UserProfileService
// → UserProfileService resolves ICacheService → CacheService.RedisCache (again)
// 3. OrderService resolves IPaymentProcessor → PaymentGateway.StripeProcessor
// → StripeProcessor resolves IAuditLogger → AuditService.ComplianceLogger
// → StripeProcessor resolves IEventBus → EventBus.RabbitMQBus
// 4. OrderService resolves INotificationSender → NotificationService.EmailSender
// → EmailSender resolves IUserService → UserService.UserProfileService (again)
// 5. OrderService resolves IEventBus → EventBus.RabbitMQBus (again)
// → publishes OrderCreatedEvent
// → Worker.OrderFulfillmentHandler picks it up
// → resolves IInventoryChecker, IBillingService, INotificationSender...
// → the cascade continues
//
// Total runtime resolution calls: 20+
// Total projects touched: 10
// Compile-time visibility of this chain: ZERO// What actually happens when someone calls ProcessOrder:
//
// 1. Web.OrderController calls OrderService.ProcessOrder()
// 2. OrderService resolves IOrderValidator → Core.OrderValidator
// → OrderValidator resolves IInventoryChecker → InventoryService.StockChecker
// → StockChecker resolves ICacheService → CacheService.RedisCache
// → OrderValidator resolves IPricingEngine → Core.PricingEngine
// → PricingEngine resolves IDiscountCalculator → Core.DiscountCalculator
// → DiscountCalculator resolves IUserService → UserService.UserProfileService
// → UserProfileService resolves ICacheService → CacheService.RedisCache (again)
// 3. OrderService resolves IPaymentProcessor → PaymentGateway.StripeProcessor
// → StripeProcessor resolves IAuditLogger → AuditService.ComplianceLogger
// → StripeProcessor resolves IEventBus → EventBus.RabbitMQBus
// 4. OrderService resolves INotificationSender → NotificationService.EmailSender
// → EmailSender resolves IUserService → UserService.UserProfileService (again)
// 5. OrderService resolves IEventBus → EventBus.RabbitMQBus (again)
// → publishes OrderCreatedEvent
// → Worker.OrderFulfillmentHandler picks it up
// → resolves IInventoryChecker, IBillingService, INotificationSender...
// → the cascade continues
//
// Total runtime resolution calls: 20+
// Total projects touched: 10
// Compile-time visibility of this chain: ZERONone of this is visible from the constructor. None of this is compiler-checked. If you remove IInventoryChecker from the DI container, you won't get a compile error. You'll get a runtime InvalidOperationException — in production, at 2 AM, when the on-call engineer is trying to figure out why orders are failing.
Face 2: IServiceProvider as Constructor Parameter
The "modernized" version. Someone read that ServiceLocator is an anti-pattern and "fixed" it by injecting IServiceProvider instead:
// MegaCorp.Core/Orders/OrderServiceV2.cs
// "Refactored" in 2022. Same problem, different syntax.
public class OrderServiceV2
{
private readonly IServiceProvider _sp;
public OrderServiceV2(IServiceProvider sp)
{
_sp = sp;
}
public async Task<OrderResult> ProcessOrder(CreateOrderCommand cmd)
{
// Exactly the same as ServiceLocator, but "injected"
var validator = _sp.GetRequiredService<IOrderValidator>();
var pricing = _sp.GetRequiredService<IPricingEngine>();
var inventory = _sp.GetRequiredService<IInventoryChecker>();
var payment = _sp.GetRequiredService<IPaymentProcessor>();
var notifications = _sp.GetRequiredService<INotificationSender>();
var audit = _sp.GetRequiredService<IAuditLogger>();
var cache = _sp.GetRequiredService<ICacheService>();
var eventBus = _sp.GetRequiredService<IEventBus>();
// Same 200 lines. Same hidden dependencies.
// But now it "uses DI" — technically.
}
}// MegaCorp.Core/Orders/OrderServiceV2.cs
// "Refactored" in 2022. Same problem, different syntax.
public class OrderServiceV2
{
private readonly IServiceProvider _sp;
public OrderServiceV2(IServiceProvider sp)
{
_sp = sp;
}
public async Task<OrderResult> ProcessOrder(CreateOrderCommand cmd)
{
// Exactly the same as ServiceLocator, but "injected"
var validator = _sp.GetRequiredService<IOrderValidator>();
var pricing = _sp.GetRequiredService<IPricingEngine>();
var inventory = _sp.GetRequiredService<IInventoryChecker>();
var payment = _sp.GetRequiredService<IPaymentProcessor>();
var notifications = _sp.GetRequiredService<INotificationSender>();
var audit = _sp.GetRequiredService<IAuditLogger>();
var cache = _sp.GetRequiredService<ICacheService>();
var eventBus = _sp.GetRequiredService<IEventBus>();
// Same 200 lines. Same hidden dependencies.
// But now it "uses DI" — technically.
}
}This is worse than the static version, because it creates the illusion of proper dependency injection while providing none of its benefits. The constructor says: "I need IServiceProvider." That tells you nothing. Every class in the entire application could have the same constructor signature. There are no compile-time constraints. There are no interface contracts. There is no way to know, from the constructor alone, what this class actually does.
Both Are the Same Problem
Both patterns turn the DI container into an inter-inter-mediator — a single point through which every service reaches every other service. The dependency graph that the compiler sees is:
Everything → ServiceProvider → EverythingEverything → ServiceProvider → EverythingWhich is the same as:
Everything → EverythingEverything → EverythingThere are no boundaries. There is no direction. There is no way to ask: "What implements order processing?" because order processing is scattered across 10 projects, 40 classes, and 200 ServiceLocator.GetService<> calls.
Let's be precise about what's lost:
| What You Need | With Proper DI | With ServiceProvider God-Object |
|---|---|---|
| See dependencies | Constructor signature | Grep every line of the class body |
| Compile-time safety | Missing registration → build error (with source-generated DI) | Missing registration → runtime exception |
| Feature boundaries | Interface contracts define the boundary | No boundaries — everything can resolve everything |
| Refactoring safety | Change an interface → compile errors show all consumers | Change an interface → runtime failures show up in production |
| Test isolation | Mock the constructor params | Initialize the entire DI container or mock the ServiceProvider |
| Onboarding | Read the constructor, understand the class | Read every line, trace every GetService<>, map the runtime graph |
| "What implements Feature X?" | Find all classes implementing IFeatureXSpec |
Grep, pray, ask the team lead who might remember |
The ServiceProvider isn't just a code smell. It's an architecture eraser. It takes whatever structure your projects might have and flattens it into a single runtime routing table. The 50 DLLs in your solution? They're an illusion of structure. The real architecture is: everything can reach everything through the god-object.
And here's the cruelest part: the DLL boundaries actively mislead you. When you see MegaCorp.PaymentGateway as a separate project, you think: "Payment is isolated. It has its own boundary." But then you open StripeProcessor.cs and see ServiceLocator.GetService<IAuditLogger>() — reaching into AuditService, which reaches into Core, which reaches into Data. The DLL boundary is a lie. The real dependency is hidden behind a runtime resolution call.
The sequence diagram shows a single order processing call resolving 4 services through the ServiceLocator. In a real industrial monorepo, ProcessOrder triggers 20+ resolution calls across 10 projects. None of them appear in any constructor. None of them are compiler-checked. None of them are linked to any business requirement.
What the New Developer Actually Needs
Let's follow the new developer — call her Alice — through her first week.
Day 1: Orientation
Alice is told: "There's a bug in order processing. When a customer applies a discount code and pays with a stored card, the invoice shows the pre-discount total." She opens the solution. 50 projects. She searches for "discount" — 147 results across 23 files in 8 projects. She searches for "invoice" — 89 results across 15 files in 6 projects. She searches for "order" — 2,341 results. She closes the search panel.
Here's what Alice's search for "discount" actually produces:
Search: "discount" — 147 results in 23 files
MegaCorp.Core/Pricing/DiscountCalculator.cs (12 hits) ← Original, year 3
MegaCorp.Core/Pricing/DiscountCalculatorV2.cs (15 hits) ← "New" version, year 7
MegaCorp.Core/Pricing/IDiscountCalculator.cs (3 hits) ← Interface for V1
MegaCorp.Core/Pricing/IDiscountCalculatorV2.cs (3 hits) ← Interface for V2
MegaCorp.Core/Pricing/DiscountRules.cs (8 hits) ← Rule engine, year 5
MegaCorp.Core/Pricing/DiscountType.cs (4 hits) ← Enum
MegaCorp.Core/Orders/OrderService.cs (6 hits) ← Uses V1 via ServiceLocator
MegaCorp.Core/Orders/OrderServiceV2.cs (5 hits) ← Uses V2 via IServiceProvider
MegaCorp.BillingService/InvoiceGenerator.cs (11 hits) ← Own discount logic (!?)
MegaCorp.BillingService/TaxCalculator.cs (4 hits) ← Discount-before-tax check
MegaCorp.PaymentGateway/StripeProcessor.cs (3 hits) ← Discount amount in metadata
MegaCorp.Contracts/OrderDto.cs (2 hits) ← DiscountAmount property
MegaCorp.Contracts/InvoiceDto.cs (2 hits) ← DiscountApplied property
MegaCorp.Contracts.V2/OrderResponseDto.cs (3 hits) ← Discount fields (V2 schema)
MegaCorp.Web/Controllers/OrderController.cs (4 hits) ← Passes discount code
MegaCorp.Web.Admin/Controllers/DiscountAdminController.cs (8 hits) ← Admin CRUD for discounts
MegaCorp.Data/Repositories/DiscountRepository.cs (6 hits) ← DB queries
MegaCorp.Data/Migrations/20240315_AddDiscountCodeTable.cs (3 hits) ← Migration
MegaCorp.Core.Tests/Pricing/DiscountCalculatorTests.cs (14 hits) ← Tests V1
MegaCorp.Core.Tests/Pricing/DiscountCalculatorV2Tests.cs (11 hits) ← Tests V2
MegaCorp.BillingService.Tests/InvoiceGeneratorTests.cs (9 hits) ← Tests billing's own logic
MegaCorp.Integration.Tests/OrderFlowTests.cs (7 hits) ← E2E (flaky, skip in CI)
MegaCorp.Worker/Handlers/OrderFulfillmentHandler.cs (4 hits) ← Reads discount from eventSearch: "discount" — 147 results in 23 files
MegaCorp.Core/Pricing/DiscountCalculator.cs (12 hits) ← Original, year 3
MegaCorp.Core/Pricing/DiscountCalculatorV2.cs (15 hits) ← "New" version, year 7
MegaCorp.Core/Pricing/IDiscountCalculator.cs (3 hits) ← Interface for V1
MegaCorp.Core/Pricing/IDiscountCalculatorV2.cs (3 hits) ← Interface for V2
MegaCorp.Core/Pricing/DiscountRules.cs (8 hits) ← Rule engine, year 5
MegaCorp.Core/Pricing/DiscountType.cs (4 hits) ← Enum
MegaCorp.Core/Orders/OrderService.cs (6 hits) ← Uses V1 via ServiceLocator
MegaCorp.Core/Orders/OrderServiceV2.cs (5 hits) ← Uses V2 via IServiceProvider
MegaCorp.BillingService/InvoiceGenerator.cs (11 hits) ← Own discount logic (!?)
MegaCorp.BillingService/TaxCalculator.cs (4 hits) ← Discount-before-tax check
MegaCorp.PaymentGateway/StripeProcessor.cs (3 hits) ← Discount amount in metadata
MegaCorp.Contracts/OrderDto.cs (2 hits) ← DiscountAmount property
MegaCorp.Contracts/InvoiceDto.cs (2 hits) ← DiscountApplied property
MegaCorp.Contracts.V2/OrderResponseDto.cs (3 hits) ← Discount fields (V2 schema)
MegaCorp.Web/Controllers/OrderController.cs (4 hits) ← Passes discount code
MegaCorp.Web.Admin/Controllers/DiscountAdminController.cs (8 hits) ← Admin CRUD for discounts
MegaCorp.Data/Repositories/DiscountRepository.cs (6 hits) ← DB queries
MegaCorp.Data/Migrations/20240315_AddDiscountCodeTable.cs (3 hits) ← Migration
MegaCorp.Core.Tests/Pricing/DiscountCalculatorTests.cs (14 hits) ← Tests V1
MegaCorp.Core.Tests/Pricing/DiscountCalculatorV2Tests.cs (11 hits) ← Tests V2
MegaCorp.BillingService.Tests/InvoiceGeneratorTests.cs (9 hits) ← Tests billing's own logic
MegaCorp.Integration.Tests/OrderFlowTests.cs (7 hits) ← E2E (flaky, skip in CI)
MegaCorp.Worker/Handlers/OrderFulfillmentHandler.cs (4 hits) ← Reads discount from eventTwenty-three files. Eight projects. Two versions of the calculator. A separate discount implementation in BillingService. Three different DTO schemas. Tests that test different versions. And an integration test marked as flaky.
Which of these files is relevant to the bug? All of them? Some of them? Alice doesn't know. The codebase doesn't tell her. She has to read each one and mentally reconstruct the flow.
She asks her team lead: "Where's the order processing code?" The answer: "Mostly in MegaCorp.Core/Orders/, but the payment part is in PaymentGateway, and the invoice generation is in BillingService, and the discount logic was recently moved to... actually, I think some of it is still in Core and some is in a new PricingEngine class. Check with Team A."
Team A is in a different time zone. Alice sends a Slack message. She waits.
Day 2: Archaeology
Team A responds: "The discount logic is in DiscountCalculatorV2 in MegaCorp.Core/Pricing/. The old one (DiscountCalculator) is still there but only used by the legacy order path in MegaCorp.Worker. The invoice generation uses the result from OrderService.ProcessOrder(), which calls the pricing engine, which calls the discount calculator. But the billing service also recalculates the total independently — it was supposed to use the same pricing engine but Team F had a deadline and hardcoded their own calculation."
Alice now knows there are two discount calculations — one in Core/Pricing/DiscountCalculatorV2 and one in BillingService/InvoiceGenerator. They should produce the same result but might not. The bug might be in either one.
She opens InvoiceGenerator.cs. It has 400 lines. It resolves 6 services through IServiceProvider. She traces each one. She draws a diagram on paper:
InvoiceGenerator (BillingService)
├── resolves IOrderRepository → Data/OrderRepository
│ └── reads Order with LineItems, DiscountCode
├── resolves ITaxCalculator → BillingService/TaxCalculator
│ └── resolves ICacheService → CacheService/RedisCache
├── resolves IDiscountCalculator → ??? Core/DiscountCalculator or V2?
│ └── depends on DI registration in Program.cs
│ └── Program.cs registers IDiscountCalculator → DiscountCalculator (V1!)
│ └── BUT Program.cs ALSO registers IDiscountCalculatorV2 → DiscountCalculatorV2
│ └── InvoiceGenerator resolves IDiscountCalculator (no V2 suffix) → gets V1
├── resolves IUserService → UserService/UserProfileService
│ └── for customer billing address
├── resolves IEventBus → EventBus/RabbitMQBus
│ └── publishes InvoiceGeneratedEvent
└── hardcoded discount calculation (lines 247-283)
└── ALSO calculates discount independently "as a cross-check"
└── uses V1 formula, not V2 formula
└── THIS IS PROBABLY THE BUGInvoiceGenerator (BillingService)
├── resolves IOrderRepository → Data/OrderRepository
│ └── reads Order with LineItems, DiscountCode
├── resolves ITaxCalculator → BillingService/TaxCalculator
│ └── resolves ICacheService → CacheService/RedisCache
├── resolves IDiscountCalculator → ??? Core/DiscountCalculator or V2?
│ └── depends on DI registration in Program.cs
│ └── Program.cs registers IDiscountCalculator → DiscountCalculator (V1!)
│ └── BUT Program.cs ALSO registers IDiscountCalculatorV2 → DiscountCalculatorV2
│ └── InvoiceGenerator resolves IDiscountCalculator (no V2 suffix) → gets V1
├── resolves IUserService → UserService/UserProfileService
│ └── for customer billing address
├── resolves IEventBus → EventBus/RabbitMQBus
│ └── publishes InvoiceGeneratedEvent
└── hardcoded discount calculation (lines 247-283)
└── ALSO calculates discount independently "as a cross-check"
└── uses V1 formula, not V2 formula
└── THIS IS PROBABLY THE BUGShe's mapping the runtime dependency graph by hand, on paper, because the codebase provides no mechanism to do it automatically. The diagram took 90 minutes to construct. In a codebase with compiler-enforced feature boundaries, it would be generated automatically by the source generator.
She finds the likely bug: InvoiceGenerator resolves IDiscountCalculator (V1) while OrderServiceV2 uses IDiscountCalculatorV2. V1 and V2 calculate differently for percentage-based discounts on orders with mixed tax rates. The invoice gets the V1 result; the order total shown to the customer uses V2. They diverge.
Day 3: The Jira Detour
Alice asks: "What are the acceptance criteria for order processing? What should the invoice show?" Her PM points her to Jira ticket MEGA-4521. The description says:
As a customer, I want to process an order so that I can purchase items.
Acceptance Criteria: 1. Order total is calculated correctly 2. Payment is captured 3. Confirmation email is sent 4. Invoice is generated
These ACs were written two years ago. They're vague. "Order total is calculated correctly" — with or without discounts? Before or after tax? Including shipping? The AC doesn't say.
Alice clicks through the linked tickets. MEGA-4521 has 6 sub-tasks, 3 of which are marked "Done" and 3 marked "Closed — Won't Fix." There are 4 related tickets (MEGA-5102, MEGA-5340, MEGA-5891, MEGA-6203) that added discount handling, tax calculation changes, and the V2 migration. Each has its own AC text field. Some contradict each other:
- MEGA-5102 AC: "Discount is applied to the subtotal before tax"
- MEGA-5891 AC: "For percentage discounts, apply to each line item individually, then sum"
- MEGA-6203 AC: "Invoice total must match the order total shown at checkout"
MEGA-5891's approach (per-line-item) produces a different result from MEGA-5102's approach (on subtotal) for orders with mixed tax rates. Which one is correct? Both tickets are marked "Done." The PM who wrote them is on parental leave.
The 47 comments on MEGA-4521 contain a conversation from 2024 where the PM clarified that discounts should be applied before tax using the per-line-item method (MEGA-5891), but this clarification never made it into the AC text of MEGA-4521 or MEGA-5102. Alice reads all 47 comments. She cross-references 4 tickets. She's now 3 hours into Jira archaeology for a single business rule.
Meanwhile, none of this context exists in the codebase. DiscountCalculator implements MEGA-5102's approach. DiscountCalculatorV2 implements MEGA-5891's approach. InvoiceGenerator's hardcoded logic implements something else entirely — it was written before either ticket and never updated. The code has three implementations of a business rule, the Jira has four conflicting specifications, and nothing connects them.
Day 4: The Fix (Maybe)
Alice has enough context to attempt a fix. She changes InvoiceGenerator to resolve IDiscountCalculatorV2 instead of IDiscountCalculator, and removes the hardcoded cross-check (which also used V1 logic). She writes a test:
// MegaCorp.BillingService.Tests/InvoiceGeneratorTests.cs
[Test]
public void Invoice_total_matches_order_total_with_percentage_discount()
{
// Arrange: order with 10% discount, mixed tax rates
var order = CreateOrderWithMixedTaxRates();
var discountCode = "SAVE10";
// Act
var invoice = _invoiceGenerator.GenerateInvoice(order.Id, discountCode);
// Assert
Assert.That(invoice.Total, Is.EqualTo(order.Total));
// What acceptance criterion does this verify? The test doesn't say.
// What feature is this for? The test doesn't know.
// If the PM changes the discount rules, will this test be updated? Nobody will know to.
}// MegaCorp.BillingService.Tests/InvoiceGeneratorTests.cs
[Test]
public void Invoice_total_matches_order_total_with_percentage_discount()
{
// Arrange: order with 10% discount, mixed tax rates
var order = CreateOrderWithMixedTaxRates();
var discountCode = "SAVE10";
// Act
var invoice = _invoiceGenerator.GenerateInvoice(order.Id, discountCode);
// Assert
Assert.That(invoice.Total, Is.EqualTo(order.Total));
// What acceptance criterion does this verify? The test doesn't say.
// What feature is this for? The test doesn't know.
// If the PM changes the discount rules, will this test be updated? Nobody will know to.
}The test passes. She opens a PR.
The code reviewer (from Team B) asks: "Does this affect the payment flow? The StripeProcessor also reads the order total — will it still match?" Alice doesn't know. She traces the StripeProcessor code. It resolves IOrderService through the ServiceLocator, calls GetOrderTotal(), and passes that to Stripe. But GetOrderTotal() uses yet another code path — it calls PricingEngine directly, bypassing both DiscountCalculator and DiscountCalculatorV2, because it was written before either existed and uses inline discount logic.
There are now three discount calculation paths in the codebase:
DiscountCalculatorV2— used byOrderServiceV2(the "current" path)DiscountCalculator(V1) — used byInvoiceGenerator(the bug Alice fixed)- Inline calculation in
PricingEngine.GetOrderTotal()— used byStripeProcessor
Alice's fix ensures the invoice matches path #1. But if path #3 (Stripe) calculates differently, customers will be charged one amount and invoiced another. She needs to verify path #3 too. That's another 2 hours of tracing.
Day 5: The Realization
Alice has spent a full week on a bug that should have taken 2 hours. Her PR has grown from a 10-line change to a 45-line change across 3 files (InvoiceGenerator, PricingEngine, and a new integration test). The PR description is 400 words explaining the three discount paths and why they diverged. Two reviewers from different teams need to approve it because the change crosses team boundaries.
The fix works. But nothing in the architecture prevents the same problem from recurring. Next quarter, when Team G adds a loyalty points system that also affects order totals, they'll face the same question: "Which discount calculation do I use?" And they'll go through the same archaeology Alice did, because the codebase still has no mechanism to say "order pricing is calculated by THIS class, implementing THIS specification, verified by THESE tests."
Alice has spent a full week on a bug that should have taken 2 hours. Not because the fix is complex — it's a 10-line change. But because the architecture provides no answers to three fundamental questions:
The new developer fixing the order processing bug needs three things:
"What IS order processing?" — the business definition. What are the acceptance criteria? What does the PM consider "order processing done correctly"?
"What code implements it?" — not a grep for
Orderacross 50 projects returning 400 files, but a specific, compiler-verified list of classes and methods that implement the order processing feature."What tests cover it?" — which tests verify which acceptance criteria? If I change
OrderService.ProcessOrder, which tests should I run? Which acceptance criteria am I affecting?
In the current monorepo, the answers are:
Check Jira. The story is MEGA-4521. The PM wrote acceptance criteria in a text field two years ago. Some of them were changed in a comment. Nobody updated the description.
Grep for "Order" and good luck. You'll find OrderService, OrderServiceV2, OrderProcessor, LegacyOrderHandler, OrderEventHandler, OrderValidator, OrderValidatorNew, and OrderPricingHelper. Some are dead code. Some are production-critical. You won't know which is which until you trace the ServiceLocator calls.
There are tests in MegaCorp.Core.Tests and MegaCorp.Integration.Tests. Some test OrderService, some test OrderServiceV2. None of them reference any acceptance criteria. None of them are linked to any requirement. The test names are
ProcessOrder_ShouldReturnSuccessandProcessOrder_InvalidInput_ShouldFail— they tell you nothing about which business rule they verify.
Alice's week is not unusual. Every new developer in every industrial monorepo goes through this initiation ritual. The time varies — some take a week, some take a month — but the experience is universal: the codebase is a map with no legend. Every road is there, every building is there, but nothing tells you which roads lead to which business destination.
This is the state of most industrial monorepos. Not because the developers are incompetent — because the architecture provides no mechanism for connecting business requirements to code. The only boundaries are physical (DLLs), and physical boundaries tell you nothing about business features.
The Cost of "Ask Around"
In a 5-person team, "ask around" works. You shout across the room: "Hey, does anyone know what validates order totals?" Someone answers. The knowledge stays in heads, and that's fine because the heads are all in the same room.
In a 50-person organization with 15 teams across 3 time zones? "Ask around" is a Slack thread that takes 4 hours to resolve, involves 6 people, and produces a conflicting answer because Team A's order validation was replaced by Team B's new implementation three months ago — but Team A's code is still in the repo, still referenced by some ServiceLocator registrations in the test environment, and still runs in the staging deployment because nobody updated the DI registrations there.
The new developer doesn't just lack knowledge. They lack any mechanism to acquire it from the codebase itself. The code doesn't know which feature it belongs to. The tests don't know which acceptance criteria they verify. The DLLs don't know which business capability they serve. The ServiceProvider knows everything about wiring and nothing about purpose.
The architecture is a telephone network with no directory. Every phone (service) can call every other phone (through the ServiceProvider switchboard), but there's no listing that says: "For order processing, dial these 10 extensions."
And that's before we even talk about the requirements. The PM's acceptance criteria — "orders with negative totals must be rejected," "stock must be reserved before payment is captured," "order confirmation emails must include line item details" — live in Jira ticket MEGA-4521, in a text field, in natural language, with no connection to any code, any test, any DLL, any project in the entire monorepo. The gap between "what the business wants" and "what the code does" is not just undocumented — it's impossible to represent in the current architecture.
Think about what would happen if Alice could have typed this on Day 1:
// Hypothetical: what if this existed?
typeof(OrderProcessingFeature).GetAcceptanceCriteria();
// Returns:
// - OrderTotalIsCalculatedCorrectly(items, discountCode) → decimal
// - PaymentIsCapturedForCorrectAmount(orderId, total) → bool
// - ConfirmationEmailIncludesLineItems(orderId) → bool
// - InvoiceShowsPostDiscountTotal(orderId, discountCode) → decimal
// - StockIsReservedBeforePayment(items) → bool
typeof(OrderProcessingFeature).GetImplementations();
// Returns:
// - MegaCorp.Core/Pricing/PricingEngine : IOrderPricingSpec
// - MegaCorp.PaymentGateway/StripeProcessor : IPaymentCaptureSpec
// - MegaCorp.NotificationService/OrderEmailSender : IOrderNotificationSpec
// - MegaCorp.BillingService/InvoiceGenerator : IInvoicingSpec
// - MegaCorp.InventoryService/StockReserver : IStockReservationSpec
typeof(OrderProcessingFeature).GetTests();
// Returns:
// - MegaCorp.Core.Tests/OrderPricingTests [Verifies: OrderTotalIsCalculatedCorrectly]
// - MegaCorp.PaymentGateway.Tests/PaymentCaptureTests [Verifies: PaymentIsCapturedForCorrectAmount]
// - MegaCorp.BillingService.Tests/InvoiceTests [Verifies: InvoiceShowsPostDiscountTotal]
// - ⚠ MISSING: No test for StockIsReservedBeforePayment
// - ⚠ MISSING: No test for ConfirmationEmailIncludesLineItems// Hypothetical: what if this existed?
typeof(OrderProcessingFeature).GetAcceptanceCriteria();
// Returns:
// - OrderTotalIsCalculatedCorrectly(items, discountCode) → decimal
// - PaymentIsCapturedForCorrectAmount(orderId, total) → bool
// - ConfirmationEmailIncludesLineItems(orderId) → bool
// - InvoiceShowsPostDiscountTotal(orderId, discountCode) → decimal
// - StockIsReservedBeforePayment(items) → bool
typeof(OrderProcessingFeature).GetImplementations();
// Returns:
// - MegaCorp.Core/Pricing/PricingEngine : IOrderPricingSpec
// - MegaCorp.PaymentGateway/StripeProcessor : IPaymentCaptureSpec
// - MegaCorp.NotificationService/OrderEmailSender : IOrderNotificationSpec
// - MegaCorp.BillingService/InvoiceGenerator : IInvoicingSpec
// - MegaCorp.InventoryService/StockReserver : IStockReservationSpec
typeof(OrderProcessingFeature).GetTests();
// Returns:
// - MegaCorp.Core.Tests/OrderPricingTests [Verifies: OrderTotalIsCalculatedCorrectly]
// - MegaCorp.PaymentGateway.Tests/PaymentCaptureTests [Verifies: PaymentIsCapturedForCorrectAmount]
// - MegaCorp.BillingService.Tests/InvoiceTests [Verifies: InvoiceShowsPostDiscountTotal]
// - ⚠ MISSING: No test for StockIsReservedBeforePayment
// - ⚠ MISSING: No test for ConfirmationEmailIncludesLineItemsDay 1. One type query. Alice would know exactly which classes implement order processing, which acceptance criteria exist, which are tested, and which are missing coverage. The bug — "invoice shows pre-discount total" — would map directly to AC InvoiceShowsPostDiscountTotal, which maps to IInvoicingSpec, which maps to InvoiceGenerator. She'd find the fix in 20 minutes, not 5 days.
This isn't hypothetical. It's what Requirements as Code provides. The question is whether it works at the scale of a 50-project industrial monorepo — or whether it's just elegant theory that collapses under real-world weight.
Spoiler: it works. And it works better at scale, because the bigger the monorepo, the more valuable compiler-enforced feature boundaries become.
In a 5-project solution, you can hold the entire architecture in your head. In a 50-project solution, you can't — so the architecture must hold itself, in the type system, enforced by the compiler. That's what this series builds toward.
The Symptoms Checklist
If you recognize three or more of these in your monorepo, you have the problem this series addresses:
- "Which feature does this code belong to?" — No answer available from the codebase. Requires asking a human or reading Jira.
- "What acceptance criteria does this test verify?" — Tests are named after technical scenarios, not business rules.
ProcessOrder_ShouldReturnSuccesstells you nothing about which AC it covers. - "If I change this class, what features break?" — The blast radius is unknown. The ServiceLocator hides the dependency graph. The only way to find out is to run all tests and hope they cover the paths.
- "Where are the boundaries?" — The answer is "DLLs" — which means "nowhere meaningful." Projects are split by technical layer (Web, Core, Data) or by accident (someone needed a faster build), not by business capability.
-
Program.cs/Startup.csis 300+ lines — The DI registration file is a merge conflict magnet that every team touches and nobody owns. - "Core" or "Common" has 200+ files — A grab-bag project that every other project references. Changing it triggers a full rebuild.
-
ServiceLocator.GetService<>exists anywhere — The static god-object pattern, even if "only used in a few places" (it's never just a few places). -
IServiceProvideris a constructor parameter — The "modern" god-object pattern, dressed up as dependency injection. - Dead interfaces in "Contracts" — Nobody knows which DTOs and interfaces are still used. Removing one might break a runtime resolution somewhere.
- New developers take 2+ weeks to make a confident change — Not because the code is complex, but because the architecture doesn't explain itself.
Every checked box is a symptom of the same root cause: the monorepo has physical boundaries but no logical boundaries. DLLs separate compilation units. Nothing separates business features.
The Scaling Question
The previous post in the requirements series answered the question: how do you scale the source entry path? — where requirements come from (Jira, Linear, YAML, databases). That's source provisioning. Adapters solve it. The compiler doesn't care whether a .ts file was written by hand or generated from Jira.
This series asks a different question:
Does the requirements-as-code architecture itself survive at industrial scale?
When you have 50 projects, 15 teams, 200 features, and a ServiceProvider god-object threading through everything — does it help to make requirements into types? Does it change anything to have a .Requirements project and a .Specifications project? Or is it just another pair of DLLs in the pile?
The answer is: it changes everything. But not for the reason you might think. The requirements project doesn't just add traceability — it creates logical boundaries that the compiler enforces. And logical boundaries are exactly what industrial monorepos are missing.
What This Series Covers
Part 2: Physical Boundaries Are Not Architecture — A deep dive into why DLLs are packaging, not design. The anatomy of both ServiceProvider anti-patterns with full code. Sequence diagrams showing the runtime resolution chaos. What's actually missing: a logical boundary layer.
Part 3: Requirements and Specifications ARE Projects — The core thesis. How a .Requirements project and a .Specifications project create compiler-enforced logical boundaries in a 50-project monorepo. Full OrderProcessingFeature with 6+ ACs spanning 5 services. The complete typed chain from requirement to specification to implementation to test.
Part 4: What Changes at 50+ Projects — The AC cascade: add one acceptance criterion, the build breaks across 5 teams until everyone implements and tests it. Feature traceability across 15+ projects. The compiler as cross-team coordination mechanism.
Part 5: Migration — Space and Time — You don't rewrite the monorepo. Space migration: project-by-project adoption. Time migration: feature-by-feature timeline. Hybrid Program.cs with old SP wiring and new typed chain coexisting. Sprint-by-sprint roadmap.
Part 6: ROI and Maintenance Costs — The business case. Defect density before and after. Onboarding ramp. Build time trends. Coverage growth. Total cost of ownership comparison. The graphs that convince management.
Prerequisites
This series builds on the Requirements as Code post, which established the core design:
- Requirements are types — abstract records with acceptance criteria as abstract methods
- Specifications are interfaces — contracts the domain must implement
- The compiler enforces the chain — from requirement to specification to implementation to test
- Six projects, one compiler — Requirements, SharedKernel, Specifications, Domain, Api, Tests
If you haven't read that post, start there. This series takes that architecture and stress-tests it against industrial-scale monorepos — the kind with 50 projects, ServiceProvider god-objects, and requirements scattered across Jira boards that nobody reads.
The question isn't whether the pattern is elegant. The question is whether it survives contact with reality. Let's find out.
"Why Not Just Split Into Microservices?"
This is the first suggestion from every architecture astronaut who sees a 50-project monorepo. "Just break it apart! Separate deployables! API boundaries! Team autonomy!"
The problems with this approach in an industrial context:
The ServiceProvider problem doesn't go away — it becomes a network problem. Instead of
ServiceLocator.GetService<IPaymentProcessor>(), you now havehttpClient.PostAsync("https://payment-service/api/capture"). The dependency is still hidden, still runtime-resolved, still not compiler-checked. You've traded an in-process god-object for a distributed god-object.The requirement traceability problem gets worse. In a monorepo, at least all the code is in one place. In microservices, "order processing" now spans 5 Git repositories, 5 CI pipelines, 5 deployment configurations, and 5 teams who may or may not coordinate. The question "what implements order processing?" now requires querying 5 repos.
The cost is prohibitive. Splitting a 50-project monorepo into microservices is a multi-year effort that delivers zero new features. The business won't fund it. The CTO already said: "Make it work."
It solves the wrong problem. The monorepo isn't the issue. The lack of logical boundaries is the issue. You can have a monorepo with excellent feature boundaries (this series shows how). You can have microservices with terrible feature boundaries (most do). The deployment topology is orthogonal to the architecture.
This series keeps the monorepo and fixes the architecture. The solution is not fewer projects — it's two new projects (.Requirements and .Specifications) that give the compiler the information it needs to enforce feature boundaries across all 50 existing projects.
The Numbers
Before we move on, let's quantify what we're dealing with. These numbers are composites from real industrial monorepos — not from one specific codebase, but representative of the pattern:
| Metric | Typical Industrial Monorepo |
|---|---|
| Projects in .sln | 40-80 |
| Lines of code | 500K - 2M |
| ServiceLocator.GetService<> calls | 200-800 |
| IServiceProvider constructor injections | 50-200 |
| Interfaces in "Contracts" grab-bag | 100-300 |
| Files in "Core" project | 200-600 |
| DI registrations in Program.cs/Startup.cs | 150-500 |
| Dead/unused interfaces nobody dares remove | 30-100 |
| Event types (string constants) | 40-150 |
| Teams | 5-20 |
| Time zones | 1-4 |
| Features (business capabilities) | 50-200 |
| Features traceable from code to Jira | 0 |
| Acceptance criteria linked to tests | 0 |
Classes with [ForRequirement] attributes |
0 |
Tests with [Verifies] attributes |
0 |
| Time for new dev to trace a feature end-to-end | 2-5 days |
| Time for new dev to make first confident change | 2-4 weeks |
| Average bug investigation time (like Alice's) | 3-8 days |
| Percentage of bugs caused by cross-team divergence | 15-30% |
The last four rows are the real cost. It's not the 50 DLLs. It's not the ServiceLocator calls. It's the time humans spend navigating a codebase that doesn't explain itself. Every hour a developer spends asking "what code implements this feature?" is an hour not spent building, fixing, or improving. Every bug like Alice's — caused by three implementations of the same business rule that diverged silently — is a bug that a compiler-enforced specification would have prevented entirely.
The zeros in the middle of the table are the most telling. Zero features traceable from code. Zero acceptance criteria linked to tests. Zero [ForRequirement] attributes. Zero [Verifies] attributes. The codebase has no vocabulary for talking about requirements. It can only talk about classes, interfaces, and DLLs — technical artifacts with no business semantics.
The rest of this series shows how to make that time approach zero — not by reorganizing the DLLs (that's reshuffling deck chairs), but by introducing a new kind of project that the compiler uses to enforce feature boundaries across the entire monorepo.
A Note on Terminology
Throughout this series, we use specific terms consistently:
| Term | Meaning |
|---|---|
| Physical boundary | A .csproj / DLL — a compilation unit. Determines what gets compiled together and deployed together. Says nothing about business intent. |
| Logical boundary | A feature-scoped contract enforced by the compiler. Determines what code belongs to which business capability, and what must be implemented and tested. |
| ServiceProvider god-object | Either ServiceLocator.GetService<T>() (static) or IServiceProvider injected as a constructor parameter. Both patterns erase architectural boundaries by allowing every class to resolve any service at runtime. |
| Requirements project | A .csproj containing feature types with acceptance criteria as abstract methods. Referenced by all implementation projects. The compiler enforces that every AC is specified, implemented, and tested. |
| Specifications project | A .csproj containing interfaces that the domain must implement. Each interface is linked to a requirement type via [ForRequirement(typeof(Feature))]. The specification IS the boundary contract. |
| The chain | The compiler-enforced link: Requirement → Specification → Implementation → Test. Every link uses typeof() or nameof(). Every link is Ctrl+Click navigable. Breaking any link produces a compile error. |
These aren't aspirational concepts. They're concrete .csproj files that you add to your solution. Part 3 shows the full implementation.
Summary
The industrial monorepo has three compounding problems:
Physical boundaries without logical boundaries. Fifty DLLs, zero feature contracts. The projects exist for build speed and deployment, not for domain modeling.
ServiceProvider as architecture. Whether static or injected, the god-object mediates every service-to-service call. The real dependency graph is invisible to the compiler. Refactoring is a gamble.
Requirements disconnected from code. Acceptance criteria live in Jira text fields. Tests are named after technical scenarios. There is no compiler-verifiable link from "what the business wants" to "what the code does."
Each problem reinforces the others. Without logical boundaries, there's no natural place to define feature contracts. Without feature contracts, the ServiceProvider fills the gap as a universal mediator. Without requirement traceability, nobody knows which code belongs to which feature, so every change requires archaeology.
The rest of this series breaks this cycle — starting with understanding why physical boundaries are the wrong tool for the job, and building toward an architecture where the compiler knows your features, your acceptance criteria, and your test coverage.
Alice's 5-day ordeal becomes a 20-minute type query. That's not a productivity improvement. That's a structural change in how industrial software is built.