Dependency Injection
"Dependency Injection is the first architecture decision every .NET developer encounters — and the first one where Convention's hidden cost starts compounding."
Dependency Injection is the gateway. Before you worry about validation, serialization, database mapping, or architecture enforcement, you must answer one question: how do your objects get their dependencies?
Every .NET developer has answered this question. Most have answered it in at least two of the four eras. And the progression from manual wiring to generated registration reveals the full arc of this series: each era reduces boilerplate — but only the fourth era eliminates the hidden overhead of documenting and policing invisible rules.
This is the story of DI across all four eras, with full code, full costs, and full accounting of what the "Convention Tax" actually looks like in practice.
We will show the actual wiki page. The actual enforcement tests. The actual Scrutor configuration. And then we will show what replaces all of it: a single attribute, a Source Generator, and four analyzer diagnostics. The before and after is not theoretical. It is line-counted.
Era 1: Code — Manual Wiring
In the beginning, you built object graphs by hand. There was no container, no scanning, no convention. If object A needed object B, you created B and passed it to A's constructor. If B needed C and D, you created those first. The order mattered. The lifetime was whatever you decided — usually "create it once and hope for the best."
This era lasted from the early days of .NET through approximately 2008-2010, when DI containers became mainstream. But traces of it survive today in console applications, utility scripts, and any codebase that never adopted a container.
The defining characteristic of the Code era is that every relationship between objects is visible and explicit — but also manual and fragile.
// Program.cs — the entire application bootstrap, circa 2008
// Step 1: Build the dependencies bottom-up
var connectionString = "Server=localhost;Database=OrderDb;Trusted_Connection=true;";
var logger = new FileLogger("logs/app.log");
// Step 2: Build the repositories
var orderRepository = new SqlOrderRepository(connectionString, logger);
var productRepository = new SqlProductRepository(connectionString, logger);
var customerRepository = new SqlCustomerRepository(connectionString, logger);
// Step 3: Build the services
var emailService = new SmtpEmailService("smtp.company.com", 587, logger);
var inventoryService = new InventoryService(productRepository, logger);
var pricingService = new PricingService(productRepository, customerRepository, logger);
var orderValidator = new OrderValidator(inventoryService, pricingService);
var orderService = new OrderService(
orderRepository,
customerRepository,
inventoryService,
pricingService,
orderValidator,
emailService,
logger);
// Step 4: Build the controllers
var orderController = new OrderController(orderService, logger);
var productController = new ProductController(inventoryService, pricingService, logger);
var customerController = new CustomerController(customerRepository, emailService, logger);
// Step 5: Build the application
var app = new WebApplication(
orderController,
productController,
customerController);
app.Run();// Program.cs — the entire application bootstrap, circa 2008
// Step 1: Build the dependencies bottom-up
var connectionString = "Server=localhost;Database=OrderDb;Trusted_Connection=true;";
var logger = new FileLogger("logs/app.log");
// Step 2: Build the repositories
var orderRepository = new SqlOrderRepository(connectionString, logger);
var productRepository = new SqlProductRepository(connectionString, logger);
var customerRepository = new SqlCustomerRepository(connectionString, logger);
// Step 3: Build the services
var emailService = new SmtpEmailService("smtp.company.com", 587, logger);
var inventoryService = new InventoryService(productRepository, logger);
var pricingService = new PricingService(productRepository, customerRepository, logger);
var orderValidator = new OrderValidator(inventoryService, pricingService);
var orderService = new OrderService(
orderRepository,
customerRepository,
inventoryService,
pricingService,
orderValidator,
emailService,
logger);
// Step 4: Build the controllers
var orderController = new OrderController(orderService, logger);
var productController = new ProductController(inventoryService, pricingService, logger);
var customerController = new CustomerController(customerRepository, emailService, logger);
// Step 5: Build the application
var app = new WebApplication(
orderController,
productController,
customerController);
app.Run();Thirty-five lines just to wire eight services. And this is a small application. A real enterprise system — an ERP, a CRM, a trading platform — had hundreds of lines of constructor wiring spread across multiple entry points. Every test project had its own wiring. Every background worker had its own wiring. And none of them stayed in sync.
What Goes Wrong
The problem surfaces the moment someone adds a dependency. A developer adds IAuditService to OrderService:
public class OrderService : IOrderService
{
public OrderService(
IOrderRepository orderRepository,
ICustomerRepository customerRepository,
IInventoryService inventoryService,
IPricingService pricingService,
IOrderValidator orderValidator,
IEmailService emailService,
IAuditService auditService, // ← NEW dependency
ILogger logger)
{
// ...
}
}public class OrderService : IOrderService
{
public OrderService(
IOrderRepository orderRepository,
ICustomerRepository customerRepository,
IInventoryService inventoryService,
IPricingService pricingService,
IOrderValidator orderValidator,
IEmailService emailService,
IAuditService auditService, // ← NEW dependency
ILogger logger)
{
// ...
}
}The developer updates the main wiring:
var auditService = new AuditService(connectionString, logger);
var orderService = new OrderService(
orderRepository,
customerRepository,
inventoryService,
pricingService,
orderValidator,
emailService,
auditService, // ← Added here
logger);var auditService = new AuditService(connectionString, logger);
var orderService = new OrderService(
orderRepository,
customerRepository,
inventoryService,
pricingService,
orderValidator,
emailService,
auditService, // ← Added here
logger);But the application has three entry points: Program.cs, TestBootstrap.cs, and WorkerServiceHost.cs. The developer updated one. The other two still call the old constructor. The code compiles because those files still use the old overload — or worse, they were using a factory method that swallows the missing argument and passes null.
Result: NullReferenceException at 2 AM in production. The dependency was missing, and nothing in the build told anyone.
The Constructor Telescope
As applications grew, constructors became telescoping monsters:
// Real constructor from a real codebase (names changed)
public class OrderProcessingService : IOrderProcessingService
{
public OrderProcessingService(
IOrderRepository orderRepository,
ICustomerRepository customerRepository,
IProductRepository productRepository,
IInventoryService inventoryService,
IPricingService pricingService,
IDiscountService discountService,
ITaxCalculator taxCalculator,
IShippingCalculator shippingCalculator,
IPaymentGateway paymentGateway,
IOrderValidator orderValidator,
IEmailService emailService,
ISmsService smsService,
IAuditService auditService,
IMetricsCollector metricsCollector,
ICacheService cacheService,
ILogger logger)
{
_orderRepository = orderRepository ?? throw new ArgumentNullException(nameof(orderRepository));
_customerRepository = customerRepository ?? throw new ArgumentNullException(nameof(customerRepository));
_productRepository = productRepository ?? throw new ArgumentNullException(nameof(productRepository));
_inventoryService = inventoryService ?? throw new ArgumentNullException(nameof(inventoryService));
_pricingService = pricingService ?? throw new ArgumentNullException(nameof(pricingService));
_discountService = discountService ?? throw new ArgumentNullException(nameof(discountService));
_taxCalculator = taxCalculator ?? throw new ArgumentNullException(nameof(taxCalculator));
_shippingCalculator = shippingCalculator ?? throw new ArgumentNullException(nameof(shippingCalculator));
_paymentGateway = paymentGateway ?? throw new ArgumentNullException(nameof(paymentGateway));
_orderValidator = orderValidator ?? throw new ArgumentNullException(nameof(orderValidator));
_emailService = emailService ?? throw new ArgumentNullException(nameof(emailService));
_smsService = smsService ?? throw new ArgumentNullException(nameof(smsService));
_auditService = auditService ?? throw new ArgumentNullException(nameof(auditService));
_metricsCollector = metricsCollector ?? throw new ArgumentNullException(nameof(metricsCollector));
_cacheService = cacheService ?? throw new ArgumentNullException(nameof(cacheService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
}// Real constructor from a real codebase (names changed)
public class OrderProcessingService : IOrderProcessingService
{
public OrderProcessingService(
IOrderRepository orderRepository,
ICustomerRepository customerRepository,
IProductRepository productRepository,
IInventoryService inventoryService,
IPricingService pricingService,
IDiscountService discountService,
ITaxCalculator taxCalculator,
IShippingCalculator shippingCalculator,
IPaymentGateway paymentGateway,
IOrderValidator orderValidator,
IEmailService emailService,
ISmsService smsService,
IAuditService auditService,
IMetricsCollector metricsCollector,
ICacheService cacheService,
ILogger logger)
{
_orderRepository = orderRepository ?? throw new ArgumentNullException(nameof(orderRepository));
_customerRepository = customerRepository ?? throw new ArgumentNullException(nameof(customerRepository));
_productRepository = productRepository ?? throw new ArgumentNullException(nameof(productRepository));
_inventoryService = inventoryService ?? throw new ArgumentNullException(nameof(inventoryService));
_pricingService = pricingService ?? throw new ArgumentNullException(nameof(pricingService));
_discountService = discountService ?? throw new ArgumentNullException(nameof(discountService));
_taxCalculator = taxCalculator ?? throw new ArgumentNullException(nameof(taxCalculator));
_shippingCalculator = shippingCalculator ?? throw new ArgumentNullException(nameof(shippingCalculator));
_paymentGateway = paymentGateway ?? throw new ArgumentNullException(nameof(paymentGateway));
_orderValidator = orderValidator ?? throw new ArgumentNullException(nameof(orderValidator));
_emailService = emailService ?? throw new ArgumentNullException(nameof(emailService));
_smsService = smsService ?? throw new ArgumentNullException(nameof(smsService));
_auditService = auditService ?? throw new ArgumentNullException(nameof(auditService));
_metricsCollector = metricsCollector ?? throw new ArgumentNullException(nameof(metricsCollector));
_cacheService = cacheService ?? throw new ArgumentNullException(nameof(cacheService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
}Thirty-two lines of pure defensive plumbing. No business logic. No value. And every callsite must provide all sixteen arguments in the right order. The ArgumentNullException guards are the developer admitting that the wiring might be wrong — and choosing to fail loud rather than fail silently.
Era 1 cost: Every wire is manual. Every new dependency is a change to every composition root. Every missed wire is a runtime failure. The only "documentation" is the code itself — which is both a strength (nothing is hidden) and a weakness (everything is verbose). There is no enforcement because there is nothing to enforce: the developer IS the container.
Era 2: Configuration — XML Containers
The DI container was the answer. Instead of manual wiring, you declared the object graph in XML and let the container resolve it at runtime. Unity, Castle Windsor, Autofac, StructureMap, Ninject — the .NET ecosystem spawned a generation of containers, each with its own XML dialect.
The promise was compelling: separate the wiring from the logic. Deploy a new implementation by changing XML, without recompiling. Let the container manage lifetimes, scopes, and disposal. The developer focuses on business logic; the container handles the plumbing.
The reality was more complicated.
<!-- unity.config — the DI configuration, circa 2012 -->
<configuration>
<configSections>
<section name="unity"
type="Microsoft.Practices.Unity.Configuration.UnityConfigurationSection,
Unity.Configuration" />
</configSections>
<unity xmlns="http://schemas.microsoft.com/practices/2010/unity">
<alias alias="ILogger" type="MyApp.Infrastructure.Logging.FileLogger, MyApp.Infrastructure" />
<alias alias="IOrderRepository" type="MyApp.Infrastructure.Data.SqlOrderRepository, MyApp.Infrastructure" />
<alias alias="ICustomerRepository" type="MyApp.Infrastructure.Data.SqlCustomerRepository, MyApp.Infrastructure" />
<alias alias="IProductRepository" type="MyApp.Infrastructure.Data.SqlProductRepository, MyApp.Infrastructure" />
<alias alias="IInventoryService" type="MyApp.Domain.Services.InventoryService, MyApp.Domain" />
<alias alias="IPricingService" type="MyApp.Domain.Services.PricingService, MyApp.Domain" />
<alias alias="IOrderValidator" type="MyApp.Domain.Validators.OrderValidator, MyApp.Domain" />
<alias alias="IEmailService" type="MyApp.Infrastructure.Email.SmtpEmailService, MyApp.Infrastructure" />
<alias alias="IOrderService" type="MyApp.Domain.Services.OrderService, MyApp.Domain" />
<container>
<!-- Infrastructure -->
<register type="ILogger" mapTo="MyApp.Infrastructure.Logging.FileLogger, MyApp.Infrastructure">
<lifetime type="singleton" />
<constructor>
<param name="path" value="logs/app.log" />
</constructor>
</register>
<register type="IOrderRepository"
mapTo="MyApp.Infrastructure.Data.SqlOrderRepository, MyApp.Infrastructure">
<lifetime type="perrequest" />
<constructor>
<param name="connectionString" value="Server=localhost;Database=OrderDb;Trusted_Connection=true;" />
</constructor>
</register>
<register type="ICustomerRepository"
mapTo="MyApp.Infrastructure.Data.SqlCustomerRepository, MyApp.Infrastructure">
<lifetime type="perrequest" />
</register>
<register type="IProductRepository"
mapTo="MyApp.Infrastructure.Data.SqlProductRepository, MyApp.Infrastructure">
<lifetime type="perrequest" />
</register>
<!-- Domain Services -->
<register type="IInventoryService"
mapTo="MyApp.Domain.Services.InventoryService, MyApp.Domain">
<lifetime type="perrequest" />
</register>
<register type="IPricingService"
mapTo="MyApp.Domain.Services.PricingService, MyApp.Domain">
<lifetime type="perrequest" />
</register>
<register type="IOrderValidator"
mapTo="MyApp.Domain.Validators.OrderValidator, MyApp.Domain">
<lifetime type="perrequest" />
</register>
<register type="IEmailService"
mapTo="MyApp.Infrastructure.Email.SmtpEmailService, MyApp.Infrastructure">
<lifetime type="singleton" />
<constructor>
<param name="host" value="smtp.company.com" />
<param name="port" value="587" />
</constructor>
</register>
<register type="IOrderService"
mapTo="MyApp.Domain.Services.OrderService, MyApp.Domain">
<lifetime type="perrequest" />
</register>
</container>
</unity>
</configuration><!-- unity.config — the DI configuration, circa 2012 -->
<configuration>
<configSections>
<section name="unity"
type="Microsoft.Practices.Unity.Configuration.UnityConfigurationSection,
Unity.Configuration" />
</configSections>
<unity xmlns="http://schemas.microsoft.com/practices/2010/unity">
<alias alias="ILogger" type="MyApp.Infrastructure.Logging.FileLogger, MyApp.Infrastructure" />
<alias alias="IOrderRepository" type="MyApp.Infrastructure.Data.SqlOrderRepository, MyApp.Infrastructure" />
<alias alias="ICustomerRepository" type="MyApp.Infrastructure.Data.SqlCustomerRepository, MyApp.Infrastructure" />
<alias alias="IProductRepository" type="MyApp.Infrastructure.Data.SqlProductRepository, MyApp.Infrastructure" />
<alias alias="IInventoryService" type="MyApp.Domain.Services.InventoryService, MyApp.Domain" />
<alias alias="IPricingService" type="MyApp.Domain.Services.PricingService, MyApp.Domain" />
<alias alias="IOrderValidator" type="MyApp.Domain.Validators.OrderValidator, MyApp.Domain" />
<alias alias="IEmailService" type="MyApp.Infrastructure.Email.SmtpEmailService, MyApp.Infrastructure" />
<alias alias="IOrderService" type="MyApp.Domain.Services.OrderService, MyApp.Domain" />
<container>
<!-- Infrastructure -->
<register type="ILogger" mapTo="MyApp.Infrastructure.Logging.FileLogger, MyApp.Infrastructure">
<lifetime type="singleton" />
<constructor>
<param name="path" value="logs/app.log" />
</constructor>
</register>
<register type="IOrderRepository"
mapTo="MyApp.Infrastructure.Data.SqlOrderRepository, MyApp.Infrastructure">
<lifetime type="perrequest" />
<constructor>
<param name="connectionString" value="Server=localhost;Database=OrderDb;Trusted_Connection=true;" />
</constructor>
</register>
<register type="ICustomerRepository"
mapTo="MyApp.Infrastructure.Data.SqlCustomerRepository, MyApp.Infrastructure">
<lifetime type="perrequest" />
</register>
<register type="IProductRepository"
mapTo="MyApp.Infrastructure.Data.SqlProductRepository, MyApp.Infrastructure">
<lifetime type="perrequest" />
</register>
<!-- Domain Services -->
<register type="IInventoryService"
mapTo="MyApp.Domain.Services.InventoryService, MyApp.Domain">
<lifetime type="perrequest" />
</register>
<register type="IPricingService"
mapTo="MyApp.Domain.Services.PricingService, MyApp.Domain">
<lifetime type="perrequest" />
</register>
<register type="IOrderValidator"
mapTo="MyApp.Domain.Validators.OrderValidator, MyApp.Domain">
<lifetime type="perrequest" />
</register>
<register type="IEmailService"
mapTo="MyApp.Infrastructure.Email.SmtpEmailService, MyApp.Infrastructure">
<lifetime type="singleton" />
<constructor>
<param name="host" value="smtp.company.com" />
<param name="port" value="587" />
</constructor>
</register>
<register type="IOrderService"
mapTo="MyApp.Domain.Services.OrderService, MyApp.Domain">
<lifetime type="perrequest" />
</register>
</container>
</unity>
</configuration>And the bootstrapping code:
// Global.asax.cs — ASP.NET WebForms / MVC era
protected void Application_Start()
{
var container = new UnityContainer();
container.LoadConfiguration();
// Now the container resolves the entire graph
var orderService = container.Resolve<IOrderService>();
// Constructor arguments? The container figures them out.
// Missing registration? Runtime exception at first resolve.
}// Global.asax.cs — ASP.NET WebForms / MVC era
protected void Application_Start()
{
var container = new UnityContainer();
container.LoadConfiguration();
// Now the container resolves the entire graph
var orderService = container.Resolve<IOrderService>();
// Constructor arguments? The container figures them out.
// Missing registration? Runtime exception at first resolve.
}The container removed the manual wiring. No more telescoping constructors in the composition root. The container traversed the dependency graph and injected everything automatically.
And if you were using ASP.NET MVC with Unity, you also needed a custom IDependencyResolver:
// UnityDependencyResolver.cs — glue code between MVC and Unity
public class UnityDependencyResolver : IDependencyResolver
{
private readonly IUnityContainer _container;
public UnityDependencyResolver(IUnityContainer container)
{
_container = container;
}
public object GetService(Type serviceType)
{
try { return _container.Resolve(serviceType); }
catch (ResolutionFailedException) { return null; } // MVC expects null, not exceptions
}
public IEnumerable<object> GetServices(Type serviceType)
{
try { return _container.ResolveAll(serviceType); }
catch (ResolutionFailedException) { return Enumerable.Empty<object>(); }
}
}
// Global.asax.cs
protected void Application_Start()
{
var container = new UnityContainer();
container.LoadConfiguration();
DependencyResolver.SetResolver(new UnityDependencyResolver(container));
}// UnityDependencyResolver.cs — glue code between MVC and Unity
public class UnityDependencyResolver : IDependencyResolver
{
private readonly IUnityContainer _container;
public UnityDependencyResolver(IUnityContainer container)
{
_container = container;
}
public object GetService(Type serviceType)
{
try { return _container.Resolve(serviceType); }
catch (ResolutionFailedException) { return null; } // MVC expects null, not exceptions
}
public IEnumerable<object> GetServices(Type serviceType)
{
try { return _container.ResolveAll(serviceType); }
catch (ResolutionFailedException) { return Enumerable.Empty<object>(); }
}
}
// Global.asax.cs
protected void Application_Start()
{
var container = new UnityContainer();
container.LoadConfiguration();
DependencyResolver.SetResolver(new UnityDependencyResolver(container));
}The catch block that returns null is revealing. The container might fail to resolve a service. MVC's contract requires null on failure rather than an exception. So the resolver swallows the error — and the controller receives a null dependency. The NullReferenceException arrives later, disconnected from its cause, with a stack trace that says nothing about DI.
What Goes Wrong
Rename OrderService to OrderProcessingService. The C# code compiles. The XML? Still says MyApp.Domain.Services.OrderService. No red squiggle. No build error. The application starts, the container reads the XML, and — if you are lucky — throws an obscure resolution exception at startup. If you are unlucky, the registration silently falls through to a default, and the NullReferenceException arrives in production.
Unity.Exceptions.ResolutionFailedException:
Resolution of the dependency failed, type = "IOrderService",
name = "(none)".
Exception occurred while: while resolving.
Exception is: InvalidOperationException - The type
MyApp.Domain.Services.OrderService, MyApp.Domain could not be found.Unity.Exceptions.ResolutionFailedException:
Resolution of the dependency failed, type = "IOrderService",
name = "(none)".
Exception occurred while: while resolving.
Exception is: InvalidOperationException - The type
MyApp.Domain.Services.OrderService, MyApp.Domain could not be found.No IntelliSense in XML. No "Find All References" for type names in strings. No refactoring support. The XML is a parallel universe that the compiler cannot see and the IDE barely understands.
The Lifetime Trap
XML configuration also introduced a subtle class of bugs: lifetime mismatches.
<!-- A singleton that depends on a per-request service — silent disaster -->
<register type="IOrderService" mapTo="OrderService">
<lifetime type="singleton" />
</register>
<register type="IOrderRepository" mapTo="SqlOrderRepository">
<lifetime type="perrequest" />
</register><!-- A singleton that depends on a per-request service — silent disaster -->
<register type="IOrderService" mapTo="OrderService">
<lifetime type="singleton" />
</register>
<register type="IOrderRepository" mapTo="SqlOrderRepository">
<lifetime type="perrequest" />
</register>OrderService is a singleton. It holds a reference to IOrderRepository, which is per-request. After the first request completes, the repository's database connection is disposed — but the singleton still holds the reference. Every subsequent request uses a disposed connection. The failure is intermittent, hard to reproduce, and devastating in production.
Nothing in the XML syntax prevents this. Nothing in the container warns about it at startup (most containers of that era did not validate lifetime hierarchies). The bug only surfaces under load, after the first connection pool timeout.
ASP.NET Core's built-in container eventually added ValidateScopes (enabled by default in Development), which catches some lifetime mismatches at startup. But in the XML era, you were on your own. The lifetime configuration was a string — "singleton", "perrequest", "transient" — with no static analysis, no type checking, and no validation. A typo like "singletom" was silently ignored, and the default lifetime was applied instead.
The Configuration Sprawl
Large applications ended up with multiple XML configuration files — one per module, merged at startup:
<!-- unity.module.orders.config -->
<unity>
<container>
<register type="IOrderService" mapTo="OrderService" />
<register type="IOrderRepository" mapTo="SqlOrderRepository" />
</container>
</unity>
<!-- unity.module.customers.config -->
<unity>
<container>
<register type="ICustomerService" mapTo="CustomerService" />
<register type="ICustomerRepository" mapTo="SqlCustomerRepository" />
</container>
</unity>
<!-- unity.module.inventory.config -->
<unity>
<container>
<register type="IInventoryService" mapTo="InventoryService" />
<register type="IProductRepository" mapTo="SqlProductRepository" />
</container>
</unity><!-- unity.module.orders.config -->
<unity>
<container>
<register type="IOrderService" mapTo="OrderService" />
<register type="IOrderRepository" mapTo="SqlOrderRepository" />
</container>
</unity>
<!-- unity.module.customers.config -->
<unity>
<container>
<register type="ICustomerService" mapTo="CustomerService" />
<register type="ICustomerRepository" mapTo="SqlCustomerRepository" />
</container>
</unity>
<!-- unity.module.inventory.config -->
<unity>
<container>
<register type="IInventoryService" mapTo="InventoryService" />
<register type="IProductRepository" mapTo="SqlProductRepository" />
</container>
</unity>Three XML files, six registrations. Now imagine forty modules. Finding a specific registration means searching across forty XML files. "Find All References" does not work across XML configuration. The tooling gap between code and configuration was a chasm — and every day, that chasm swallowed developer hours.
Era 2 cost: The manual wiring is gone, but a parallel configuration universe has replaced it — one the compiler cannot check, the IDE cannot refactor, and the developer cannot trust.
Era 3: Convention — Assembly Scanning and Implicit Registration
ASP.NET Core's built-in DI container, combined with libraries like Scrutor, brought Convention over Configuration to dependency injection. Instead of listing every registration in XML or code, you defined rules: scan assemblies, match patterns, register automatically.
// Program.cs — ASP.NET Core with Scrutor, circa 2020
var builder = WebApplication.CreateBuilder(args);
// Explicit registrations for infrastructure
builder.Services.AddDbContext<OrderDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("OrderDb")));
// Convention: scan the assembly, find classes in specific namespaces,
// register them by their implemented interfaces
builder.Services.Scan(scan => scan
.FromAssemblyOf<OrderService>()
.AddClasses(classes => classes.InNamespaces("MyApp.Services"))
.AsImplementedInterfaces()
.WithScopedLifetime());
builder.Services.Scan(scan => scan
.FromAssemblyOf<SqlOrderRepository>()
.AddClasses(classes => classes.InNamespaces("MyApp.Repositories"))
.AsImplementedInterfaces()
.WithScopedLifetime());
builder.Services.Scan(scan => scan
.FromAssemblyOf<SmtpEmailService>()
.AddClasses(classes => classes.InNamespaces("MyApp.Infrastructure"))
.AsImplementedInterfaces()
.WithSingletonLifetime());
var app = builder.Build();// Program.cs — ASP.NET Core with Scrutor, circa 2020
var builder = WebApplication.CreateBuilder(args);
// Explicit registrations for infrastructure
builder.Services.AddDbContext<OrderDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("OrderDb")));
// Convention: scan the assembly, find classes in specific namespaces,
// register them by their implemented interfaces
builder.Services.Scan(scan => scan
.FromAssemblyOf<OrderService>()
.AddClasses(classes => classes.InNamespaces("MyApp.Services"))
.AsImplementedInterfaces()
.WithScopedLifetime());
builder.Services.Scan(scan => scan
.FromAssemblyOf<SqlOrderRepository>()
.AddClasses(classes => classes.InNamespaces("MyApp.Repositories"))
.AsImplementedInterfaces()
.WithScopedLifetime());
builder.Services.Scan(scan => scan
.FromAssemblyOf<SmtpEmailService>()
.AddClasses(classes => classes.InNamespaces("MyApp.Infrastructure"))
.AsImplementedInterfaces()
.WithSingletonLifetime());
var app = builder.Build();Fifteen lines of scanning configuration. Clean. Elegant. No XML. No listing every type. The framework discovers services automatically based on namespace conventions.
This is the era most modern .NET teams live in today. It works well. It is a genuine improvement over XML configuration. Developers who moved from Unity XML to Scrutor scanning felt the same relief that developers who moved from manual wiring to Unity felt a decade earlier.
But Convention has a cost that nobody counts at the time of adoption — a cost that only becomes visible when the team grows, when new developers join, and when the conventions start to drift from the documentation that describes them.
Here is what that cost looks like, in full.
The Documentation: The Wiki Page That Must Exist
For the convention to work, every developer on the team must know the rules. The rules are not in the code — they are in the conventions. And conventions must be documented.
Here is the wiki page. Not a hypothetical. Not a caricature. This is what the page actually looks like on teams that take conventions seriously. Teams that do not bother with this page have an even worse problem: the conventions live in one person's head, and that person is on vacation when the new developer starts.
# Service Registration Guide
**Last updated:** 2024-03-15
**Author:** Tech Lead
**Status:** ⚠️ Please verify against current codebase — some conventions may have changed since last update
## Overview
We use Scrutor for automatic service registration based on namespace conventions.
Services are discovered at startup by scanning assemblies.
## Namespace Conventions
| Namespace | Lifetime | Interface Required | Notes |
|-----------------------|-----------|--------------------|---------------------------------|
| `MyApp.Services` | Scoped | Yes (I{Name}) | Business logic services |
| `MyApp.Repositories` | Scoped | Yes (I{Name}) | Data access layer |
| `MyApp.Infrastructure` | Singleton | Yes (I{Name}) | Cross-cutting concerns |
| `MyApp.Handlers` | Transient | No | MediatR handlers (auto-wired) |
## Rules
1. **Every service MUST implement an interface** named `I{ClassName}`.
- ✅ `OrderService : IOrderService`
- ❌ `OrderService` (no interface → not discovered by Scrutor)
2. **Services MUST be in the correct namespace** for their lifetime.
- Scoped services → `MyApp.Services` or `MyApp.Repositories`
- Singleton services → `MyApp.Infrastructure`
- Mixing lifetimes within a namespace will cause incorrect registration.
3. **Do NOT register services manually** if they follow the convention.
- Manual registration + Scrutor scanning = duplicate registration → undefined behavior.
4. **Scoped services MUST NOT depend on Transient services.**
- This causes a captured dependency. The Transient is created once and reused.
- There is no compile-time check for this. Be careful.
5. **Abstract classes are excluded** from scanning.
- If you need to register an abstract base, do it manually.
6. **If your service needs constructor parameters** from configuration, register it manually:
```csharp
builder.Services.AddSingleton<IEmailService>(sp =>
new SmtpEmailService(
sp.GetRequiredService<IConfiguration>()["Smtp:Host"],
int.Parse(sp.GetRequiredService<IConfiguration>()["Smtp:Port"])));# Service Registration Guide
**Last updated:** 2024-03-15
**Author:** Tech Lead
**Status:** ⚠️ Please verify against current codebase — some conventions may have changed since last update
## Overview
We use Scrutor for automatic service registration based on namespace conventions.
Services are discovered at startup by scanning assemblies.
## Namespace Conventions
| Namespace | Lifetime | Interface Required | Notes |
|-----------------------|-----------|--------------------|---------------------------------|
| `MyApp.Services` | Scoped | Yes (I{Name}) | Business logic services |
| `MyApp.Repositories` | Scoped | Yes (I{Name}) | Data access layer |
| `MyApp.Infrastructure` | Singleton | Yes (I{Name}) | Cross-cutting concerns |
| `MyApp.Handlers` | Transient | No | MediatR handlers (auto-wired) |
## Rules
1. **Every service MUST implement an interface** named `I{ClassName}`.
- ✅ `OrderService : IOrderService`
- ❌ `OrderService` (no interface → not discovered by Scrutor)
2. **Services MUST be in the correct namespace** for their lifetime.
- Scoped services → `MyApp.Services` or `MyApp.Repositories`
- Singleton services → `MyApp.Infrastructure`
- Mixing lifetimes within a namespace will cause incorrect registration.
3. **Do NOT register services manually** if they follow the convention.
- Manual registration + Scrutor scanning = duplicate registration → undefined behavior.
4. **Scoped services MUST NOT depend on Transient services.**
- This causes a captured dependency. The Transient is created once and reused.
- There is no compile-time check for this. Be careful.
5. **Abstract classes are excluded** from scanning.
- If you need to register an abstract base, do it manually.
6. **If your service needs constructor parameters** from configuration, register it manually:
```csharp
builder.Services.AddSingleton<IEmailService>(sp =>
new SmtpEmailService(
sp.GetRequiredService<IConfiguration>()["Smtp:Host"],
int.Parse(sp.GetRequiredService<IConfiguration>()["Smtp:Port"])));What to do when adding a new service
- Create the class in the correct namespace
- Implement the correct interface (I{ClassName})
- Verify the lifetime matches the namespace convention
- If the service needs special configuration, add a manual registration in Program.cs
- Run the architecture tests to verify compliance
Common Mistakes
- Putting a service in
MyApp.Helpers→ not scanned → silently missing at runtime - Naming the interface
IOrderSvcinstead ofIOrderService→ scanned but name mismatch confusion - Registering manually AND via convention → duplicate registration, last-wins behavior
That is forty-three lines of documentation for "how services get registered." Not for what services do — just for how they get into the DI container. And this documentation exists outside the codebase, in a wiki that developers may or may not read, that may or may not reflect the current state of `Program.cs`.
### The Enforcement: The Tests That Must Exist
Documentation alone does not prevent violations. A wiki page is an honor system: it asks developers to comply. It does not force them. So the team writes tests — architectural tests that use reflection to verify that the conventions documented in the wiki are actually followed in the code.
These tests are the enforcement arm of the convention. Without them, the wiki is aspirational. With them, the wiki has teeth — but the teeth are in a different file, in a different project, run at a different time (CI, not compile time):
```csharp
// tests/ArchitectureTests/ServiceRegistrationTests.cs
using NetArchTest.Rules;
namespace MyApp.Tests.Architecture;
public class ServiceRegistrationTests
{
[Fact]
public void All_Services_In_Services_Namespace_Must_Implement_Interface()
{
var result = Types.InAssembly(typeof(OrderService).Assembly)
.That()
.ResideInNamespace("MyApp.Services")
.And()
.AreClasses()
.And()
.AreNotAbstract()
.Should()
.ImplementInterface(t => t.Name.StartsWith("I"))
.GetResult();
Assert.True(result.IsSuccessful,
$"Services without interfaces: {string.Join(", ", result.FailingTypeNames ?? [])}");
}
[Fact]
public void All_Repositories_Must_Be_In_Repositories_Namespace()
{
var result = Types.InAssembly(typeof(SqlOrderRepository).Assembly)
.That()
.ImplementInterface(typeof(IRepository<>))
.Should()
.ResideInNamespace("MyApp.Repositories")
.GetResult();
Assert.True(result.IsSuccessful,
$"Misplaced repositories: {string.Join(", ", result.FailingTypeNames ?? [])}");
}
[Fact]
public void No_Scoped_Service_Should_Depend_On_Transient_Service()
{
// This test is fragile: it relies on namespace conventions to infer lifetimes
var scopedTypes = Types.InAssembly(typeof(OrderService).Assembly)
.That()
.ResideInNamespaceContaining("Services")
.Or()
.ResideInNamespaceContaining("Repositories")
.GetTypes();
var transientTypes = Types.InAssembly(typeof(OrderService).Assembly)
.That()
.ResideInNamespaceContaining("Handlers")
.GetTypes()
.Select(t => t.FullName)
.ToHashSet();
foreach (var scopedType in scopedTypes)
{
var constructorParams = scopedType
.GetConstructors()
.SelectMany(c => c.GetParameters())
.Select(p => p.ParameterType);
foreach (var param in constructorParams)
{
var implType = param.IsInterface
? Types.InAssembly(typeof(OrderService).Assembly)
.That()
.ImplementInterface(param)
.GetTypes()
.FirstOrDefault()
: param;
if (implType != null && transientTypes.Contains(implType.FullName))
{
Assert.Fail(
$"Scoped service {scopedType.Name} depends on Transient {implType.Name}. " +
$"This causes a captured dependency.");
}
}
}
}
[Fact]
public void All_Services_Must_Be_Registered_In_DI_Container()
{
// Build the actual service provider and check registrations
var builder = WebApplication.CreateBuilder();
// ... replicate the Program.cs configuration ...
var provider = builder.Services.BuildServiceProvider();
var serviceTypes = Types.InAssembly(typeof(OrderService).Assembly)
.That()
.ResideInNamespace("MyApp.Services")
.And()
.AreClasses()
.And()
.AreNotAbstract()
.GetTypes();
foreach (var serviceType in serviceTypes)
{
var interfaceType = serviceType.GetInterfaces()
.FirstOrDefault(i => i.Name == $"I{serviceType.Name}");
if (interfaceType == null) continue;
var resolved = provider.GetService(interfaceType);
Assert.NotNull(resolved);
}
}
}
That is forty-three lines of documentation for "how services get registered." Not for what services do — just for how they get into the DI container. And this documentation exists outside the codebase, in a wiki that developers may or may not read, that may or may not reflect the current state of `Program.cs`.
### The Enforcement: The Tests That Must Exist
Documentation alone does not prevent violations. A wiki page is an honor system: it asks developers to comply. It does not force them. So the team writes tests — architectural tests that use reflection to verify that the conventions documented in the wiki are actually followed in the code.
These tests are the enforcement arm of the convention. Without them, the wiki is aspirational. With them, the wiki has teeth — but the teeth are in a different file, in a different project, run at a different time (CI, not compile time):
```csharp
// tests/ArchitectureTests/ServiceRegistrationTests.cs
using NetArchTest.Rules;
namespace MyApp.Tests.Architecture;
public class ServiceRegistrationTests
{
[Fact]
public void All_Services_In_Services_Namespace_Must_Implement_Interface()
{
var result = Types.InAssembly(typeof(OrderService).Assembly)
.That()
.ResideInNamespace("MyApp.Services")
.And()
.AreClasses()
.And()
.AreNotAbstract()
.Should()
.ImplementInterface(t => t.Name.StartsWith("I"))
.GetResult();
Assert.True(result.IsSuccessful,
$"Services without interfaces: {string.Join(", ", result.FailingTypeNames ?? [])}");
}
[Fact]
public void All_Repositories_Must_Be_In_Repositories_Namespace()
{
var result = Types.InAssembly(typeof(SqlOrderRepository).Assembly)
.That()
.ImplementInterface(typeof(IRepository<>))
.Should()
.ResideInNamespace("MyApp.Repositories")
.GetResult();
Assert.True(result.IsSuccessful,
$"Misplaced repositories: {string.Join(", ", result.FailingTypeNames ?? [])}");
}
[Fact]
public void No_Scoped_Service_Should_Depend_On_Transient_Service()
{
// This test is fragile: it relies on namespace conventions to infer lifetimes
var scopedTypes = Types.InAssembly(typeof(OrderService).Assembly)
.That()
.ResideInNamespaceContaining("Services")
.Or()
.ResideInNamespaceContaining("Repositories")
.GetTypes();
var transientTypes = Types.InAssembly(typeof(OrderService).Assembly)
.That()
.ResideInNamespaceContaining("Handlers")
.GetTypes()
.Select(t => t.FullName)
.ToHashSet();
foreach (var scopedType in scopedTypes)
{
var constructorParams = scopedType
.GetConstructors()
.SelectMany(c => c.GetParameters())
.Select(p => p.ParameterType);
foreach (var param in constructorParams)
{
var implType = param.IsInterface
? Types.InAssembly(typeof(OrderService).Assembly)
.That()
.ImplementInterface(param)
.GetTypes()
.FirstOrDefault()
: param;
if (implType != null && transientTypes.Contains(implType.FullName))
{
Assert.Fail(
$"Scoped service {scopedType.Name} depends on Transient {implType.Name}. " +
$"This causes a captured dependency.");
}
}
}
}
[Fact]
public void All_Services_Must_Be_Registered_In_DI_Container()
{
// Build the actual service provider and check registrations
var builder = WebApplication.CreateBuilder();
// ... replicate the Program.cs configuration ...
var provider = builder.Services.BuildServiceProvider();
var serviceTypes = Types.InAssembly(typeof(OrderService).Assembly)
.That()
.ResideInNamespace("MyApp.Services")
.And()
.AreClasses()
.And()
.AreNotAbstract()
.GetTypes();
foreach (var serviceType in serviceTypes)
{
var interfaceType = serviceType.GetInterfaces()
.FirstOrDefault(i => i.Name == $"I{serviceType.Name}");
if (interfaceType == null) continue;
var resolved = provider.GetService(interfaceType);
Assert.NotNull(resolved);
}
}
}That is eighty-five lines of enforcement code. Tests that exist solely to verify that developers followed the conventions. Tests that use reflection to infer lifetimes from namespaces — because the convention system has no first-class notion of lifetime. Tests that must be maintained every time the convention evolves.
And these tests have their own failure modes. The No_Scoped_Service_Should_Depend_On_Transient_Service test uses reflection to traverse constructors and infer lifetimes from namespace placement. If someone creates a new namespace — say MyApp.Projections — the test does not know what lifetime to assign. The test silently ignores services in unknown namespaces. The very gap that the test was supposed to close opens again in a different location.
Convention enforcement tests are recursive: they police conventions, but they themselves follow conventions (naming, assertion patterns, coverage expectations) that are not enforced by anything. It is turtles all the way down.
Counting the Convention Tax
Let us be precise about what "services get registered" costs in the Convention era:
📄 wiki/service-registration-guide.md 43 lines (documentation)
📄 tests/ServiceRegistrationTests.cs 85 lines (enforcement)
📄 Program.cs — Scrutor config 15 lines (implementation)
──────────────────────────────────────────────────────
Total convention overhead: 143 lines
Lines that register services: 15
Lines that document and enforce conventions: 128📄 wiki/service-registration-guide.md 43 lines (documentation)
📄 tests/ServiceRegistrationTests.cs 85 lines (enforcement)
📄 Program.cs — Scrutor config 15 lines (implementation)
──────────────────────────────────────────────────────
Total convention overhead: 143 lines
Lines that register services: 15
Lines that document and enforce conventions: 128One hundred and twenty-eight lines of overhead for fifteen lines of actual configuration. The ratio is 8.5:1. For every line of useful DI configuration, there are 8.5 lines of documentation and enforcement. And all 128 lines of overhead must be maintained independently of the code they describe.
And this is a conservative estimate. It assumes the wiki page stays current (it will not), the tests stay in sync with evolving conventions (they lag), and new developers actually find and read the documentation before writing their first service (they ask a colleague instead, who gives them a partial answer from memory).
This is the Convention Tax. And DI is just one domain. Multiply this across validation, serialization, architecture boundaries, configuration binding, error handling, and logging — ten domains in a typical enterprise application — and you have a codebase where convention overhead exceeds feature code. A 143-line tax in ten domains is 1,430 lines of documentation and enforcement that contribute zero business value. And every one of those 1,430 lines can drift out of sync with the code it describes.
Era 4: Contention — Attribute-Driven Generation
What if registering a service required no wiki page to explain, no test to enforce, and no scanning configuration to maintain? What if the intent was declared on the class itself, and the compiler did the rest?
What if registering a service looked like this:
[Injectable(Lifetime.Scoped)]
public class OrderService : IOrderService
{
public OrderService(
IOrderRepository orderRepository,
ICustomerRepository customerRepository,
IInventoryService inventoryService,
IPricingService pricingService,
IOrderValidator orderValidator,
IEmailService emailService,
ILogger<OrderService> logger)
{
// Business logic here. No registration ceremony.
}
}[Injectable(Lifetime.Scoped)]
public class OrderService : IOrderService
{
public OrderService(
IOrderRepository orderRepository,
ICustomerRepository customerRepository,
IInventoryService inventoryService,
IPricingService pricingService,
IOrderValidator orderValidator,
IEmailService emailService,
ILogger<OrderService> logger)
{
// Business logic here. No registration ceremony.
}
}One attribute. That is all the developer writes. The rest is handled by the compiler.
The Attribute
The foundation is a simple attribute and an enum. This is the entire API surface that the developer interacts with. Everything else is generated.
namespace MyApp.DI;
/// <summary>
/// Marks a class for automatic DI registration.
/// The Source Generator reads this attribute at compile time and produces
/// the registration code. The Roslyn Analyzer validates lifetime rules.
/// </summary>
[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
public sealed class InjectableAttribute : Attribute
{
public Lifetime Lifetime { get; }
public InjectableAttribute(Lifetime lifetime = Lifetime.Scoped)
{
Lifetime = lifetime;
}
}
public enum Lifetime
{
Transient,
Scoped,
Singleton
}namespace MyApp.DI;
/// <summary>
/// Marks a class for automatic DI registration.
/// The Source Generator reads this attribute at compile time and produces
/// the registration code. The Roslyn Analyzer validates lifetime rules.
/// </summary>
[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
public sealed class InjectableAttribute : Attribute
{
public Lifetime Lifetime { get; }
public InjectableAttribute(Lifetime lifetime = Lifetime.Scoped)
{
Lifetime = lifetime;
}
}
public enum Lifetime
{
Transient,
Scoped,
Singleton
}The Services: Decorated with Intent
Each service declares its own lifetime. No external file. No namespace convention. The attribute lives on the class — version-controlled, code-reviewed, and refactored alongside the class it describes.
// Domain/Services/OrderService.cs
[Injectable(Lifetime.Scoped)]
public class OrderService : IOrderService
{
public OrderService(
IOrderRepository orderRepository,
ICustomerRepository customerRepository,
IInventoryService inventoryService)
{ /* ... */ }
public async Task<OrderResult> CreateOrder(CreateOrderCommand command) { /* ... */ }
}
// Domain/Services/PricingService.cs
[Injectable(Lifetime.Scoped)]
public class PricingService : IPricingService
{
public PricingService(
IProductRepository productRepository,
ICustomerRepository customerRepository)
{ /* ... */ }
public decimal CalculatePrice(Product product, Customer customer) { /* ... */ }
}
// Infrastructure/Repositories/SqlOrderRepository.cs
[Injectable(Lifetime.Scoped)]
public class SqlOrderRepository : IOrderRepository
{
public SqlOrderRepository(OrderDbContext dbContext) { /* ... */ }
}
// Infrastructure/Email/SmtpEmailService.cs
[Injectable(Lifetime.Singleton)]
public class SmtpEmailService : IEmailService
{
public SmtpEmailService(IOptions<SmtpSettings> settings, ILogger<SmtpEmailService> logger)
{ /* ... */ }
}
// Infrastructure/Cache/RedisCacheService.cs
[Injectable(Lifetime.Singleton)]
public class RedisCacheService : ICacheService
{
public RedisCacheService(IOptions<RedisSettings> settings) { /* ... */ }
}// Domain/Services/OrderService.cs
[Injectable(Lifetime.Scoped)]
public class OrderService : IOrderService
{
public OrderService(
IOrderRepository orderRepository,
ICustomerRepository customerRepository,
IInventoryService inventoryService)
{ /* ... */ }
public async Task<OrderResult> CreateOrder(CreateOrderCommand command) { /* ... */ }
}
// Domain/Services/PricingService.cs
[Injectable(Lifetime.Scoped)]
public class PricingService : IPricingService
{
public PricingService(
IProductRepository productRepository,
ICustomerRepository customerRepository)
{ /* ... */ }
public decimal CalculatePrice(Product product, Customer customer) { /* ... */ }
}
// Infrastructure/Repositories/SqlOrderRepository.cs
[Injectable(Lifetime.Scoped)]
public class SqlOrderRepository : IOrderRepository
{
public SqlOrderRepository(OrderDbContext dbContext) { /* ... */ }
}
// Infrastructure/Email/SmtpEmailService.cs
[Injectable(Lifetime.Singleton)]
public class SmtpEmailService : IEmailService
{
public SmtpEmailService(IOptions<SmtpSettings> settings, ILogger<SmtpEmailService> logger)
{ /* ... */ }
}
// Infrastructure/Cache/RedisCacheService.cs
[Injectable(Lifetime.Singleton)]
public class RedisCacheService : ICacheService
{
public RedisCacheService(IOptions<RedisSettings> settings) { /* ... */ }
}Five services. Five attributes. No namespace conventions. No wiki page explaining which namespace maps to which lifetime. The lifetime is declared on the class itself — visible, explicit, and compiler-checked.
Notice something else: the services live wherever makes sense for the domain. OrderService is in Domain/Services/. SqlOrderRepository is in Infrastructure/Repositories/. SmtpEmailService is in Infrastructure/Email/. The folder structure reflects the domain — not the DI convention. In the Convention era, the namespace had to match the Scrutor scanning rule. In the Contention era, the namespace is free to reflect the architecture. The attribute carries the DI metadata; the namespace carries the domain meaning.
What the Source Generator Produces
At compile time, the Source Generator scans every class decorated with [Injectable], reads the lifetime, finds the implemented interfaces, and generates a single extension method. The generated file is deterministic: the same source code always produces the same generated output. There are no runtime surprises, no assembly scanning order dependencies, no race conditions during startup.
Here is the complete generated output for our five services:
// <auto-generated>
// This file was generated by the Injectable Source Generator.
// Do not modify — changes will be overwritten on next build.
// Generated at: 2026-03-29T14:22:07Z
// </auto-generated>
#nullable enable
using Microsoft.Extensions.DependencyInjection;
namespace MyApp.DI.Generated;
public static class InjectableServiceCollectionExtensions
{
/// <summary>
/// Registers all [Injectable]-decorated services discovered at compile time.
/// <para>Services registered:</para>
/// <list type="bullet">
/// <item><see cref="OrderService"/> as <see cref="IOrderService"/> (Scoped)</item>
/// <item><see cref="PricingService"/> as <see cref="IPricingService"/> (Scoped)</item>
/// <item><see cref="SqlOrderRepository"/> as <see cref="IOrderRepository"/> (Scoped)</item>
/// <item><see cref="SmtpEmailService"/> as <see cref="IEmailService"/> (Singleton)</item>
/// <item><see cref="RedisCacheService"/> as <see cref="ICacheService"/> (Singleton)</item>
/// </list>
/// </summary>
public static IServiceCollection AddDomainServices(this IServiceCollection services)
{
// Scoped registrations
services.AddScoped<IOrderService, OrderService>();
services.AddScoped<IPricingService, PricingService>();
services.AddScoped<IOrderRepository, SqlOrderRepository>();
// Singleton registrations
services.AddSingleton<IEmailService, SmtpEmailService>();
services.AddSingleton<ICacheService, RedisCacheService>();
return services;
}
}// <auto-generated>
// This file was generated by the Injectable Source Generator.
// Do not modify — changes will be overwritten on next build.
// Generated at: 2026-03-29T14:22:07Z
// </auto-generated>
#nullable enable
using Microsoft.Extensions.DependencyInjection;
namespace MyApp.DI.Generated;
public static class InjectableServiceCollectionExtensions
{
/// <summary>
/// Registers all [Injectable]-decorated services discovered at compile time.
/// <para>Services registered:</para>
/// <list type="bullet">
/// <item><see cref="OrderService"/> as <see cref="IOrderService"/> (Scoped)</item>
/// <item><see cref="PricingService"/> as <see cref="IPricingService"/> (Scoped)</item>
/// <item><see cref="SqlOrderRepository"/> as <see cref="IOrderRepository"/> (Scoped)</item>
/// <item><see cref="SmtpEmailService"/> as <see cref="IEmailService"/> (Singleton)</item>
/// <item><see cref="RedisCacheService"/> as <see cref="ICacheService"/> (Singleton)</item>
/// </list>
/// </summary>
public static IServiceCollection AddDomainServices(this IServiceCollection services)
{
// Scoped registrations
services.AddScoped<IOrderService, OrderService>();
services.AddScoped<IPricingService, PricingService>();
services.AddScoped<IOrderRepository, SqlOrderRepository>();
// Singleton registrations
services.AddSingleton<IEmailService, SmtpEmailService>();
services.AddSingleton<ICacheService, RedisCacheService>();
return services;
}
}The generated code is plain, readable, debuggable. No reflection. No assembly scanning at runtime. No Scrutor. The container receives explicit registrations — the same code a developer would write by hand, but produced by the compiler from declared intent.
This is worth emphasizing: the generated code is not hidden. It appears in the IDE under the "Analyzers" node in Solution Explorer. A developer can open it, read it, set breakpoints in it (in Debug builds), and verify that the registrations match their expectations. The generated code is more transparent than Scrutor's runtime scanning — because you can see exactly what the container will receive, before the application starts.
If a service is missing from the generated output, the developer does not need to debug Scrutor's namespace filtering or wonder if the assembly was loaded. They check whether [Injectable] is on the class. If it is, the registration is in the generated file. If it is not, the analyzer already told them.
And Program.cs becomes:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<OrderDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("OrderDb")));
builder.Services.AddDomainServices(); // ← One line. All [Injectable] services registered.
var app = builder.Build();var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<OrderDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("OrderDb")));
builder.Services.AddDomainServices(); // ← One line. All [Injectable] services registered.
var app = builder.Build();What the Analyzer Enforces
The Source Generator produces the code. The Roslyn Analyzer guards the boundaries. Together they form a closed system: the SG handles the happy path (correct registrations), and the analyzer handles every deviation from the happy path (missing attributes, wrong lifetimes, circular dependencies, missing interfaces).
Four diagnostics, all emitted at compile time:
REG001: Missing [Injectable] Attribute
A class implements a service interface but lacks [Injectable]. The developer probably forgot.
public class AuditService : IAuditService // REG001: Class 'AuditService' implements
{ // 'IAuditService' but is not decorated with
// ... // [Injectable]. Add [Injectable(Lifetime.Scoped)]
} // or suppress this diagnostic if intentional.public class AuditService : IAuditService // REG001: Class 'AuditService' implements
{ // 'IAuditService' but is not decorated with
// ... // [Injectable]. Add [Injectable(Lifetime.Scoped)]
} // or suppress this diagnostic if intentional.Build output:
error REG001: Class 'AuditService' implements interface 'IAuditService'
but is not marked with [Injectable]. This service will not
be registered in the DI container.
[MyApp.Domain/Services/AuditService.cs(3,14)]error REG001: Class 'AuditService' implements interface 'IAuditService'
but is not marked with [Injectable]. This service will not
be registered in the DI container.
[MyApp.Domain/Services/AuditService.cs(3,14)]REG002: [Injectable] Without Interface
A class has [Injectable] but implements no interface. This is likely a mistake — the container registers interface-to-implementation mappings.
[Injectable(Lifetime.Scoped)]
public class HelperUtility // REG002: 'HelperUtility' is marked [Injectable]
{ // but implements no interface. DI registration
// ... // requires an interface. Either add an interface
} // or remove [Injectable].[Injectable(Lifetime.Scoped)]
public class HelperUtility // REG002: 'HelperUtility' is marked [Injectable]
{ // but implements no interface. DI registration
// ... // requires an interface. Either add an interface
} // or remove [Injectable].Build output:
warning REG002: Class 'HelperUtility' is decorated with [Injectable] but
does not implement any interface. The source generator
cannot produce a registration without a service type.
[MyApp.Domain/Helpers/HelperUtility.cs(1,2)]warning REG002: Class 'HelperUtility' is decorated with [Injectable] but
does not implement any interface. The source generator
cannot produce a registration without a service type.
[MyApp.Domain/Helpers/HelperUtility.cs(1,2)]REG003: Lifetime Mismatch
A Scoped service depends on a Transient service. This is the "captured dependency" problem — the Transient instance is created once and held by the Scoped service for the entire request lifetime, defeating the purpose of Transient.
[Injectable(Lifetime.Transient)]
public class NotificationFormatter : INotificationFormatter { /* ... */ }
[Injectable(Lifetime.Scoped)]
public class OrderService : IOrderService
{
public OrderService(
INotificationFormatter formatter) // REG003: Scoped service 'OrderService'
{ // depends on Transient service
// ... // 'NotificationFormatter'. This creates
} // a captured dependency.
}[Injectable(Lifetime.Transient)]
public class NotificationFormatter : INotificationFormatter { /* ... */ }
[Injectable(Lifetime.Scoped)]
public class OrderService : IOrderService
{
public OrderService(
INotificationFormatter formatter) // REG003: Scoped service 'OrderService'
{ // depends on Transient service
// ... // 'NotificationFormatter'. This creates
} // a captured dependency.
}Build output:
error REG003: Scoped service 'OrderService' has a constructor dependency
on 'INotificationFormatter' which is registered as Transient
(via 'NotificationFormatter'). A Scoped service holding a
Transient dependency creates a captured dependency — the
Transient instance will live for the entire scope instead
of being recreated. Change 'NotificationFormatter' to Scoped
or 'OrderService' to Transient.
[MyApp.Domain/Services/OrderService.cs(8,9)]error REG003: Scoped service 'OrderService' has a constructor dependency
on 'INotificationFormatter' which is registered as Transient
(via 'NotificationFormatter'). A Scoped service holding a
Transient dependency creates a captured dependency — the
Transient instance will live for the entire scope instead
of being recreated. Change 'NotificationFormatter' to Scoped
or 'OrderService' to Transient.
[MyApp.Domain/Services/OrderService.cs(8,9)]REG004: Circular Dependency
The Source Generator builds the full dependency graph at compile time. If it detects a cycle, it reports it immediately — not as a runtime StackOverflowException, but as a compile error with the full cycle path.
[Injectable(Lifetime.Scoped)]
public class ServiceA : IServiceA
{
public ServiceA(IServiceB b) { }
}
[Injectable(Lifetime.Scoped)]
public class ServiceB : IServiceB
{
public ServiceB(IServiceC c) { }
}
[Injectable(Lifetime.Scoped)]
public class ServiceC : IServiceC
{
public ServiceC(IServiceA a) { } // REG004: Circular dependency
}[Injectable(Lifetime.Scoped)]
public class ServiceA : IServiceA
{
public ServiceA(IServiceB b) { }
}
[Injectable(Lifetime.Scoped)]
public class ServiceB : IServiceB
{
public ServiceB(IServiceC c) { }
}
[Injectable(Lifetime.Scoped)]
public class ServiceC : IServiceC
{
public ServiceC(IServiceA a) { } // REG004: Circular dependency
}Build output:
error REG004: Circular dependency detected:
ServiceA → IServiceB (ServiceB)
→ IServiceC (ServiceC)
→ IServiceA (ServiceA) ← cycle
Break the cycle by introducing an interface, using
Lazy<T>, or restructuring the dependency graph.
[MyApp.Domain/Services/ServiceC.cs(4,27)]error REG004: Circular dependency detected:
ServiceA → IServiceB (ServiceB)
→ IServiceC (ServiceC)
→ IServiceA (ServiceA) ← cycle
Break the cycle by introducing an interface, using
Lazy<T>, or restructuring the dependency graph.
[MyApp.Domain/Services/ServiceC.cs(4,27)]In the Code and Configuration eras, circular dependencies caused a StackOverflowException at runtime — or in the case of a container, an inscrutable resolution error buried in a ten-level-deep stack trace. In the Convention era, if you were lucky, the container threw at startup; if not, you discovered it under load. In the Contention era, the IDE underlines the offending constructor parameter in red before you finish typing.
What the Developer Writes vs. What the Compiler Produces
Let us count precisely what each party contributes. The developer writes one attribute per service. The compiler produces everything else:
Developer writes:
[Injectable(Lifetime.Scoped)] on OrderService ← 1 attribute
[Injectable(Lifetime.Scoped)] on PricingService ← 1 attribute
[Injectable(Lifetime.Scoped)] on SqlOrderRepository ← 1 attribute
[Injectable(Lifetime.Singleton)] on SmtpEmailService ← 1 attribute
[Injectable(Lifetime.Singleton)] on RedisCacheService ← 1 attribute
builder.Services.AddDomainServices() ← 1 line in Program.cs
Compiler produces:
InjectableServiceCollectionExtensions.g.cs ← full registration code
Lifetime validation (REG003) ← enforced at compile time
Circular dependency detection (REG004) ← enforced at compile time
Missing registration detection (REG001) ← enforced at compile time
Interface requirement validation (REG002) ← enforced at compile timeDeveloper writes:
[Injectable(Lifetime.Scoped)] on OrderService ← 1 attribute
[Injectable(Lifetime.Scoped)] on PricingService ← 1 attribute
[Injectable(Lifetime.Scoped)] on SqlOrderRepository ← 1 attribute
[Injectable(Lifetime.Singleton)] on SmtpEmailService ← 1 attribute
[Injectable(Lifetime.Singleton)] on RedisCacheService ← 1 attribute
builder.Services.AddDomainServices() ← 1 line in Program.cs
Compiler produces:
InjectableServiceCollectionExtensions.g.cs ← full registration code
Lifetime validation (REG003) ← enforced at compile time
Circular dependency detection (REG004) ← enforced at compile time
Missing registration detection (REG001) ← enforced at compile time
Interface requirement validation (REG002) ← enforced at compile timeNo wiki page. No architecture tests. No Scrutor configuration. No namespace conventions to learn, document, or enforce. The entire DI registration story fits in two concepts: an attribute and a single extension method call. A new developer can understand the system by reading any one service class.
The Convention Tax: Side by Side
We have now seen all the artifacts. Let us lay them side by side. Every artifact that must exist for DI registration to work correctly, across Convention and Contention:
| Artifact | Convention (Era 3) | Contention (Era 4) |
|---|---|---|
| Documentation | 43 lines — wiki page explaining namespace rules, lifetime mapping, registration patterns, common mistakes | 0 lines — the [Injectable(Lifetime.Scoped)] attribute on the class IS the documentation. A new developer reads the attribute and knows the lifetime. |
| Enforcement | 85 lines — NetArchTest tests checking namespace placement, interface implementation, lifetime mismatches, registration completeness | 0 lines — the Roslyn Analyzer emits REG001-REG004 at compile time. No test code to write or maintain. |
| Implementation | 15 lines — Scrutor scanning configuration in Program.cs | 1 attribute per service + 1 line in Program.cs (AddDomainServices()) |
| Generated code | 0 lines — runtime assembly scanning | ~30 lines — explicit AddScoped/AddSingleton calls, fully visible in IDE |
| Developer writes | 143 lines total (128 overhead + 15 config) | 6 lines total (5 attributes + 1 registration call) |
| Convention drift risk | High — wiki diverges from code, tests lag behind refactors | None — attribute is on the class, generated code is always current |
| Failure mode | Runtime (missing service) or CI (test failure) | Compile time (REG001-REG004) |
| Onboarding cost | Read wiki + ask colleague + run tests | See attribute on existing code + copy pattern |
| Refactoring safety | Rename breaks Scrutor if namespace changes | Rename is safe — attribute travels with the class |
The ratio is 143:6 — roughly 24:1. For every line a developer writes in the Contention era, the Convention era required twenty-four lines of documentation, enforcement, and configuration.
But the numbers alone understate the difference. The 143 Convention lines are spread across three files in three different locations (wiki, test project, startup code) maintained by three different processes (documentation reviews, test maintenance, code reviews). The 6 Contention lines are co-located with the code they describe — each attribute lives on the class it registers. There is exactly one place to look, one thing to maintain, and one source of truth.
Visualizing the Progression
Three diagrams tell the story: what the mechanism looks like in each era, where failures surface, and what the Convention Tax costs in concrete artifacts.
2. What Happens When You Forget?
The defining question for any system is not "how does it work when everything is correct?" but "what happens when someone makes a mistake?" Each era moves the failure earlier in the development lifecycle:
A Day-in-the-Life Comparison
Scenario: A new developer joins the team. Their first task is to create an AuditService that logs order changes to a separate database. It must be Scoped (one instance per HTTP request) and implement IAuditService.
Convention era — the new developer's journey:
- Creates
AuditService.csinMyApp.Domain/Services/(because that seemed right). - Implements
IAuditService. Writes the business logic. Runs the app locally. Gets a runtime error:InvalidOperationException: Unable to resolve service for type 'IAuditService'. - Asks a colleague. Colleague says "Did you check the wiki?" The developer finds the wiki. Reads it. Realizes the namespace is wrong for Scrutor to pick it up — the class is in
MyApp.Domain.Servicesbut Scrutor scansMyApp.Services. - Moves the class. Runs again. It works. But they never ran the architecture tests locally.
- Pushes to CI. The
All_Repositories_Must_Be_In_Repositories_Namespacetest fails because the audit repository was placed in the wrong namespace. Fixes that. Pushes again. - CI passes. PR merged. Total time: 3 hours for a class that took 30 minutes to write.
Contention era — the same developer:
- Creates
AuditService.csanywhere in the project. Adds[Injectable(Lifetime.Scoped)]. ImplementsIAuditService. Writes the business logic. - Builds. Green. The Source Generator added the registration automatically.
- Pushes. CI passes. PR merged. Total time: 35 minutes.
The developer never read a wiki. Never ran an architecture test. Never asked a colleague where to put the file. The attribute told the compiler everything it needed, and the compiler told the developer everything they got wrong — in real time, in the editor.
The time difference is not about typing speed. It is about feedback loops. The Convention era's feedback loop runs through wiki → code → CI → fix → CI. The Contention era's feedback loop runs through attribute → compile → done. One loop takes hours. The other takes seconds.
The Step-by-Step Contrast
In the Convention era, adding a new service requires five steps:
- Create the class in the correct namespace (consult wiki if unsure).
- Implement the correct interface with the correct naming pattern.
- Verify the lifetime matches the namespace convention (consult wiki).
- Run the architecture tests locally to confirm compliance.
- Hope nobody else on the team added a manual registration that conflicts with the scanning.
In the Contention era, adding a new service requires one step:
- Add
[Injectable(Lifetime.Scoped)]to the class.
If the developer forgets the attribute, the analyzer flags it. If the developer uses the wrong lifetime, the analyzer catches the mismatch. If the developer creates a circular dependency, the analyzer reports the full cycle. If the developer decorates a class with no interface, the analyzer warns.
There is no wiki to consult, because the attribute IS the documentation. There is no test to run, because the analyzer IS the enforcement. There is no scanning configuration to maintain, because the Source Generator IS the implementation.
The compiler does not trust the developer to follow conventions. It generates the correct code from declared intent, and it refuses to compile incorrect code. That is Contention.
And the onboarding cost drops to near zero. A new developer does not need to read a wiki, memorize namespace rules, or learn which architecture tests exist. They see [Injectable(Lifetime.Scoped)] on an existing service, copy the pattern, and the compiler handles the rest. If they get it wrong, the compiler tells them — immediately, in the editor, with a fix suggestion. The attribute is the documentation and the enforcement in a single token.
Cross-References
This pattern — attribute declares intent, Source Generator produces code, Analyzer guards boundaries — is the meta-pattern of the entire series. It is the same pattern that drives the Builder Pattern and DDD posts. The [Injectable] attribute follows the same philosophy as [AggregateRoot]: one declaration, full generation, compile-time enforcement. In each case, the attribute is small — a single line — but it carries enough semantic weight for the Source Generator to produce dozens or hundreds of lines of correct, consistent, maintainable code. The developer's cognitive burden shifts from "remember and follow the rules" to "declare the intent and let the compiler handle the rules."
In Part III, we apply the same framework to validation — another domain where Convention says "every command must have a validator" but only code review enforces it. The wiki page exists. The ArchUnit test exists. And somewhere, a command without a validator is already in production. Contention makes the compiler generate the validator from required properties — and the analyzer refuses to compile a command that lacks one.
The Pattern Emerging
DI is just one domain, but it reveals the universal pattern. Across all four eras, the progression follows the same arc:
- Code — the developer does everything manually. Failures are runtime.
- Configuration — the wiring moves to external files. Failures are startup/deploy.
- Convention — the framework discovers things automatically, but the rules are invisible. Failures are CI (tests) or runtime (missed conventions). Documentation and enforcement are required.
- Contention — the developer declares intent with an attribute. The compiler generates code and guards boundaries. Failures are compile-time. No documentation to write. No enforcement code to maintain. No drift.
DI is the simplest domain to see this pattern in, because the artifacts are concrete: a registration line, a wiki page, a test file. But the pattern scales. Validation has the same four eras. Serialization has them. Architecture enforcement has them. And in every domain, the Convention Tax follows the same formula:
Convention Tax = Documentation + Enforcement + Drift RiskConvention Tax = Documentation + Enforcement + Drift RiskIn DI, we measured it: 43 + 85 + constant vigilance = 143 lines of overhead for 15 lines of configuration. In validation, the numbers will be different, but the formula is the same. In architecture enforcement, the documentation is ADRs instead of wiki pages, and the enforcement is NetArchTest instead of registration tests — but the pattern is identical.
The Contention answer is always the same: collapse all three to zero by moving the intent into the type system, where the compiler can see it, generate from it, and guard it.
Next: Part III: Validation — from manual if-checks to [Validated] source-generated validators.