Skip to main content

C# Primary Constructors: IDE0290, Pitfalls and Examples

Last updated: October 9, 2026 · Tested with .NET 10 (SDK 10.0.401, runtime 10.0.12), C# 14, Microsoft.Extensions.DependencyInjection from the ASP.NET Core 10.0.12 shared framework, Windows 11

Short answer: a C# primary constructor (C# 12 and later) puts the constructor parameters on the type itself: class OrderService(IOrderRepository repository). The parameters are in scope in the whole class body. They are not properties, and they are not readonly: the compiler turns a parameter into a hidden mutable field only if you use it after construction. IDE0290 "Use primary constructor" is the style rule that suggests the conversion. Its automatic fix keeps your readonly fields, so it doesn't change behavior. Turn it off with csharp_style_prefer_primary_constructors = false in .editorconfig. I compiled and ran every example below on .NET 10. The output is real, and so are the eight compiler messages you will meet: CS9124, CS9113, CS9107, CS9105, CS9111, CS9114, CS8862 and CS1061.

Primary Constructor Syntax: Before and After

The classic version of a service with two injected dependencies:

public class OrderServiceClassic
{
    private readonly IOrderRepository _repository;
    private readonly ILogger<OrderServiceClassic> _logger;

    public OrderServiceClassic(IOrderRepository repository, ILogger<OrderServiceClassic> logger)
    {
        _repository = repository;
        _logger = logger;
    }

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

The same class with a 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);
    }
}

Primary constructors work on classes and structs from C# 12 (.NET 8). Records have had them since C# 9, but there they generate public properties (see records vs classes below).

What the Compiler Actually Generates

I used reflection to list the instance fields of each type. This is the clearest way to see what "captured" means:

static void Fields<T>()
{
    var fs = typeof(T).GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
    Console.WriteLine($"{typeof(T).Name,-22} fields: " +
        (fs.Length == 0 ? "(none)" : string.Join(", ", fs.Select(f => $"{f.FieldType.Name} {f.Name}"))));
}
OrderServiceClassic    fields: IOrderRepository _repository, ILogger`1 _logger
OrderService           fields: IOrderRepository <repository>P, ILogger`1 <logger>P
OrderServiceReadonly   fields: IOrderRepository repository
Temperature            fields: Double <Fahrenheit>k__BackingField
TemperatureCaptured    fields: Double <celsius>P, Double <Fahrenheit>k__BackingField
Cache                  fields: Int32 <capacity>P, Int32 _capacity
  • OrderService: both parameters are used in a method, so each one becomes a hidden field named <repository>P. Same number of fields as the classic version, just not readonly.
  • Temperature: celsius is only used in a property initializer, so no field is created for it.
  • TemperatureCaptured: the same class plus one method that reads celsius. That one method adds a hidden field.
  • Cache: the value is stored twice. That's a bug, covered in the warnings section below.
public class Temperature(double celsius)
{
    public double Fahrenheit { get; } = celsius * 9 / 5 + 32;
}

public class TemperatureCaptured(double celsius)
{
    public double Fahrenheit { get; } = celsius * 9 / 5 + 32;
    public double Kelvin() => celsius + 273.15;
}

So there is no runtime magic: a captured parameter is an ordinary private field. Its name, <repository>P, can't be written in C#, which matters for serializers and anything else that works by reflection.

IDE0290 "Use primary constructor": When It Fires and What the Fix Does

IDE0290 is a code-style rule from the .NET SDK analyzers. By default it doesn't show up in dotnet build. To see it on the command line (or fail CI on it), set a severity and turn on EnforceCodeStyleInBuild:

# .editorconfig
root = true
[*.cs]
dotnet_diagnostic.IDE0290.severity = warning
<PropertyGroup>
  <EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
</PropertyGroup>

I wrote five classic constructors and built them:

ConstructorIDE0290?
Only assigns readonly fieldsYes
Assigns with a null check (?? throw new ArgumentNullException(...))Yes
Assigns, then runs another statement (Console.WriteLine)No
Two constructors, one chained with : this(10)Yes (on the one that does the work)
Assigns a field that a method mutates later (_count++)Yes
Services.cs(9,12): warning IDE0290: Use primary constructor (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0290)

Then I let the fixer convert them with dotnet format style --diagnostics IDE0290 --severity warn. Before and after for the first one:

public class OrderService
{
    private readonly IOrderRepository _repository;

    public OrderService(IOrderRepository repository)
    {
        _repository = repository;
    }

    public Order? Get(int id) => _repository.Find(id);
}
public class OrderService(IOrderRepository repository)
{
    private readonly IOrderRepository _repository = repository;

    public Order? Get(int id) => _repository.Find(id);
}

The fixer keeps your fields and their readonly. Every field became a field initializer, the null check moved into the initializer, the chained : this(10) constructor stayed, and the mutated field stayed mutable. The project built with 0 warnings afterwards. So accepting IDE0290 this way is a safe, mechanical change. What it saves is the constructor body, not the field declarations. If you delete the fields by hand and use the parameters directly, you lose readonly (see the next sections).

How to Disable IDE0290

I tested each option with the rule at warning severity (4 warnings in the test project):

SettingWarnings left
csharp_style_prefer_primary_constructors = false in .editorconfig0
dotnet_diagnostic.IDE0290.severity = none (or simply not setting a severity)0
#pragma warning disable IDE0290 / restore around one class3
[SuppressMessage("Style", "IDE0290:Use primary constructor", Justification = "...")] on one class3
# .editorconfig: the team prefers explicit constructors
[*.cs]
csharp_style_prefer_primary_constructors = false

The option is the right choice for a team decision. It's also documented in the IDE0290 rule page. Use the pragma or the attribute for a single class you want to keep explicit, for example one whose constructor you expect to grow.

Parameters Are Mutable (and How to Keep Them readonly)

A captured parameter can be reassigned anywhere in the class, and the compiler says nothing:

public class Counter(int start)
{
    public void Reset() => start = 0;
    public int Current => start;
}
Counter after Reset(): 0

There's no readonly modifier for primary constructor parameters. The clean fix is a readonly field with the same name as the parameter. Inside the initializer, repository means the parameter. Everywhere else, it means the field:

public class OrderServiceReadonly(IOrderRepository repository)
{
    private readonly IOrderRepository repository = repository;

    public Task<Order?> GetOrderAsync(int id) => repository.FindAsync(id);
}

The reflection output above shows that this type has exactly one field, repository, and no hidden <repository>P. Try to reassign it and you get the normal readonly error:

error CS0191: A readonly field cannot be assigned to (except in a constructor or init-only setter of the type in which the field is defined or a variable initializer)

In a readonly struct the compiler protects you without that trick:

public readonly struct Counter(int start)
{
    public void Reset() => start = 0;
    public int Current => start;
}
error CS9114: A primary constructor parameter of a readonly type cannot be assigned to (except in init-only setter of the type or a variable initializer)

Primary Constructor Warnings and Errors (Exact Messages)

I compiled each snippet alone on .NET 10. These are the messages you'll see in the build output:

CodeMessageCause
CS9124 (warning)Parameter 'int capacity' is captured into the state of the enclosing type and its value is also used to initialize a field, property, or event.Parameter copied to a field and used directly: two copies
CS9113 (warning)Parameter 'port' is unread.A parameter nobody uses
CS9107 (warning)Parameter 'string name' is captured into the state of the enclosing type and its value is also passed to the base constructor. The value might be captured by the base class as well.Passed to : Shape(name) and also used in the derived class
CS8862 (error)A constructor declared in a type with parameter list must have 'this' constructor initializer.Extra constructor that doesn't chain to the primary one
CS1061 (error)'OrderService' does not contain a definition for 'connectionString' ...this.connectionString: parameters aren't members
CS9105 (error)Cannot use primary constructor parameter 'string path' in this context.Used in a static method
CS9111 (error)Anonymous methods, lambda expressions, query expressions, and local functions inside an instance member of a struct cannot access primary constructor parameterLambda in a struct reading the parameter
CS9114 (error)A primary constructor parameter of a readonly type cannot be assigned to ...Assignment in a readonly struct

CS9124: The Double-Storage Bug

CS9124 is the only one of these warnings that points at a real bug in shipped code, so don't suppress it. This class compiles with just that warning:

public class Cache(int capacity)
{
    private int _capacity = capacity;

    public void Grow() => _capacity *= 2;

    public bool IsFull(int count) => count >= capacity;
    public int Capacity => _capacity;
}
var cache = new Cache(2);
cache.Grow();
Console.WriteLine($"Capacity = {cache.Capacity}, IsFull(3) = {cache.IsFull(3)}");
Capacity = 4, IsFull(3) = True

Capacity is 4, yet 3 items count as full, because IsFull reads the original parameter (still 2) and not the field. The fix: use the field everywhere, or give the field the same name as the parameter so capacity can only mean the field.

Dependency Injection with Primary Constructors

The Microsoft DI container sees a primary constructor as an ordinary public constructor, so nothing changes in registration:

var services = new ServiceCollection()
    .AddLogging(b => b.AddSimpleConsole(o => o.SingleLine = true))
    .AddSingleton<IOrderRepository, InMemoryOrderRepository>()
    .AddTransient<OrderService>()
    .AddTransient<ReportService>();
info: OrderService[0] Fetching order 42
order: Order { Id = 42, Total = 99.50 }

A secondary constructor is a gotcha. It must chain to the primary one, and the container then picks whichever constructor it can satisfy:

public class ReportService(IOrderRepository repository, IClock clock)
{
    public ReportService(IOrderRepository repository) : this(repository, new SystemClock()) { }
    public async Task<string> HeaderAsync(int id) =>
        $"{clock.GetType().Name}: order {(await repository.FindAsync(id))?.Id}";
}
IClock not registered -> SystemClock: order 42
IClock registered     -> FixedClock: order 42

Without an IClock registration, the container silently used the one-parameter constructor and the hard-coded SystemClock, with no error. If you see a "default" dependency in production that you thought you'd replaced, check the registration first. More on lifetimes and registration in the dependency injection tutorial.

Primary Constructors and System.Text.Json

When the parameters feed public properties, System.Text.Json deserializes through the primary constructor (it's the only public constructor, and the names match case-insensitively):

public class Rectangle(double width, double height)
{
    public double Width { get; } = width;
    public double Height { get; } = height;
    public double Area => Width * Height;
}
Rectangle: {"Width":4,"Height":5,"Area":20}

When the parameters are only captured, serialization quietly writes an empty object and deserialization throws:

public class Money(decimal amount, string currency)
{
    public override string ToString() => $"{amount} {currency}";
}
Serialize Money(9.99 EUR): {}
InvalidOperationException: Each parameter in the deserialization constructor on type 'Money' must bind to an object property or field on deserialization. Each parameter name and type must match with a property or field on the object. Fields are only considered when 'JsonSerializerOptions.IncludeFields' is enabled. The name match can be case-insensitive.

The hidden <amount>P field doesn't count as a match. For types that go over the wire, expose properties as Rectangle does, or use a record. Other serializer differences are in System.Text.Json vs Newtonsoft.

Primary Constructors in Classes vs Records

public record PersonRecord(string Name, int Age);

public class PersonClass(string name, int age)
{
    public string Description => $"{name} is {age} years old";
}
var r1 = new PersonRecord("Ada", 36);
Console.WriteLine($"{r1} | equal: {r1 == new PersonRecord("Ada", 36)}");
var c1 = new PersonClass("Ada", 36);
Console.WriteLine($"{c1} | equal: {c1.Equals(new PersonClass("Ada", 36))} | {c1.Description}");
PersonRecord { Name = Ada, Age = 36 } | equal: True
PersonClass | equal: False | Ada is 36 years old

A record turns its parameters into public init-only properties and generates value equality, ToString() and deconstruction. A class with a primary constructor generates none of that. That's also why the naming differs: record parameters are PascalCase (they become properties), class parameters are camelCase (they stay parameters). The full comparison is in records vs classes vs structs.

When to Use Them (and When Not)

  • Use them for services, controllers, handlers and middleware whose dependencies are injected once and never reassigned. Add the same-name readonly field if your team wants the compiler to enforce that.
  • Use them for small classes whose parameters only feed property initializers (no hidden fields at all).
  • Don't use them for DTOs that only capture parameters: JSON and other reflection-based tools can't see the hidden fields. Use a record or explicit properties.
  • Keep an explicit constructor when it does real work besides assignments. IDE0290 doesn't fire there anyway.
  • Treat CS9124 and CS9107 as bugs until proven otherwise. You can promote them in .editorconfig with dotnet_diagnostic.CS9124.severity = error.

FAQ

What does IDE0290 "Use primary constructor" mean? It's a style suggestion: your constructor only assigns parameters, so the class could declare them in a primary constructor. The automatic fix keeps your fields (including readonly), so it doesn't change behavior.

How do I turn off "Use primary constructor"? Add csharp_style_prefer_primary_constructors = false under [*.cs] in .editorconfig. For one class, use #pragma warning disable IDE0290 / restore or [SuppressMessage].

Are primary constructor parameters readonly? No, not in a class or a normal struct. Declare private readonly T name = name; to get a readonly field with the same name. In a readonly struct, assigning one is error CS9114.

Can I access a primary constructor parameter with this? No. Parameters aren't members, so this.connectionString is CS1061.

Are primary constructors slower? They compile to the same thing as hand-written code: a captured parameter is an ordinary private field, and an uncaptured one has no field at all (see the reflection output above). I didn't benchmark them, because there's no different code path to measure.

Which C# version do I need? C# 12 (the default for .NET 8) for classes and structs. Everything here was also tested with C# 14 on .NET 10. For what came after C# 12, see C# 13 features (and what C# 14 added).

References: IDE0290 rule · Primary constructors (C# guide)

This article is part of the C# tutorials guide (language features, patterns, performance and testing).

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