Why I Switched to Primary Constructors for DI in C#
A friendly guide on why primary constructors made my C# dependency injection cleaner, with simple examples, diagrams, tables, and honest trade-offs.
Think about an autorickshaw driver getting ready for the day. Before the first ride, he checks three things: fuel in the tank, air in the tyres, and the meter working. He sets these up once in the morning. After that, for every passenger, he just drives. He does not refill fuel at each stop or refit the tyres for each trip. The setup happens one time, at the start, and then the tools are simply there whenever he needs them.
A class in C# is a lot like that driver. It also needs a few tools to do its job: maybe a logger to write messages, a repository to read from a database, an email sender. We hand those tools to the class once, when it is created. This handing-over is called dependency injection, or DI for short. And the place where we receive the tools is the constructor.
For years I wrote that constructor the long way. Then C# 12 gave us primary constructors, and after using them for a while, I switched almost all my DI classes to this style. This post is the honest story of why I switched, what got better, and the few sharp corners you should know about. We will go slowly, with small examples and pictures.
Quick note on versions: primary constructors arrived in C# 12 with .NET 8. Today .NET 10 is the current LTS and C# 14 has shipped, so this feature is now a normal, everyday tool. Everything below works in all those versions.
First, what is dependency injection?
Imagine a class called OrderService. Its job is to save an order and then send a confirmation email. To do this it needs two helpers: a database repository and an email sender.
There are two ways to get those helpers.
The bad way is to build them inside the class with new. Then OrderService is glued to one exact database and one exact email service. You cannot swap them. You cannot test the order logic without really sending an email. That is painful.
The good way is dependency injection. The class simply asks for what it needs in its constructor. Something outside the class, called the DI container, builds the helpers and hands them in. Now the class is free. In a real app you pass the real email sender. In a test you pass a fake one that does nothing.
So the constructor is the front door where dependencies walk in. The question this post answers is simple: how should we write that front door?
The old way, with hand-written fields
Before C# 12, here is how almost every DI class looked. Notice how each dependency is mentioned three times.
public class OrderService
{
private readonly IOrderRepository _repository;
private readonly IEmailSender _emailSender;
private readonly ILogger<OrderService> _logger;
public OrderService(
IOrderRepository repository,
IEmailSender emailSender,
ILogger<OrderService> logger)
{
_repository = repository;
_emailSender = emailSender;
_logger = logger;
}
public async Task PlaceOrderAsync(Order order)
{
await _repository.SaveAsync(order);
await _emailSender.SendAsync(order.CustomerEmail, "Order placed!");
_logger.LogInformation("Order {Id} placed", order.Id);
}
}Count the typing. IEmailSender appears once as a field type, once as a parameter type, and the name emailSender shows up in the parameter list and again in the assignment. For a class with five dependencies, the top of the file becomes a wall of plumbing before you reach a single line of real logic.
I never liked this. It is boring to write, easy to get wrong (forget one assignment and you get a NullReferenceException later), and it hides the actual purpose of the class under a pile of repetition.
The new way, with a primary constructor
Here is the exact same class written with a primary constructor. The dependencies are listed right next to the class name. No fields. No assignments.
public class OrderService(
IOrderRepository repository,
IEmailSender emailSender,
ILogger<OrderService> logger)
{
public async Task PlaceOrderAsync(Order order)
{
await repository.SaveAsync(order);
await emailSender.SendAsync(order.CustomerEmail, "Order placed!");
logger.LogInformation("Order {Id} placed", order.Id);
}
}That is the whole class. The dependencies repository, emailSender, and logger are now in scope everywhere inside the class body. You use them by their plain names. No underscore, no assignment, no separate field.
When you use one of these parameters inside a method, the compiler quietly creates the hidden storage for it. You do not see that storage, but it is there. So the value lives for the whole life of the object, exactly like a field would. If a parameter is never used in any method, the compiler is smart enough to not create storage at all.
The two versions do the same thing. But the second one fits in your head at a glance. The job of the class is now the first thing you read, not the last.
Why I actually switched
It was not only about typing less. A few concrete things got better in my daily work.
My reasons to switch
Steps
Less noise
No repeated fields
Fewer bugs
No forgotten assignment
Faster reading
Logic comes first
Easy refactor
Add a dep in one spot
Less noise. A service that depends on four things used to start with about twelve lines of plumbing. Now it starts with the class name and the four parameters. The signal-to-noise ratio of my files went up a lot.
Fewer silly bugs. With hand-written constructors, a classic mistake is to add a new parameter but forget the assignment line. The code still compiles. Then at runtime you hit a null. With primary constructors there is no assignment line to forget, so that whole bug class disappears.
Logic reads first. When I open a class, I want to see what it does. The primary constructor moves the "what it needs" up to the title line and lets the methods speak for themselves right after.
Adding a dependency is one edit. Need to add an ICache? You add one parameter to the header and start using cache. Done. In the old style you touched three places.
Here is a table of the same trade-off, side by side.
| Concern | Old hand-written constructor | Primary constructor |
|---|---|---|
| Lines to declare a dependency | About three per dependency | One per dependency |
| Risk of forgetting an assignment | Real, fails at runtime | None, no assignment exists |
| Where the name lives | Field, parameter, assignment | Just the parameter |
| Readability of small services | Buried under plumbing | Clear and short |
| Works with the DI container | Yes | Yes, exactly the same |
How the DI container sees it
A fair worry when switching is: "Will the dependency injection container still understand my class?" The happy answer is yes, and nothing changes in your registration.
The container does not read your class line by line. It looks at the class's public constructor and its parameters, then fills each parameter with a service it knows about. A primary constructor is a public constructor. So the container treats both styles the same.
Your registration in Program.cs stays exactly as before.
var builder = WebApplication.CreateBuilder(args);
// Register the dependencies the container will inject.
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddScoped<IEmailSender, SmtpEmailSender>();
// Register the service that uses them. Its constructor style does not matter.
builder.Services.AddScoped<OrderService>();
var app = builder.Build();
app.Run();When something asks for an OrderService, the container looks at the constructor, sees it needs an IOrderRepository and an IEmailSender, builds those, and passes them in. It genuinely does not care whether you wrote the old style or the new one.
This works the same way in ASP.NET Core controllers too. A controller with a primary constructor is perfectly normal, and the framework injects its dependencies just like before.
The sharp corners you should know
I switched happily, but I would be a poor teacher if I only showed the good side. There are a few things that bite people. Knowing them ahead of time means they will never bite you.
1. Primary constructor parameters are not readonly
This is the big one. The old private readonly field could never be reassigned by accident. A primary constructor parameter can be changed inside the class. The compiler does not stop you from writing repository = somethingElse; later in a method.
For a dependency, you almost never want that. If you want the old safety, assign the parameter to a real readonly field yourself.
public class PaymentService(IBankGateway gateway)
{
// Capture into a readonly field so it can never be reassigned by accident.
private readonly IBankGateway _gateway = gateway;
public Task ChargeAsync(decimal amount) =>
_gateway.ChargeAsync(amount);
}Yes, this brings back one line of plumbing. But you only do it when you truly care about that extra safety, not for every class.
2. The same name can shadow
If a primary constructor parameter is named name and you also have a property called Name, things can get confusing fast. Keep parameter names clear and avoid clashing with property names. A little care here saves head-scratching later.
3. Inheritance needs a small tweak
If your class has a base class, the primary constructor passes values up to the base using a short syntax right in the header. It reads cleanly once you have seen it once.
| Situation | What to do |
|---|---|
| Plain DI service | Use the primary constructor directly |
Need a readonly guarantee | Capture into a private readonly field |
| Want validation on a value | Validate in a field initializer or a body block |
| Base class needs arguments | Pass them with : base(...) in the header |
Should I capture into a field?
Steps
Start
New DI class
Need readonly?
Yes: make a field
Need validation?
Yes: field init
Plain param is fine
Otherwise just use it
A quick word on testing
Some folks worry that hidden storage makes testing harder. It does not. You test a class with a primary constructor exactly the same way you always did: you build it directly and pass in fakes.
[Fact]
public async Task PlaceOrder_SavesAndEmails()
{
var repo = new FakeOrderRepository();
var email = new FakeEmailSender();
var logger = NullLogger<OrderService>.Instance;
// Build the service by hand, passing fakes into the primary constructor.
var service = new OrderService(repo, email, logger);
await service.PlaceOrderAsync(new Order { Id = 1, CustomerEmail = "[email protected]" });
Assert.True(repo.WasSaved);
Assert.True(email.WasSent);
}Because the constructor parameters are just normal parameters, the test reads naturally. There is no magic to mock and nothing special to set up.
When I still keep the old style
I did not switch everything. There are a few cases where I happily keep a hand-written constructor.
When a class needs heavy validation on its inputs, or runs setup logic that is clearer spread across a few lines, a normal constructor body gives more room to breathe. When a dependency must be readonly and I would have to capture it into a field anyway, the saving is smaller. And in older code that the whole team knows well, I do not rewrite working classes just for style. The win is biggest for new, simple DI services, and that is most of what I write.
A note on the wider ecosystem while we are here: some popular libraries that people pair with services, like MediatR and MassTransit, have moved to a commercial license for newer versions. That has nothing to do with primary constructors, but if you are choosing tools for a new project, check the license first so there are no surprises.
Quick recap
- Dependency injection means a class receives its tools from outside, through its constructor, instead of building them itself.
- The old style declared a field, a parameter, and an assignment for every dependency, which was repetitive and easy to get wrong.
- A primary constructor lists the dependencies next to the class name and lets you use them by name anywhere inside the class.
- The compiler creates the hidden storage only when a parameter is actually used, so there is no waste.
- The DI container works exactly the same with both styles, and your registration in
Program.csdoes not change. - The biggest gotcha: primary constructor parameters are not readonly. Capture into a
private readonlyfield when you need that guarantee. - Testing is unchanged: build the class with fakes and assert as usual.
- Keep the old style for heavy validation, strict readonly needs, or stable legacy code; reach for primary constructors for new, simple services.
References and further reading
- Declare C# primary constructors (Microsoft Learn)
- Dependency injection in .NET (Microsoft Learn)
- What's new in C# 12 (Microsoft Learn)
- How to use primary constructors in C# 12 (InfoWorld)
Related Posts
Getting Started with Primary Constructors in .NET 8 and C# 12
Learn C# 12 primary constructors in .NET 8 the easy way: cleaner classes, fewer lines, dependency injection, and simple real-life examples for beginners.
Getting Started with C# Records: A Beginner's Friendly Guide
Learn C# records the easy way: value equality, with expressions, positional syntax, and record struct, explained with simple real-life examples.
C# init-only and required Properties: A Beginner's Guide
Learn C# init-only and required properties with simple analogies, diagrams, and code. Build safe, immutable objects that are filled correctly every time.
How to Write Elegant Code with C# Pattern Matching
Learn C# pattern matching the easy way. Use is, switch expressions, property and list patterns to write clean, readable, and elegant .NET code.
5 Awesome C# Refactoring Tips to Write Cleaner Code
Learn 5 simple C# refactoring tips with examples in modern C# 14. Make your code cleaner, safer, and easier to read using guard clauses and more.
Why I Write Tall LINQ Queries: Readable C# Pipelines
Learn why writing tall, one-operator-per-line LINQ queries in C# makes your code easier to read, debug, and review. Beginner friendly with diagrams.