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 notreadonly. - Temperature:
celsiusis 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:
| Constructor | IDE0290? |
|---|---|
Only assigns readonly fields | Yes |
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):
| Setting | Warnings left |
|---|---|
csharp_style_prefer_primary_constructors = false in .editorconfig | 0 |
dotnet_diagnostic.IDE0290.severity = none (or simply not setting a severity) | 0 |
#pragma warning disable IDE0290 / restore around one class | 3 |
[SuppressMessage("Style", "IDE0290:Use primary constructor", Justification = "...")] on one class | 3 |
# .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:
| Code | Message | Cause |
|---|---|---|
| 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 parameter | Lambda 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
readonlyfield 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
.editorconfigwithdotnet_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
Post a Comment