Skip to main content

C# Primary Constructors Explained with Examples (C# 12)

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 readonly fields 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 readonly fields 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.

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...