Published on

Primary Constructors in C# 12 (And why i like them)

Authors

Primary Constructors in C# 12 (and why I like them)

Why?

I write a lot of small “data + a bit of behavior” types.
The usual pattern is: fields → constructor → property assignments. Boilerplate.

Primary constructors remove that noise.
They let you declare constructor parameters right on the type and use them in field initializers, property initializers, and the body. Cleaner, tighter, less scrolling.

How do we use them?

1) The classic, before C# 12

public class Invoice
{
    private readonly decimal _amount;
    public string Currency { get; }

    public Invoice(decimal amount, string currency)
    {
        _amount = amount;
        Currency = currency ?? "USD";
    }

    public decimal TotalWithVat(decimal vatRate) => _amount * (1 + vatRate);
}

2) With a primary constructor

public class Invoice(decimal amount, string currency)
{
    private readonly decimal _amount = amount;
    public string Currency { get; } = currency ?? "USD";

    public decimal TotalWithVat(decimal vatRate) => _amount * (1 + vatRate);
}

Same behavior. Fewer lines.
Parameters (amount, currency) are in scope for initializers and methods.


Records work the same way

public record Product(string Sku, decimal Price);

public record DiscountedProduct(string Sku, decimal Price, decimal Discount)
{
    public decimal Final => Price - Discount;
}

You can still add members, validation, etc.


Validation inline

public class User(string email)
{
    public string Email { get; } =
        string.IsNullOrWhiteSpace(email) ? throw new ArgumentException("Email required") : email.ToLowerInvariant();
}

No extra constructor ceremony.


DI-friendly types

You can keep constructor injection and still avoid the assignment noise:

public class ReportService(ILogger<ReportService> logger, IHttpClientFactory http)
{
    private readonly ILogger<ReportService> _logger = logger;
    private readonly HttpClient _client = http.CreateClient("reports");

    public async Task<string> GetAsync(string id)
    {
        _logger.LogInformation("Fetching {Id}", id);
        return await _client.GetStringAsync($"/reports/{id}");
    }
}

Note: you still choose which parameters become fields. Nothing is forced.


When I reach for primary constructors

  • Small domain types that carry a couple of required values.
  • Services with simple DI needs where I don’t want a wall of assignments.
  • Records where I want a tiny, expressive model plus a property or two.

If a type has complex construction logic, builders/factories might still read better. Use the tool that keeps the code obvious.