DEV Community

Cristian Sifuentes
Cristian Sifuentes

Posted on

Stop Writing DI Constructors and Manually Registering .NET Services — Do This Instead (2026)

Stop Writing DI Constructors and Manually Registering .NET Services — Do This Instead (2026)

Stop Writing DI Constructors and Manually Registering .NET Services — Do This Instead (2026)

If you’ve been building serious .NET systems for a while—APIs, background workers, Blazor apps, multi-tenant platforms—you already know the rhythm of Dependency Injection:

1) add a private field

2) add a constructor parameter

3) assign the parameter to the field

4) go to Program.cs

5) register the service

6) repeat until your soul leaves your body

This workflow isn’t “hard”. It’s just friction—and worse, it’s friction that creates runtime failures when you miss a registration or when refactors drift out of sync with Program.cs.

It’s 2026. We can keep DI as a compile-time contract and stop treating constructors and registrations as an endless copy/paste ceremony.

This post shows a clean approach using compile-time source generation (no reflection, no runtime scanning) with:

  • MintPlayer.SourceGenerators
  • MintPlayer.SourceGenerators.Attributes

The pitch is simple:

  • Constructor? Generated.
  • Service registration? Generated.
  • Inheritance forwarding? Generated.
  • Initialization after injection? Supported.
  • AOT / trimming? Friendly.

And you still ship plain .NET code with predictable behavior.


TL;DR

Before (typical DI pain):

public class UserService : IUserService
{
    private readonly IUserRepository _userRepository;
    private readonly IEmailService _emailService;
    private readonly ILogger<UserService> _logger;
    private readonly IConfiguration _configuration;

    public UserService(
        IUserRepository userRepository,
        IEmailService emailService,
        ILogger<UserService> logger,
        IConfiguration configuration)
    {
        _userRepository = userRepository;
        _emailService = emailService;
        _logger = logger;
        _configuration = configuration;
    }
}

// Program.cs
services.AddScoped<IUserRepository, UserRepository>();
services.AddScoped<IEmailService, EmailService>();
services.AddScoped<IUserService, UserService>();
// ...repeat for 50+ services
Enter fullscreen mode Exit fullscreen mode

After (compile-time DI ergonomics):

[Register(typeof(IUserService), ServiceLifetime.Scoped)]
public partial class UserService : IUserService
{
    [Inject] private readonly IUserRepository _userRepository;
    [Inject] private readonly IEmailService _emailService;
    [Inject] private readonly ILogger<UserService> _logger;
    [Inject] private readonly IConfiguration _configuration;
}

// Program.cs
services.AddMyProject(); // generated extension method
Enter fullscreen mode Exit fullscreen mode

No reflection. No runtime magic. Just generated code you can inspect.


The Real Problem With “Traditional” DI Isn’t DI — It’s Drift

Dependency Injection as a pattern is fine. The built-in container in ASP.NET Core is excellent for the majority of systems.

The pain comes from the mechanical work surrounding DI:

  • Boilerplate explosion: every dependency requires 3–4 edits (field, parameter, assignment, sometimes null-guards).
  • Registration fatigue: Program.cs becomes a constantly mutating registry file.
  • Runtime failures: missing a registration is discovered late—during execution, under load, or in a path your tests didn’t cover.
  • Inheritance forwarding: base class dependencies add friction and repetitive constructor threading.
  • Refactor risk: moving services across assemblies or changing lifetimes is not self-describing in the class—it lives elsewhere.

A codebase that ships continuously cannot afford “drift as a workflow”.

We want the compiler to do more of the policing.

That’s the philosophical core of this approach:

If a change can be validated at compile-time, don’t defer it to runtime.


The Compile-Time Model: Mark the Graph, Generate the Plumbing

This library introduces three practical attributes that matter most for real teams:

  • [Inject] — marks dependency fields (constructor generation)
  • [Register] — marks services for automatic registration (extension method generation)
  • [PostConstruct] — marks a parameterless initialization hook called after injection assignment

And the rules are intentionally strict:

  • classes must be partial
  • [PostConstruct] must be parameterless
  • one [PostConstruct] per class
  • no reflection scanning at startup

Those constraints are not limitations—they’re guardrails that keep the model deterministic, analyzable, and AOT-friendly.


Step 1 — Install the Packages

dotnet add package MintPlayer.SourceGenerators
dotnet add package MintPlayer.SourceGenerators.Attributes
Enter fullscreen mode Exit fullscreen mode

That’s it. No custom MSBuild tasks. No runtime scanning libraries. The generator runs during compilation like any other Roslyn source generator.


Step 2 — Constructor Generation With [Inject]

You write fields, not constructors.

public partial class OrderService : IOrderService
{
    [Inject] private readonly IOrderRepository _orderRepository;
    [Inject] private readonly IPaymentGateway _paymentGateway;
    [Inject] private readonly ILogger<OrderService> _logger;

    public async Task<Order> ProcessOrder(OrderRequest request)
    {
        _logger.LogInformation("Processing order...");
        var order = await _orderRepository.CreateAsync(request);
        await _paymentGateway.ChargeAsync(order);
        return order;
    }
}
Enter fullscreen mode Exit fullscreen mode

What gets generated (conceptually) is the constructor you would have written anyway:

public partial class OrderService
{
    public OrderService(
        IOrderRepository orderRepository,
        IPaymentGateway paymentGateway,
        ILogger<OrderService> logger)
    {
        _orderRepository = orderRepository;
        _paymentGateway = paymentGateway;
        _logger = logger;
    }
}
Enter fullscreen mode Exit fullscreen mode

Why this matters in real systems

This isn’t about saving 10 lines. It’s about eliminating a class of errors and distractions:

  • No more “constructor parameter ordering” debates in PRs.
  • No more forgetting to assign a parameter.
  • No more reformatting noise.
  • Dependencies remain explicit (fields are still in the type).
  • You preserve immutability (readonly) and avoid property injection ambiguity.

In other words: the dependency graph becomes declarative, but still visible.


Step 3 — Post-Construction Initialization With [PostConstruct]

Sometimes you need setup that depends on injected services—configuration parsing, precomputing settings, validating options, establishing cached derived state.

With classic DI you’d do this:

  • in the constructor (bad: constructors should be trivial), or
  • in an explicit Initialize() method (risky: can be forgotten), or
  • via IHostedService / IOptions (sometimes overkill)

[PostConstruct] provides a precise hook:

public partial class CacheService : ICacheService
{
    [Inject] private readonly IConfiguration _configuration;
    [Inject] private readonly ILogger<CacheService> _logger;

    private TimeSpan _defaultExpiration;
    public bool IsInitialized { get; private set; }

    [PostConstruct]
    private void Initialize()
    {
        _defaultExpiration = TimeSpan.FromMinutes(
            _configuration.GetValue<int>("Cache:DefaultExpirationMinutes"));

        _logger.LogInformation("Cache initialized with {Expiration}", _defaultExpiration);
        IsInitialized = true;
    }
}
Enter fullscreen mode Exit fullscreen mode

Generated constructor calls Initialize() after assigning fields:

public CacheService(IConfiguration configuration, ILogger<CacheService> logger)
{
    _configuration = configuration;
    _logger = logger;
    Initialize(); // called after assignment
}
Enter fullscreen mode Exit fullscreen mode

Why [PostConstruct] is better than “doing work in constructors”

Because it expresses intent:

“This is initialization logic that depends on injected dependencies.”

It also gives you a clean place to:

  • compute derived values
  • validate configuration
  • warm up local caches
  • configure policies

…and do it in a way that remains deterministic and testable.


Step 4 — Inheritance Without Constructor Threading

Constructor forwarding is where DI becomes miserable in real codebases.

Base class needs dependencies → derived class needs more dependencies → you’re forced to thread everything through, even when the derived class doesn’t conceptually “care”.

With [Inject], base dependencies are still declared in the base, and the generator forwards them automatically.

Base class

public partial class RepositoryBase<TEntity>
{
    [Inject] protected readonly IDbContext _dbContext;
    [Inject] protected readonly ILogger _logger;
}
Enter fullscreen mode Exit fullscreen mode

Derived class adds its own

public partial class UserRepository : RepositoryBase<User>
{
    [Inject] private readonly ICacheService _cache;

    public async Task<User?> GetByIdAsync(int id)
    {
        // _dbContext and _logger from base
        // _cache from derived
        return await _dbContext.Users.FindAsync(id);
    }
}
Enter fullscreen mode Exit fullscreen mode

Generated constructor threads base params without you touching it:

public partial class UserRepository
{
    public UserRepository(
        ICacheService cache,
        IDbContext dbContext,
        ILogger logger)
        : base(dbContext, logger)
    {
        _cache = cache;
    }
}
Enter fullscreen mode Exit fullscreen mode

Why this is a big deal

Inheritance chains are where “DI cleanliness” often dies.

This keeps dependency declarations close to where they are conceptually owned, without re-plumbing constructors.

That makes refactors safer and PRs quieter.


Step 5 — Service Registration With [Register]

Now let’s delete the second most annoying layer: Program.cs service registration sprawl.

Instead of maintaining a service ledger by hand:

services.AddScoped<IUserService, UserService>();
services.AddScoped<IOrderService, OrderService>();
services.AddScoped<IEmailService, EmailService>();
// ... and so on
Enter fullscreen mode Exit fullscreen mode

You annotate the service directly:

[Register(typeof(IUserService), ServiceLifetime.Scoped)]
public partial class UserService : IUserService
{
    [Inject] private readonly IUserRepository _repository;
}
Enter fullscreen mode Exit fullscreen mode
[Register(typeof(IEmailService), ServiceLifetime.Singleton)]
public partial class EmailService : IEmailService
{
    [Inject] private readonly IConfiguration _config;
}
Enter fullscreen mode Exit fullscreen mode
[Register(typeof(IPaymentGateway), ServiceLifetime.Transient)]
public partial class StripePaymentGateway : IPaymentGateway
{
    [Inject] private readonly IHttpClientFactory _httpClientFactory;
}
Enter fullscreen mode Exit fullscreen mode

The generator emits an extension method:

public static class DependencyInjectionExtensionMethods
{
    public static IServiceCollection AddMyProject(this IServiceCollection services)
    {
        return services
            .AddScoped<IUserService, UserService>()
            .AddSingleton<IEmailService, EmailService>()
            .AddTransient<IPaymentGateway, StripePaymentGateway>();
    }
}
Enter fullscreen mode Exit fullscreen mode

And now your Program.cs becomes a composition root again, not a registry dump:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddMyProject(); // one line

var app = builder.Build();
Enter fullscreen mode Exit fullscreen mode

Why this changes maintenance economics

Because lifetime becomes self-describing at the class declaration site.

In a PR, you read the service and instantly know:

  • what it implements
  • how it’s registered
  • how long it lives

That’s the kind of “small advantage” that compounds over years.


Step 6 — Factory Registration for Complex Instantiation

Some objects need custom creation logic (config-driven selection, external connection creation, etc.). [RegisterFactory] supports this.

public enum DatabaseType { SqlServer, PostgreSQL, SQLite }

[Register(typeof(IDbConnection), ServiceLifetime.Scoped)]
public partial class DatabaseConnection : IDbConnection
{
    [Inject] private readonly string _connectionString;
    [Inject] private readonly DatabaseType _type;

    [RegisterFactory]
    public static IDbConnection Create(IServiceProvider provider)
    {
        var config = provider.GetRequiredService<IConfiguration>();
        var dbType = Enum.Parse<DatabaseType>(config["Database:Type"]!);
        var connString = config.GetConnectionString(dbType.ToString())!;

        return new DatabaseConnection(connString, dbType);
    }
}
Enter fullscreen mode Exit fullscreen mode

Generated registration uses your factory:

public static IServiceCollection AddMyProject(this IServiceCollection services)
{
    return services
        .AddScoped<IDbConnection>(DatabaseConnection.Create);
}
Enter fullscreen mode Exit fullscreen mode

Expert note

Factories are where DI can quietly become a service-locator if abused.

Keep factories narrow and deterministic. Use them when instantiation genuinely depends on runtime configuration, not to “hide dependencies”.


Step 7 — Multiple Implementations Under the Same Interface

.NET DI supports multi-registration naturally:

  • register multiple implementations for the same interface
  • inject IEnumerable<T>

With [Register], this becomes effortless and consistent.

Example: Notification pipeline

public interface INotificationHandler
{
    Task HandleAsync(Notification notification);
}
Enter fullscreen mode Exit fullscreen mode
[Register(typeof(INotificationHandler), ServiceLifetime.Scoped)]
public partial class EmailNotificationHandler : INotificationHandler
{
    [Inject] private readonly IEmailService _emailService;

    public Task HandleAsync(Notification n)
        => _emailService.SendAsync(n.UserEmail, n.Message);
}
Enter fullscreen mode Exit fullscreen mode
[Register(typeof(INotificationHandler), ServiceLifetime.Scoped)]
public partial class SmsNotificationHandler : INotificationHandler
{
    [Inject] private readonly ISmsGateway _smsGateway;

    public Task HandleAsync(Notification n)
        => _smsGateway.SendAsync(n.PhoneNumber, n.Message);
}
Enter fullscreen mode Exit fullscreen mode
public partial class NotificationDispatcher
{
    [Inject] private readonly IEnumerable<INotificationHandler> _handlers;

    public async Task DispatchAsync(Notification notification)
    {
        foreach (var handler in _handlers)
            await handler.HandleAsync(notification);
    }
}
Enter fullscreen mode Exit fullscreen mode

The generator registers all implementations; the container injects them all.

This pattern scales elegantly for:

  • plugin architectures
  • validation pipelines
  • strategy sets
  • decorator chains
  • event handlers

Step 8 — Organize Registrations by Method Name (Large Solutions)

In multi-project solutions, you often want separate registration methods:

  • AddDomainServices()
  • AddInfrastructure()
  • AddSearch()

You can hint method names per service:

[Register(typeof(IUserService), ServiceLifetime.Scoped, "AddDomainServices")]
public partial class UserService : IUserService { }

[Register(typeof(ICacheService), ServiceLifetime.Singleton, "AddInfrastructure")]
public partial class RedisCacheService : ICacheService { }
Enter fullscreen mode Exit fullscreen mode

Or configure assembly-level defaults:

[assembly: ServiceRegistrationConfiguration(
    DefaultMethodName = "AddPaymentModule",
    DefaultAccessibility = EGeneratedAccessibility.Internal)]
Enter fullscreen mode Exit fullscreen mode

This is the difference between “works in a toy app” and “survives in a 40-project solution”.


Step 9 — Register Third-Party Types (Yes, Even If You Can’t Edit Them)

You can register third-party types at the assembly level:

[assembly: Register(typeof(StackExchange.Redis.ConnectionMultiplexer), ServiceLifetime.Singleton)]
[assembly: Register(typeof(Serilog.Core.Logger), ServiceLifetime.Singleton)]

[assembly: Register(typeof(Octokit.IGitHubClient), typeof(Octokit.GitHubClient), ServiceLifetime.Scoped)]
[assembly: Register(typeof(IElasticClient), typeof(ElasticClient), ServiceLifetime.Singleton, "AddSearch")]
Enter fullscreen mode Exit fullscreen mode

Generated methods become a clean module boundary:

public static IServiceCollection AddMyProject(this IServiceCollection services)
{
    return services
        .AddSingleton<ConnectionMultiplexer>()
        .AddSingleton<Logger>()
        .AddScoped<IGitHubClient, GitHubClient>();
}

public static IServiceCollection AddSearch(this IServiceCollection services)
{
    return services
        .AddSingleton<IElasticClient, ElasticClient>();
}
Enter fullscreen mode Exit fullscreen mode

Step 10 — Inject Config Without Injecting IConfiguration Everywhere

The library also offers [Config], [ConnectionString], and [Options] to inject values directly—reducing IConfiguration plumbing.

public partial class FullFeaturedService
{
    [Config("App:Name")] private readonly string appName;
    [Config("App:MaxConnections", DefaultValue = 100)] private readonly int maxConnections;
    [ConnectionString("MainDb")] private readonly string mainDbConnection;
    [Options("Features")] private readonly IOptionsMonitor<CustomerConfig> featureOptions;

    [PostConstruct]
    private void OnInitialized()
    {
        // All values available here
    }
}
Enter fullscreen mode Exit fullscreen mode

This is especially useful when you want services to declare “what config they need” without carrying a config provider around as an omnipresent dependency.


Why Source Generation Beats Reflection Scanning (Especially in 2026)

You might ask: “Why not Scrutor? Why not assembly scanning?”

Here’s the pragmatic answer: runtime scanning does work, but it has tradeoffs that get worse as .NET moves deeper into:

  • trimming
  • NativeAOT
  • smaller containers
  • cold start optimization

Source generation keeps the whole mechanism compile-time.

Aspect Reflection / Scanning Source Generation
Startup time Slower (runtime scanning) Zero overhead
AOT Often problematic Friendly
Trimming Can break discovery Stable
Type safety Runtime errors Compile-time
Debugging Indirect Generated code is visible
IDE support Limited Full IntelliSense

Source generators are metaprogramming that the compiler can reason about.

That’s the future-facing direction of the platform.


Complete Minimal Example (Drop-In)

Service interface:

public interface IGreetingService
{
    string Greet(string name);
}
Enter fullscreen mode Exit fullscreen mode

Service implementation:

[Register(typeof(IGreetingService), ServiceLifetime.Scoped)]
public partial class GreetingService : IGreetingService
{
    [Inject] private readonly ILogger<GreetingService> _logger;

    public string Greet(string name)
    {
        _logger.LogInformation("Greeting {Name}", name);
        return $"Hello, {name}!";
    }
}
Enter fullscreen mode Exit fullscreen mode

Minimal API usage:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddMyProject(); // generated

var app = builder.Build();

app.MapGet("/greet/{name}", (string name, IGreetingService greeter)
    => greeter.Greet(name));

app.Run();
Enter fullscreen mode Exit fullscreen mode

This is clean, testable, and—most importantly—boringly maintainable.


Final Thoughts

After adopting this style, you stop thinking about DI plumbing and start thinking about the system again.

The benefits compound:

  • Less code: hundreds of lines of constructors and registrations disappear.
  • Clearer intent: lifetimes and contracts are visible at the type declaration site.
  • Fewer bugs: forgetting registrations becomes harder (because it’s generated).
  • Faster startup: no scanning overhead.
  • AOT-friendly: better alignment with trimming and native compilation.
  • Better DX: you’re writing domain logic, not ritual.

Compile-time DI is not a novelty. It’s a quality-of-life upgrade that also improves correctness.

If you build large .NET systems, you’ll feel it immediately.

— Written by Cristian Sifuentes

Full-stack engineer · .NET systems thinker · Architecture-first developer

Top comments (0)