
Learn C# primary constructors with runnable examples, best practices and pitfalls. Cut boilerplate in C# 12 classes and DI services — start coding today.
If you have ever written a service class in .NET and felt like you were typing the same parameter name three times — once in the constructor signature, once in the assignment, once in the field declaration — then the C# primary constructor is the feature you have been waiting for. Introduced in C# 12 (shipped with .NET 8 in November 2023), primary constructors let you declare constructor parameters directly on the class or struct declaration and use them anywhere in the body. This tutorial covers the syntax, the semantics that trip people up, real-world dependency injection patterns, and the pitfalls that static analysis will not always catch.
What Is a C# Primary Constructor?
A primary constructor is a parameter list attached directly to a type declaration. Those parameters are in scope for the entire class body — field initializers, property initializers, methods, and other constructors.
Here is the classic "before" code that every C# developer has written a thousand times:
public class OrderService
{
private readonly IOrderRepository _repository;
private readonly ILogger<OrderService> _logger;
private readonly IEmailSender _emailSender;
public OrderService(
IOrderRepository repository,
ILogger<OrderService> logger,
IEmailSender emailSender)
{
_repository = repository;
_logger = logger;
_emailSender = emailSender;
}
public async Task<Order?> GetAsync(int id)
{
_logger.LogInformation("Fetching order {OrderId}", id);
return await _repository.FindAsync(id);
}
}
And here is the same class rewritten with a primary constructor:
public class OrderService(
IOrderRepository repository,
ILogger<OrderService> logger,
IEmailSender emailSender)
{
public async Task<Order?> GetAsync(int id)
{
logger.LogInformation("Fetching order {OrderId}", id);
return await repository.FindAsync(id);
}
}
Fourteen lines of ceremony became three. Nothing else changed: the generated type still has a single public constructor with the same three parameters, so ASP.NET Core's dependency injection container resolves it exactly as before.
Records already had this — what is new?
If you have used records, the syntax looks familiar. C# 9 gave record types a positional parameter list. C# 12 extended the same syntax to class and struct, but with one critical difference: records generate public properties from their parameters; classes and structs do not.
public record PersonRecord(string Name);
public class PersonClass(string name);
var r = new PersonRecord("Ada");
Console.WriteLine(r.Name); // Works — record generates a public Name property
var c = new PersonClass("Ada");
// Console.WriteLine(c.name); // Compile error — no member is generated
This is the single most common misunderstanding about the feature. A primary constructor parameter on a class is a parameter, not a property. It is only reachable from inside the class body.
How Capture Actually Works (The WHY)
When a primary constructor parameter is used in an instance member — a method, property getter, or lambda — the compiler "captures" it into a hidden private field. If the parameter is only used in a field or property initializer, no backing field is emitted at all, because the value is consumed during construction and never needed again.
That distinction matters for memory and for immutability:
// Captured: a hidden compiler-generated field holds 'repository' for the object's lifetime
public class CapturingService(IOrderRepository repository)
{
public Task<Order?> GetAsync(int id) => repository.FindAsync(id);
}
// NOT captured: 'repository' is consumed by the initializer, then discarded
public class NonCapturingService(IOrderRepository repository)
{
private readonly IOrderRepository _repository = repository;
public Task<Order?> GetAsync(int id) => _repository.FindAsync(id);
}
Both compile to roughly the same amount of state, but only the second gives you a readonly field. That leads directly to the biggest pitfall.
Pitfall 1: Captured parameters are mutable
There is no way to mark a primary constructor parameter as readonly. Any instance member in the class can assign to it, silently mutating the hidden field:
public class Counter(int start)
{
public int Current => start;
public void Bump() => start++; // Legal! Mutates the captured field
}
In a small class that is obvious. In a 400-line service written by three people, it is a source of genuine bugs. Best practice: if the value must never change after construction, assign it to a readonly field and use that field everywhere.
Pitfall 2: Shadowing and naming conventions
Because the parameter and a manually declared field can coexist, naming matters more than usual. The common team convention is to keep camelCase parameter names and, when you need a field, prefix it with an underscore:
public class InvoiceService(IClock clock)
{
private readonly IClock _clock = clock; // Clear which one is which
}
Avoid naming a field exactly the same as the parameter — the member wins inside method bodies but the parameter wins inside initializers, which is a subtle trap.
Constructor Chaining and Validation
A type may still declare additional constructors, but every one of them must chain to the primary constructor via this(...). This guarantees the captured parameters are always assigned.
public class Money(decimal amount, string currency)
{
public decimal Amount { get; } = amount;
public string Currency { get; } = currency;
// Must chain — omitting ': this(...)' is a compile error
public Money(decimal amount) : this(amount, "USD") { }
public override string ToString() => $"{Amount:F2} {Currency}";
}
Also note: a class with a primary constructor no longer gets an implicit public parameterless constructor. If you need one, declare it explicitly and chain.
Validation is the question everyone asks next. Primary constructors have no body, so where do guard clauses go? Put them in the field or property initializer using a throw expression:
public class EmailAddress(string value)
{
public string Value { get; } =
string.IsNullOrWhiteSpace(value)
? throw new ArgumentException("Email cannot be empty.", nameof(value))
: value.Trim().ToLowerInvariant();
}
public class ReportService(IReportRepository repository)
{
private readonly IReportRepository _repository =
repository ?? throw new ArgumentNullException(nameof(repository));
}
This pattern is idiomatic, keeps the value non-capturable, and gives you a readonly field for free. If validation genuinely needs multiple statements, that is a strong signal the type should use a conventional constructor instead — a primary constructor is not an obligation.
Primary Constructors with Inheritance
You pass arguments to a base class directly in the base type list:
public abstract class Shape(string name)
{
public string Name { get; } = name;
public abstract double Area();
}
public class Circle(string name, double radius) : Shape(name)
{
public override double Area() => Math.PI * radius * radius;
}
Watch for warning CS9107: "Parameter is captured into the state of the enclosing type and its value is also passed to the base constructor." That happens when you both pass a parameter to the base and use it in the derived body, producing two copies of the same value:
public class Square(string name, double side) : Shape(name)
{
// CS9107 — 'name' is stored in the base AND captured here
public string Describe() => $"{name} with side {side}";
// Fix: use the inherited member instead
public string DescribeFixed() => $"{Name} with side {side}";
}
Treat CS9107 as an error in your build, not a suggestion. Duplicated state drifts.
Primary Constructors on Structs
Structs support the same syntax and it works beautifully for small value types:
public readonly struct Point(double x, double y)
{
public double X { get; } = x;
public double Y { get; } = y;
public double DistanceTo(Point other)
=> Math.Sqrt(Math.Pow(X - other.X, 2) + Math.Pow(Y - other.Y, 2));
}
Two struct-specific rules: a struct always keeps its parameterless constructor (producing an all-default instance), so a primary constructor cannot enforce that every Point was built with real coordinates; and in a readonly struct you cannot capture a parameter into mutable hidden state, so assign parameters to readonly properties or fields as shown above.
Best Practices: When to Use a C# Primary Constructor
- Use them for dependency injection. ASP.NET Core services, minimal API handlers, MediatR handlers, and background services are the sweet spot. The parameters are injected once, never reassigned, and used across many methods.
- Use them for small immutable value types where records are not appropriate (for example when you need custom equality or want to avoid a generated
ToString). - Assign to a
readonlyfield when immutability matters or when the class is large enough that accidental mutation is realistic. - Do not use them when construction needs real logic — multi-step validation, conditional wiring, or ordering-sensitive setup reads far better in a conventional constructor body.
- Be careful with capture in lambdas and event handlers. A captured parameter used inside a long-lived delegate keeps the whole object graph alive; the same rule as any closure applies.
- Mind serialization and EF Core. EF Core 8 and later can bind entities through primary constructors, but the parameters must map to properties; a class with no properties and only captured parameters cannot be materialized from the database.
- Agree on a team convention and enforce it with an
.editorconfigrule. Half a codebase using primary constructors and half not is worse than either choice alone.
Quick Migration Checklist
Converting an existing .NET 8+ codebase is mostly mechanical, and both Visual Studio and Rider offer a "Use primary constructor" refactoring. Before you accept it across a solution, confirm:
- The project targets
net8.0or later, or sets<LangVersion>12.0</LangVersion>. - No parameter is reassigned anywhere in the body.
- Guard clauses have been moved into initializers, not dropped.
- The build is clean of CS9107.
- Reflection-based frameworks (serializers, mocking libraries, ORMs) still resolve the type — a quick integration test run is worth more than any code review here.
Conclusion: Key Takeaways
The C# primary constructor is one of those rare features that is both trivially easy to adopt and easy to misuse. The mental model is short: parameters declared on the type are in scope for the whole body; using them in an instance member captures them into a hidden, mutable field; using them only in an initializer does not capture at all; every other constructor must chain with this(...); and unlike records, classes and structs generate no public members from those parameters.
Adopt it aggressively for dependency-injected services, where it removes genuine boilerplate with zero behavioural change. Adopt it selectively for value types, always assigning to readonly members. Skip it entirely for classes whose construction is real work. Do that, and C# 12 primary constructors will make your class definitions shorter, flatter, and easier to read — without smuggling in mutable state you did not intend.
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.
Comments
Post a Comment