C# 12 Primary Constructors: When the Syntax Sugar Helps and When It Hurts
C# 12 primary constructors cut boilerplate for simple classes, but they hide trade‑offs for DI, validation, and overloads. Learn when to use them and how to verify the generated code.
22 Jul 2025, 06:49 UTC

The problem: boilerplate constructors everywhere
Most C# developers have written the same pattern dozens of times: a class that only stores a handful of dependencies, a constructor that assigns each parameter to a private readonly field, and maybe a base‑class call. The code works, but it adds noise that obscures the actual intent.
C# 12 introduces primary constructors for non‑record classes and structs. You declare the parameters on the type itself, and the compiler synthesises the constructor and the backing fields. The feature is purely syntactic — it does not change the runtime semantics — but it can dramatically reduce ceremony for simple data‑oriented types.
How primary constructors work
- Declaration:
public class Order(int id, string customer)replaces a manual constructor and two readonly fields. - Capture semantics: A parameter becomes a private readonly field only if it is used in an instance member. Unused parameters are not emitted, saving a tiny allocation.
- Initialization order: Primary parameters are assigned before any field initializers and before the base constructor runs. You can forward a subset to the base:
public class PremiumOrder(int id, string customer, decimal discount) : Order(id, customer). - Limitations:
thiscannot reference primary parameters before the base constructor completes. If you need derived‑type logic during construction, use aninitblock or a factory method.
Worked example: a lightweight service
// Primary‑constructor version
public class ReportService(ILogger log, IReportConfig cfg)
{
public void Generate() => log.LogInformation(cfg.Template);
}
// Equivalent manual version for comparison
public class ReportServiceManual
{
private readonly ILogger _log;
private readonly IReportConfig _cfg;
public ReportServiceManual(ILogger log, IReportConfig cfg)
{
_log = log;
_cfg = cfg;
}
public void Generate() => _log.LogInformation(_cfg.Template);
}
Both compile to a single .ctor with two readonly fields. Inspecting the IL (e.g., dotnet-ildasm) shows identical signatures — primary constructors add no hidden overhead.
When the sugar turns sour
| Scenario | Primary constructor | Traditional constructor |
|---|---|---|
| Simple DTO / config object | ✅ Concise, readable | ❌ Extra boilerplate |
| Multiple overloads or optional parameters | ❌ Not supported (only one primary ctor) | ✅ Easy to add overloads |
| Runtime validation / complex initialization | ❌ Validation must live in method body or factory | ✅ Validation in ctor body |
| Dependency‑injection containers expecting named parameters | ⚠️ Parameter names preserved, but attributes need param: prefix | ✅ Attributes on parameters directly |
For DI‑heavy codebases where you frequently need several constructor overloads, validation logic, or custom attributes on parameters, the traditional approach remains clearer.
Practical checklist before adopting
- Target .NET 8 SDK or newer (or set
<LangVersion>12</LangVersion>in the project file). - Verify that the type has a single, straightforward constructor — no overloads, no complex validation.
- Run
dotnet buildand inspect the generated IL withdotnet-ildasmto confirm a single.ctorand readonly fields. - If you use attributes like
[Required], apply them as[param: Required]on the class declaration. - Profile allocation (BenchmarkDotNet or
dotnet-trace) to ensure no unexpected allocations compared with the manual version.
Closing guidance
Primary constructors are a welcome ergonomic improvement for the large class of “plain data holder” types — configuration objects, DTOs, simple services, and struct‑like records. Adopt them when the constructor is only assigning parameters to readonly fields. Keep traditional constructors for anything that needs overloads, validation, or custom parameter attributes. A quick IL check and a micro‑benchmark give you confidence that the syntactic sugar carries no runtime cost.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.