
Learn how to use C# primary constructor to simplify class definitions. Practical examples, best practices, and tips for cleaner C# code. Start now!
What Is a C# Primary Constructor?
A C# primary constructor lets you declare constructor parameters directly in the class or struct declaration, eliminating boilerplate code that developers have written for decades. Introduced in C# 12 (with .NET 8), primary constructors for classes and structs build on a feature that records have enjoyed since C# 9.
If you have ever written a class with a constructor that does nothing but assign parameters to fields, you already know the pain. A typical C# class constructor requires you to declare private fields, write a constructor method, and assign each parameter — often tripling the lines of code for a simple data holder. Primary constructors in C# fix this by letting you define parameters right where you define the class.
Why Primary Constructors Matter in Modern C#
Before C# 12, even a simple service class required verbose ceremony. Consider a logging service that depends on two other services:
// Traditional C# class constructor — before C# 12
public class OrderService
{
private readonly ILogger<OrderService> _logger;
private readonly IOrderRepository _repository;
private readonly IEmailService _emailService;
public OrderService(
ILogger<OrderService> logger,
IOrderRepository repository,
IEmailService emailService)
{
_logger = logger;
_repository = repository;
_emailService = emailService;
}
public async Task PlaceOrderAsync(Order order)
{
_logger.LogInformation("Placing order {OrderId}", order.Id);
await _repository.SaveAsync(order);
await _emailService.SendConfirmationAsync(order);
}
}
That is 20 lines before any business logic appears. Now look at the same class using a primary constructor in C#:
// C# 12 primary constructor — clean and concise
public class OrderService(
ILogger<OrderService> logger,
IOrderRepository repository,
IEmailService emailService)
{
public async Task PlaceOrderAsync(Order order)
{
logger.LogInformation("Placing order {OrderId}", order.Id);
await repository.SaveAsync(order);
await emailService.SendConfirmationAsync(order);
}
}
The result is the same behavior in half the code. The parameters logger, repository, and emailService are captured and available throughout the class body. No private fields, no explicit assignments, no constructor method.
How C# Primary Constructor Works Under the Hood
Understanding the mechanics helps you avoid common pitfalls. When you declare a primary constructor, the compiler does the following:
- Creates a constructor with the specified parameters on the compiled class.
- Captures parameters that are used in the class body as compiler-generated private fields.
- Does NOT generate properties — unlike records, primary constructor parameters in classes remain private captures, not public properties.
- Parameters unused in the class body are not captured at all, keeping the object lean.
This is a critical distinction from records. In a record, primary constructor parameters automatically become public properties with init setters. In a regular class or struct, they are simply constructor parameters you can reference in your code.
// Record — parameters become public properties automatically
public record Product(string Name, decimal Price);
// Class — parameters are NOT public properties
public class Product(string name, decimal price)
{
// You must explicitly create properties if you want them
public string Name { get; } = name;
public decimal Price { get; } = price;
}
Practical Examples of Primary Constructors in C#
1. Dependency Injection in ASP.NET Core
The most common use case for C# primary constructors is dependency injection in ASP.NET Core controllers and services. This is where the feature truly shines:
// ASP.NET Core controller with primary constructor
[ApiController]
[Route("api/[controller]")]
public class ProductsController(
IProductService productService,
ILogger<ProductsController> logger) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll()
{
logger.LogInformation("Fetching all products");
var products = await productService.GetAllAsync();
return Ok(products);
}
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id)
{
var product = await productService.GetByIdAsync(id);
return product is null ? NotFound() : Ok(product);
}
}
2. Initializing Properties and Fields
You can use primary constructor parameters to initialize properties, fields, and other members directly:
public class BankAccount(string accountHolder, decimal initialBalance)
{
public string AccountHolder { get; } = accountHolder;
public decimal Balance { get; private set; } = initialBalance;
// The parameter 'initialBalance' is also captured because
// it is used in the method below
public string GetSummary()
=> $"{AccountHolder}: Balance = {Balance:C} (opened with {initialBalance:C})";
public void Deposit(decimal amount)
{
if (amount <= 0)
throw new ArgumentOutOfRangeException(nameof(amount));
Balance += amount;
}
}
3. Struct with Primary Constructor
Primary constructors work with structs too, which is great for lightweight value types:
public struct Coordinate(double latitude, double longitude)
{
public double Latitude { get; } = latitude;
public double Longitude { get; } = longitude;
public double DistanceTo(Coordinate other)
{
double dLat = Math.Abs(Latitude - other.Latitude);
double dLon = Math.Abs(Longitude - other.Longitude);
return Math.Sqrt(dLat * dLat + dLon * dLon);
}
public override string ToString() => $"({Latitude}, {Longitude})";
}
4. Combining with Inheritance
Primary constructors integrate smoothly with class inheritance. The derived class can pass arguments to the base class primary constructor:
public class Animal(string name, int age)
{
public string Name { get; } = name;
public int Age { get; } = age;
}
// Derived class passes arguments to the base primary constructor
public class Dog(string name, int age, string breed) : Animal(name, age)
{
public string Breed { get; } = breed;
public string Describe()
=> $"{Name} is a {Age}-year-old {Breed}";
}
// Usage
var dog = new Dog("Rex", 5, "German Shepherd");
Console.WriteLine(dog.Describe());
// Output: Rex is a 5-year-old German Shepherd
C# Primary Constructor Best Practices
Adopting primary constructors effectively requires following a few C# constructor best practices. Here are the guidelines experienced developers follow:
Use Primary Constructors for Dependency Injection
Services, controllers, and handlers that receive dependencies through their constructor are the ideal candidates. The parameters are used throughout the class and never need to be publicly exposed:
// Ideal use case — DI services
public class NotificationService(
IEmailSender emailSender,
ISmsSender smsSender,
ILogger<NotificationService> logger)
{
public async Task NotifyAsync(User user, string message)
{
logger.LogInformation("Notifying user {UserId}", user.Id);
if (user.PrefersEmail)
await emailSender.SendAsync(user.Email, message);
else
await smsSender.SendAsync(user.Phone, message);
}
}
Avoid Mutating Captured Parameters
Primary constructor parameters are not readonly. You can accidentally reassign them inside the class body. This is a common pitfall:
public class Counter(int initialCount)
{
// BAD — mutating the captured parameter directly
public void Increment() => initialCount++;
public int Count => initialCount;
}
// BETTER — use an explicit field when mutation is needed
public class Counter(int initialCount)
{
private int _count = initialCount;
public void Increment() => _count++;
public int Count => _count;
}
When you need to mutate state, declare an explicit field initialized from the parameter. This makes the intent clear and avoids surprising behavior.
Know When NOT to Use Primary Constructors
Primary constructors are not always the right choice. Avoid them when:
- Constructor logic is complex — if you need validation, conditional logic, or exception handling in the constructor, a traditional constructor body is clearer.
- You need multiple constructors — a class can have only one primary constructor. Additional constructors must use
this(...)to chain to it. - Public API models — if the class is a data transfer object or part of a public API, records with automatic property generation are usually a better fit.
Adding Validation to a Primary Constructor
A frequent question is how to validate parameters when there is no constructor body. You have two clean approaches:
// Approach 1 — Validate via property initializer with a helper method
public class Temperature(double celsius)
{
public double Celsius { get; } = ValidateCelsius(celsius);
private static double ValidateCelsius(double value)
=> value < -273.15
? throw new ArgumentOutOfRangeException(
nameof(value), "Temperature cannot be below absolute zero")
: value;
public double Fahrenheit => Celsius * 9.0 / 5.0 + 32;
}
// Approach 2 — Chain from a secondary constructor
public class Temperature
{
public double Celsius { get; }
public Temperature(double celsius)
{
ArgumentOutOfRangeException.ThrowIfLessThan(celsius, -273.15);
Celsius = celsius;
}
public double Fahrenheit => Celsius * 9.0 / 5.0 + 32;
}
Approach 1 keeps the primary constructor syntax. Approach 2 is more conventional when validation is the norm. Choose whichever makes the code more readable for your team.
Primary Constructor vs Record vs Traditional Constructor
Here is a quick comparison to help you choose the right approach:
| Feature | Primary Constructor (class) | Record | Traditional Constructor |
|---|---|---|---|
| Auto-generates properties | No | Yes | No |
| Value equality | No | Yes | No |
| Constructor body | No | No | Yes |
| Parameters are readonly | No (mutable) | Yes (init) | Depends on field |
| Best for | DI, service classes | DTOs, immutable data | Complex initialization |
| Minimum C# version | C# 12 | C# 9 | All versions |
Common Pitfalls to Avoid
Watch out for these mistakes when using primary constructors in C#:
- Assuming parameters are properties — they are not. External code cannot access
obj.parameterNameunless you explicitly declare a property. - Double capture — if you initialize a property from a parameter AND use that parameter elsewhere in the class, the compiler generates both a property backing field and a separate captured field. Use the property instead of the parameter after initialization.
- Forgetting
this()chaining — any secondary constructor must call the primary constructor using: this(...). The compiler enforces this. - Serialization surprises — since primary constructor parameters are not properties, JSON serializers will not pick them up unless you declare explicit properties.
// Double capture pitfall — avoid this
public class UserProfile(string name)
{
// 'name' is stored in the property backing field
public string Name { get; } = name;
// 'name' is ALSO captured separately for use here — wasted memory
public string Greeting => $"Hello, {name}!";
}
// Fixed — reference the property, not the parameter
public class UserProfile(string name)
{
public string Name { get; } = name;
// Uses the property, no double capture
public string Greeting => $"Hello, {Name}!";
}
Conclusion
The C# primary constructor is one of the most impactful features in C# 12 for reducing boilerplate. It is especially powerful for dependency injection scenarios in ASP.NET Core, where classes routinely accept multiple services through their constructors.
Here are the key takeaways:
- Use primary constructors for service classes, controllers, and any class where the constructor simply captures dependencies.
- Remember that parameters are not properties — declare properties explicitly when you need external access.
- Avoid mutating captured parameters directly; use explicit fields for mutable state.
- Watch for double capture — reference properties, not parameters, after initialization.
- Choose the right tool — use records for data, primary constructors for services, and traditional constructors for complex initialization.
Start using primary constructors today in your .NET 8+ projects. They make your code cleaner, easier to read, and faster to write — all without sacrificing any functionality.
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