Welcome to the first installment of C# Pills, where we'll explain those small changes and juiciest features of C# with the passion of a chef explaining their secret recipe.
Today we're going to talk about a feature that has been living with us since C# 12 (which corresponds to .Net 8 version) and that, although it doesn't make as much noise as other features, is silently changing the way we write our classes. Let me introduce you to primary constructors: those little heroes that allow us to write less code and be more productive.
TL;DR (For the impatient)
- Primary constructors arrived with C# 12 for classes and structs
- They allow declaring parameters directly in the type definition
- Parameters are available throughout the class body
- If not used, the compiler automatically discards them
- If used in methods, they're captured as private fields
- All other constructors must call the primary one with
this(...)
- They're perfect for dependency injection and simple initialization
What are primary constructors?
Imagine you could declare your favorite constructor's parameters directly in your class header, as if it were a method signature. That's exactly what primary constructors do.
Before you had to do this:
public class CustomerService
{
private readonly IRepository _repository;
private readonly ILogger _logger;
public CustomerService(IRepository repository, ILogger logger)
{
_repository = repository;
_logger = logger;
}
public Customer GetCustomer(int id)
{
_logger.LogInformation($"Getting customer {id}");
return _repository.GetById(id);
}
}
Now you can simplify them like this:
public class CustomerService(IRepository repository, ILogger logger)
{
public Customer GetCustomer(int id)
{
logger.LogInformation($"Getting customer {id}");
return repository.GetById(id);
}
}
Do you see the difference? The code is cleaner, more concise, and straight to the point. The repository
and logger
parameters are available throughout the class as if they were magic variables.
The magic behind the scenes
When the compiler sees that you use a primary constructor parameter inside a method (like in the previous example), it automatically creates a private field to store it. It's like having an invisible assistant who takes care of the dirty work for you.
// What you write:
public class BankAccount(string accountNumber, decimal initialBalance)
{
public void Deposit(decimal amount)
{
initialBalance += amount; // Uses the parameter
Console.WriteLine($"Account {accountNumber}: New balance = {initialBalance}");
}
}
// What the compiler does (approximately):
public class BankAccount
{
private readonly string __accountNumber; // Generated field
private decimal __initialBalance; // Generated field
public BankAccount(string accountNumber, decimal initialBalance)
{
__accountNumber = accountNumber;
__initialBalance = initialBalance;
}
public void Deposit(decimal amount)
{
__initialBalance += amount;
Console.WriteLine($"Account {__accountNumber}: New balance = {__initialBalance}");
}
}
But if you only use parameters for initialization, the compiler is smart enough not to create unnecessary fields:
public class Circle(double radius)
{
public double Area { get; } = Math.PI * radius * radius; // Only initialization
public double Circumference { get; } = 2 * Math.PI * radius; // Only initialization
}
In this case, radius
is not captured in a field because it's only used during initialization.
The rules of the game
Primary constructors have some rules you need to know to use them like a master:
Rule #1: All roads lead to the primary
If you have a primary constructor, any other constructor you add must call it using this(...)
:
public class Product(string name, decimal price)
{
public string Name { get; } = name;
public decimal Price { get; } = price;
// Additional constructor that MUST call the primary
public Product(string name) : this(name, 0.0m) { }
// This constructor would cause compilation error:
// public Product() { } // ❌ Doesn't call the primary constructor
}
Rule #2: Parameters are parameters, not properties
Primary constructor parameters are not class members. You can't access them with this.parameter
:
public class Person(string name)
{
public void PrintName()
{
Console.WriteLine(name); // ✅ Correct
Console.WriteLine(this.name); // ❌ Compilation error
}
}
Rule #3: Smart scope
Parameters have different behaviors depending on where you use them:
public class SmartClass(int value, string name) : BaseClass(value)
{
// In field/property initializers, parameters have priority
public string Name { get; } = name; // Uses the 'name' parameter
// In method bodies, class members have priority
public void DoSomething()
{
Console.WriteLine(Name); // Uses the 'Name' property, not the 'name' parameter
Console.WriteLine(name); // Uses the 'name' parameter (automatically captured)
}
}
Perfect use cases
Dependency injection
Primary constructors shine especially in dependency injection scenarios:
// Clean and concise API controller
public class WeatherController(IWeatherService weatherService, ILogger<WeatherController> logger) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<Weather>> Get(string city)
{
logger.LogInformation($"Getting weather for {city}");
var weather = await weatherService.GetWeatherAsync(city);
return Ok(weather);
}
}
Validation with style
You can add validation directly in property initializers:
public class User(string email, int age)
{
public string Email { get; } = IsValidEmail(email)
? email
: throw new ArgumentException("Invalid email", nameof(email));
public int Age { get; } = age >= 0
? age
: throw new ArgumentException("Age cannot be negative", nameof(age));
private static bool IsValidEmail(string email)
=> email.Contains("@"); // Simple validation for the example
}
Elegant inheritance
Primary constructors work very well with inheritance:
public class Vehicle(string brand, int year)
{
public string Brand { get; } = brand;
public int Year { get; } = year;
}
public class Car(string brand, int year, int doors) : Vehicle(brand, year)
{
public int Doors { get; } = doors;
public void ShowInfo()
{
Console.WriteLine($"Car: {Brand} {Year} with {Doors} doors");
// Note: Brand and Year are properties of the base class
// doors is automatically captured because it's used here
Console.WriteLine($"Original doors parameter: {doors}");
}
}
Dangers and warnings
The double storage danger
Be careful about capturing parameters that you've already used to initialize properties:
public class Person(string name)
{
public string Name { get; set; } = name; // Stored in the property
public override string ToString() => name; // Also captured as a field!
}
In this case, name
is stored twice: once in the Name
property and once in a generated private field. But don't worry, the compiler will warn you about this.
Records vs Normal classes
In records, primary constructor parameters automatically become public properties:
// In a record, 'Name' becomes a public property
public record PersonRecord(string Name);
// In a normal class, 'name' is just a parameter
public class PersonClass(string name)
{
// You need to create the property manually if you want it
public string Name { get; } = name;
}
Attributes in primary constructors
You can apply attributes to the primary constructor using the method
target:
[method: JsonConstructor] // The attribute goes to the generated constructor
public class ApiResponse(int statusCode, string message)
{
public int StatusCode { get; } = statusCode;
public string Message { get; } = message;
}
When NOT to use primary constructors?
Primary constructors are great, but they're not always the best option:
- When you need complex initialization logic: Primary constructors don't allow additional code. If you need complex validations or special configuration, a traditional constructor might be better.
- When you have many optional parameters: Primary constructors don't support optional parameters elegantly.
- When the constructor visibility should be different from the class: You can't make a primary constructor private while the class is public.
Conclusion: The silent revolution
Primary constructors won't change the world, but they'll definitely make your code cleaner, less verbose, and more expressive. They're that small but significant improvement that makes you smile every time you write a new class.
They're especially useful for:
- Services with dependency injection
- DTOs and data transfer objects
- Classes that primarily store and expose data
- Any scenario where you want less ceremony and more functionality
The next time you're going to create a new class, ask yourself: "Could I use a primary constructor here?". The answer will probably be yes, and your code will thank you for it.
I hope this little pill has been useful to you. See you in the next C# pill, where we'll continue exploring the features that make this language a delight to develop with!
Useful references: