
Learn C# primary constructors in C# 12 with practical examples, best practices, and pitfalls. Simplify your class definitions today.
If you have written much C#, you know the ritual: declare a private field, add a constructor parameter, assign one to the other, repeat for every dependency. C# primary constructors, introduced in C# 12 with .NET 8, remove that ceremony by letting you declare constructor parameters directly on the class or struct declaration. In this tutorial you will learn how C# primary constructors work, when to use them, how they differ from records, and the pitfalls that catch even experienced developers.
What Are C# Primary Constructors?
A primary constructor is a set of parameters declared in the header of a class or struct. Those parameters are in scope throughout the entire body of the type, so you can use them in fields, properties, methods, and even other constructors without manually assigning them anywhere.
Here is the traditional approach most of us have written hundreds of times:
public class OrderService
{
private readonly IOrderRepository _repository;
private readonly ILogger<OrderService> _logger;
public OrderService(IOrderRepository repository, ILogger<OrderService> logger)
{
_repository = repository;
_logger = logger;
}
public async Task<Order?> GetOrderAsync(int id)
{
_logger.LogInformation("Fetching order {Id}", id);
return await _repository.FindAsync(id);
}
}
And here is the same class using a C# 12 primary constructor:
public class OrderService(IOrderRepository repository, ILogger<OrderService> logger)
{
public async Task<Order?> GetOrderAsync(int id)
{
logger.LogInformation("Fetching order {Id}", id);
return await repository.FindAsync(id);
}
}
Ten lines became four. Nothing about the runtime behavior changed, but the intent is clearer: this service needs a repository and a logger, full stop.
Requirements
- C# 12 or later (
<LangVersion>12</LangVersion>or the default for .NET 8+). - Works on classes and structs. Records have had primary constructors since C# 9, but with different semantics (covered below).
How Primary Constructor Parameters Actually Work
This is the part that matters for understanding the WHY. Primary constructor parameters are not automatically fields or properties on a class. They are parameters whose scope is extended to the whole type body. The compiler decides what to do with them based on how you use them:
- If a parameter is used only in a field or property initializer, it is consumed at construction time and no storage is generated.
- If a parameter is used inside a method, property getter, or lambda, the compiler generates a hidden private field to "capture" it so it survives after construction.
public class Temperature(double celsius)
{
// Used only in an initializer: no hidden field for 'celsius' is needed here
public double Fahrenheit { get; } = celsius * 9 / 5 + 32;
// Used in a method body: the compiler captures 'celsius' into a private field
public double Kelvin() => celsius + 273.15;
}
Understanding this capture behaviour explains most of the pitfalls later in this article.
Practical Examples of C# Primary Constructors
1. Dependency Injection in ASP.NET Core
The most common real-world use is dependency injection. Controllers, services, and middleware all benefit:
[ApiController]
[Route("api/[controller]")]
public class ProductsController(IProductService products, ILogger<ProductsController> logger)
: ControllerBase
{
[HttpGet("{id:int}")]
public async Task<IActionResult> Get(int id)
{
var product = await products.GetByIdAsync(id);
if (product is null)
{
logger.LogWarning("Product {Id} not found", id);
return NotFound();
}
return Ok(product);
}
}
ASP.NET Core's DI container resolves the primary constructor exactly as it would any other constructor, so no configuration changes are required.
2. Initializing Fields and Properties
Primary constructor parameters can feed property initializers, which is handy for immutable value-like classes:
public class Rectangle(double width, double height)
{
public double Width { get; } = width;
public double Height { get; } = height;
public double Area => Width * Height;
}
var rect = new Rectangle(4, 5);
Console.WriteLine(rect.Area); // 20
3. Validation with Explicit Constructors
Every other constructor in the class must call the primary constructor via this(...). This guarantees the primary parameters are always initialized, and it gives you a place to add validation or overloads:
public class BankAccount(string owner, decimal balance)
{
public string Owner { get; } = owner ?? throw new ArgumentNullException(nameof(owner));
public decimal Balance { get; private set; } = balance;
// Overload for a new account with zero balance
public BankAccount(string owner) : this(owner, 0m) { }
public void Deposit(decimal amount)
{
if (amount <= 0) throw new ArgumentOutOfRangeException(nameof(amount));
Balance += amount;
}
}
4. Passing Parameters to a Base Class
Primary constructor parameters can be forwarded directly to a base class constructor in the declaration:
public abstract class Shape(string name)
{
public string Name { get; } = name;
public abstract double Area();
}
public class Circle(double radius) : Shape("Circle")
{
public override double Area() => Math.PI * radius * radius;
}
public class Square(double side) : Shape("Square")
{
public override double Area() => side * side;
}
5. Primary Constructors on Structs
public readonly struct Point(int x, int y)
{
public int X { get; } = x;
public int Y { get; } = y;
public double DistanceTo(Point other) =>
Math.Sqrt(Math.Pow(X - other.X, 2) + Math.Pow(Y - other.Y, 2));
}
Primary Constructors in Classes vs Records
A frequent source of confusion is that records also have primary constructors, but they behave differently. This table sums it up:
- Records: primary constructor parameters automatically become public init-only properties, and they participate in value equality,
ToString(), and deconstruction. - Classes and structs: parameters are just parameters. No public members are generated, no equality is synthesized, nothing is exposed unless you declare it.
// Record: Name and Age are public properties, equality is value-based
public record PersonRecord(string Name, int Age);
// Class: name and age are private, nothing is exposed
public class PersonClass(string name, int age)
{
public string Description => $"{name} is {age} years old";
}
var r = new PersonRecord("Ada", 36);
Console.WriteLine(r.Name); // Works
var c = new PersonClass("Ada", 36);
// Console.WriteLine(c.name); // Compile error: no such member
Rule of thumb: use a record when you want an immutable data carrier with value semantics. Use a class with a primary constructor when you want to reduce boilerplate in a service or behaviour-focused type. Note also the naming convention: record parameters are PascalCase because they become properties; class parameters are camelCase because they remain parameters.
Common Pitfalls With C# Primary Constructors
Pitfall 1: Parameters Are Mutable
Unlike a readonly field, a captured primary constructor parameter can be reassigned anywhere in the class. This is the number one complaint from developers:
public class Counter(int start)
{
public void Reset() => start = 0; // Compiles! No readonly protection
public int Current => start;
}
Fix: if immutability matters, copy the parameter into a readonly field or get-only property and use that instead:
public class Counter(int start)
{
private readonly int _start = start;
public int Current => _start;
}
Pitfall 2: Double Storage
If you assign a parameter to a field and also use the parameter directly in a method, the compiler stores the value twice: once in your field and once in the hidden capture field. Worse, they can drift out of sync.
public class Cache(int capacity)
{
private int _capacity = capacity;
public void Grow() => _capacity *= 2;
// BUG: uses the original parameter, not _capacity, so it never reflects Grow()
public bool IsFull(int count) => count >= capacity;
}
Modern IDEs and analyzers (for example, IDE0290 and related warnings in Visual Studio and Rider) flag this. The fix is simple: pick one—either use the parameter everywhere or assign it once and use the field everywhere.
Pitfall 3: No Automatic Null Checks
Primary constructors do not validate anything. If you need null guards, do them in an initializer as shown in the BankAccount example, or use a static helper method.
Pitfall 4: Capturing in Structs and readonly Members
Captured parameters in a struct become hidden mutable fields, which breaks a readonly struct if you try to mutate them. Prefer assigning to properties in readonly struct types.
Pitfall 5: Debugging Visibility
Because captured parameters become compiler-generated fields with unspeakable names, they can be slightly harder to inspect in some debuggers or reflection-based tools like serializers. If a type must be reflected over (for example, by an ORM), stick to explicit properties.
Best Practices for C# 12 Primary Constructors
- Use them for dependency injection. Services, controllers, handlers, and middleware are the ideal candidates, since injected dependencies are rarely reassigned.
- Use camelCase parameter names on classes and structs. Some teams prefer a naming convention hint, but avoid underscore prefixes since they are parameters, not fields.
- Assign to
readonlyfields when immutability is a contract, not just a hope. - Do not mix parameter usage and field assignment for the same value.
- Prefer records for data and primary-constructor classes for behavior.
- Keep the parameter list short. If a primary constructor has eight parameters, the class probably has too many responsibilities—that's a design signal, not a syntax problem.
- Enable analyzers. The .NET SDK ships rules that warn about double capture and suggest converting to primary constructors where safe.
Refactoring Existing Code
Both Visual Studio 2022 (17.8+) and JetBrains Rider offer a "Use primary constructor" refactoring. Place the cursor on a traditional constructor, press Ctrl+., and the IDE will convert it and update field references. Review the result carefully: if the class mutated its fields, the refactoring may keep explicit fields, which is the correct outcome.
Performance Considerations
There is no runtime cost to primary constructors. Captured parameters compile to ordinary private fields, and non-captured parameters vanish entirely. In fact, a class that only uses parameters in initializers can end up with fewer fields than the hand-written equivalent. Benchmarks with BenchmarkDotNet show identical IL and identical allocation profiles versus traditional constructors.
Conclusion: Key Takeaways
C# primary constructors are one of the most useful quality-of-life features in C# 12. They cut boilerplate dramatically, especially in dependency-injected services, without changing runtime behaviour. To use them well, remember:
- Primary constructor parameters are in scope for the whole type but are not public members on classes and structs.
- The compiler generates a hidden field only when a parameter is captured in a method or property body.
- Parameters are mutable; copy them to
readonlyfields when immutability matters. - Never both assign a parameter to a field and use the parameter directly—you'll store it twice and risk bugs.
- Use records for data with value semantics, and classes with primary constructors for services and behaviour.
- All other constructors must chain to the primary constructor with
this(...).
Start by converting your DI-heavy services and controllers, lean on your IDE's refactoring tools, and keep the analyzers switched on. Once you get used to C# primary constructors, you'll wonder how you tolerated all those assignment lines for so long.
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