Skip to main content

C# Primary Constructors: Simplify Classes (C# 12 Guide)

Learn C# primary constructors with runnable examples, best practices and pitfalls. Cut boilerplate in C# 12 classes and DI services — start coding today.

If you have ever written a service class in .NET and felt like you were typing the same parameter name three times — once in the constructor signature, once in the assignment, once in the field declaration — then the C# primary constructor is the feature you have been waiting for. Introduced in C# 12 (shipped with .NET 8 in November 2023), primary constructors let you declare constructor parameters directly on the class or struct declaration and use them anywhere in the body. This tutorial covers the syntax, the semantics that trip people up, real-world dependency injection patterns, and the pitfalls that static analysis will not always catch.

What Is a C# Primary Constructor?

A primary constructor is a parameter list attached directly to a type declaration. Those parameters are in scope for the entire class body — field initializers, property initializers, methods, and other constructors.

Here is the classic "before" code that every C# developer has written a thousand times:

public class OrderService
{
    private readonly IOrderRepository _repository;
    private readonly ILogger<OrderService> _logger;
    private readonly IEmailSender _emailSender;

    public OrderService(
        IOrderRepository repository,
        ILogger<OrderService> logger,
        IEmailSender emailSender)
    {
        _repository = repository;
        _logger = logger;
        _emailSender = emailSender;
    }

    public async Task<Order?> GetAsync(int id)
    {
        _logger.LogInformation("Fetching order {OrderId}", id);
        return await _repository.FindAsync(id);
    }
}

And here is the same class rewritten with a primary constructor:

public class OrderService(
    IOrderRepository repository,
    ILogger<OrderService> logger,
    IEmailSender emailSender)
{
    public async Task<Order?> GetAsync(int id)
    {
        logger.LogInformation("Fetching order {OrderId}", id);
        return await repository.FindAsync(id);
    }
}

Fourteen lines of ceremony became three. Nothing else changed: the generated type still has a single public constructor with the same three parameters, so ASP.NET Core's dependency injection container resolves it exactly as before.

Records already had this — what is new?

If you have used records, the syntax looks familiar. C# 9 gave record types a positional parameter list. C# 12 extended the same syntax to class and struct, but with one critical difference: records generate public properties from their parameters; classes and structs do not.

public record PersonRecord(string Name);
public class PersonClass(string name);

var r = new PersonRecord("Ada");
Console.WriteLine(r.Name);   // Works — record generates a public Name property

var c = new PersonClass("Ada");
// Console.WriteLine(c.name); // Compile error — no member is generated

This is the single most common misunderstanding about the feature. A primary constructor parameter on a class is a parameter, not a property. It is only reachable from inside the class body.

How Capture Actually Works (The WHY)

When a primary constructor parameter is used in an instance member — a method, property getter, or lambda — the compiler "captures" it into a hidden private field. If the parameter is only used in a field or property initializer, no backing field is emitted at all, because the value is consumed during construction and never needed again.

That distinction matters for memory and for immutability:

// Captured: a hidden compiler-generated field holds 'repository' for the object's lifetime
public class CapturingService(IOrderRepository repository)
{
    public Task<Order?> GetAsync(int id) => repository.FindAsync(id);
}

// NOT captured: 'repository' is consumed by the initializer, then discarded
public class NonCapturingService(IOrderRepository repository)
{
    private readonly IOrderRepository _repository = repository;

    public Task<Order?> GetAsync(int id) => _repository.FindAsync(id);
}

Both compile to roughly the same amount of state, but only the second gives you a readonly field. That leads directly to the biggest pitfall.

Pitfall 1: Captured parameters are mutable

There is no way to mark a primary constructor parameter as readonly. Any instance member in the class can assign to it, silently mutating the hidden field:

public class Counter(int start)
{
    public int Current => start;

    public void Bump() => start++;   // Legal! Mutates the captured field
}

In a small class that is obvious. In a 400-line service written by three people, it is a source of genuine bugs. Best practice: if the value must never change after construction, assign it to a readonly field and use that field everywhere.

Pitfall 2: Shadowing and naming conventions

Because the parameter and a manually declared field can coexist, naming matters more than usual. The common team convention is to keep camelCase parameter names and, when you need a field, prefix it with an underscore:

public class InvoiceService(IClock clock)
{
    private readonly IClock _clock = clock;   // Clear which one is which
}

Avoid naming a field exactly the same as the parameter — the member wins inside method bodies but the parameter wins inside initializers, which is a subtle trap.

Constructor Chaining and Validation

A type may still declare additional constructors, but every one of them must chain to the primary constructor via this(...). This guarantees the captured parameters are always assigned.

public class Money(decimal amount, string currency)
{
    public decimal Amount { get; } = amount;
    public string Currency { get; } = currency;

    // Must chain — omitting ': this(...)' is a compile error
    public Money(decimal amount) : this(amount, "USD") { }

    public override string ToString() => $"{Amount:F2} {Currency}";
}

Also note: a class with a primary constructor no longer gets an implicit public parameterless constructor. If you need one, declare it explicitly and chain.

Validation is the question everyone asks next. Primary constructors have no body, so where do guard clauses go? Put them in the field or property initializer using a throw expression:

public class EmailAddress(string value)
{
    public string Value { get; } =
        string.IsNullOrWhiteSpace(value)
            ? throw new ArgumentException("Email cannot be empty.", nameof(value))
            : value.Trim().ToLowerInvariant();
}

public class ReportService(IReportRepository repository)
{
    private readonly IReportRepository _repository =
        repository ?? throw new ArgumentNullException(nameof(repository));
}

This pattern is idiomatic, keeps the value non-capturable, and gives you a readonly field for free. If validation genuinely needs multiple statements, that is a strong signal the type should use a conventional constructor instead — a primary constructor is not an obligation.

Primary Constructors with Inheritance

You pass arguments to a base class directly in the base type list:

public abstract class Shape(string name)
{
    public string Name { get; } = name;
    public abstract double Area();
}

public class Circle(string name, double radius) : Shape(name)
{
    public override double Area() => Math.PI * radius * radius;
}

Watch for warning CS9107: "Parameter is captured into the state of the enclosing type and its value is also passed to the base constructor." That happens when you both pass a parameter to the base and use it in the derived body, producing two copies of the same value:

public class Square(string name, double side) : Shape(name)
{
    // CS9107 — 'name' is stored in the base AND captured here
    public string Describe() => $"{name} with side {side}";

    // Fix: use the inherited member instead
    public string DescribeFixed() => $"{Name} with side {side}";
}

Treat CS9107 as an error in your build, not a suggestion. Duplicated state drifts.

Primary Constructors on Structs

Structs support the same syntax and it works beautifully for small value types:

public readonly struct Point(double x, double y)
{
    public double X { get; } = x;
    public double Y { get; } = y;

    public double DistanceTo(Point other)
        => Math.Sqrt(Math.Pow(X - other.X, 2) + Math.Pow(Y - other.Y, 2));
}

Two struct-specific rules: a struct always keeps its parameterless constructor (producing an all-default instance), so a primary constructor cannot enforce that every Point was built with real coordinates; and in a readonly struct you cannot capture a parameter into mutable hidden state, so assign parameters to readonly properties or fields as shown above.

Best Practices: When to Use a C# Primary Constructor

  • Use them for dependency injection. ASP.NET Core services, minimal API handlers, MediatR handlers, and background services are the sweet spot. The parameters are injected once, never reassigned, and used across many methods.
  • Use them for small immutable value types where records are not appropriate (for example when you need custom equality or want to avoid a generated ToString).
  • Assign to a readonly field when immutability matters or when the class is large enough that accidental mutation is realistic.
  • Do not use them when construction needs real logic — multi-step validation, conditional wiring, or ordering-sensitive setup reads far better in a conventional constructor body.
  • Be careful with capture in lambdas and event handlers. A captured parameter used inside a long-lived delegate keeps the whole object graph alive; the same rule as any closure applies.
  • Mind serialization and EF Core. EF Core 8 and later can bind entities through primary constructors, but the parameters must map to properties; a class with no properties and only captured parameters cannot be materialized from the database.
  • Agree on a team convention and enforce it with an .editorconfig rule. Half a codebase using primary constructors and half not is worse than either choice alone.

Quick Migration Checklist

Converting an existing .NET 8+ codebase is mostly mechanical, and both Visual Studio and Rider offer a "Use primary constructor" refactoring. Before you accept it across a solution, confirm:

  • The project targets net8.0 or later, or sets <LangVersion>12.0</LangVersion>.
  • No parameter is reassigned anywhere in the body.
  • Guard clauses have been moved into initializers, not dropped.
  • The build is clean of CS9107.
  • Reflection-based frameworks (serializers, mocking libraries, ORMs) still resolve the type — a quick integration test run is worth more than any code review here.

Conclusion: Key Takeaways

The C# primary constructor is one of those rare features that is both trivially easy to adopt and easy to misuse. The mental model is short: parameters declared on the type are in scope for the whole body; using them in an instance member captures them into a hidden, mutable field; using them only in an initializer does not capture at all; every other constructor must chain with this(...); and unlike records, classes and structs generate no public members from those parameters.

Adopt it aggressively for dependency-injected services, where it removes genuine boilerplate with zero behavioural change. Adopt it selectively for value types, always assigning to readonly members. Skip it entirely for classes whose construction is real work. Do that, and C# 12 primary constructors will make your class definitions shorter, flatter, and easier to read — without smuggling in mutable state you did not intend.

About csharp-coder.com
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.

Comments

Popular posts from this blog

.NET MAUI Tutorial 2026: Build Cross-Platform Apps in C#

Learn .NET MAUI in 2026 to build iOS, Android, Windows & Mac apps from one C# codebase. Start this cross-platform tutorial with code examples today. .NET MAUI (Multi-platform App UI) is Microsoft's framework for building native iOS, Android, Windows, and macOS apps from a single C# codebase . If you've ever wanted to ship a mobile app without learning Swift, Kotlin, and Win32 separately, this .NET MAUI tutorial for 2026 is your starting point. In this guide you'll learn what .NET MAUI is, why it matters for cross-platform app development in C#, and how to build your first working app — with runnable code examples and the best practices senior engineers actually use in production. What Is .NET MAUI and Why Use It in 2026? .NET MAUI is the evolution of Xamarin.Forms, fully integrated into the modern .NET runtime. With one project and one language — C# — you target four platforms. The framework compiles to native UI controls on each device, so a button on iOS...

Angular 14 : 404 error during refresh page after deployment

In this article, We will learn how to solve 404 file or directory not found angular error in production.  Refresh browser angular 404 file or directory not found error You have built an Angular app and created a production build with ng build --prod You deploy it to a production server. Everything works fine until you refresh the page. The app throws The requested URL was not found on this server message (Status code 404 not found). It appears that angular routing not working on the production server when you refresh the page. The error appears on the following scenarios When you type the URL directly in the address bar. When you refresh the page The error appears on all the pages except the root page.   Reason for the requested URL was not found on this server error In a Multi-page web application, every time the application needs to display a page it has to send a request to the web server. You can do that by either typing the URL in the address bar, clicking on the Me...

Angular 14 CRUD Operation with Web API .Net 6.0

How to Perform CRUD Operation Using Angular 14 In this article, we will learn the angular crud (create, read, update, delete) tutorial with ASP.NET Core 6 web API. We will use the SQL Server database and responsive user interface for our Web app, we will use the Bootstrap 5. Let's start step by step. Step 1 - Create Database and Web API First we need to create Employee database in SQL Server and web API to communicate with database. so you can use my previous article CRUD operations in web API using net 6.0 to create web API step by step. As you can see, after creating all the required API and database, our API creation part is completed. Now we have to do the angular part like installing angular CLI, creating angular 14 project, command for building and running angular application...etc. Step 2 - Install Angular CLI Now we have to install angular CLI into our system. If you have already installed angular CLI into your system then skip this step.  To install angular CLI ope...