When C# 12 introduced primary constructors for classes and structs, the feature divided the developer community. Some praised it as a welcome reduction in boilerplate. Others viewed it as a foot-gun that obscures important implementation details. The truth, as is often the case, depends heavily on context.
What Are Primary Constructors?
Primary constructors allow you to declare constructor parameters directly in the type declaration. These parameters become available throughout the class body, including in field initializers, methods, and properties.
// Traditional approach
public class UserService
{
private readonly ILogger _logger;
private readonly IUserRepository _repository;
public UserService(ILogger logger, IUserRepository repository)
{
_logger = logger;
_repository = repository;
}
}
// Primary constructor approach
public class UserService(ILogger logger, IUserRepository repository)
{
// Parameters are captured and available throughout the class
public void CreateUser(User user)
{
logger.LogInformation("Creating user");
repository.Add(user);
}
}
Classes vs. Records: A Critical Distinction
Primary constructors behave fundamentally differently in records versus classes. This distinction is crucial for understanding when to use them.
Records with Primary Constructors
Parameters automatically become public init-only properties. The compiler generates equality members, a deconstructor, and implements value-based equality semantics.
Classes with Primary Constructors
Parameters are captured but do not become properties. They remain accessible throughout the class as captured variables, similar to closure behavior.
// Record: parameters become properties
public record Person(string Name, int Age);
var person = new Person("Alice", 30);
Console.WriteLine(person.Name); // Works - Name is a property
// Class: parameters are captured, NOT properties
public class Customer(string name, int age)
{
public void PrintInfo()
{
Console.WriteLine(name); // Works - captured parameter
}
}
var customer = new Customer("Bob", 25);
Console.WriteLine(customer.name); // ERROR - name is not a property!
Critical Gotcha: In classes, primary constructor parameters do not automatically become properties. You must explicitly declare properties if you need external access. This is probably the most common source of confusion.
When to Use Primary Constructors
✓ Ideal Use Cases
1. Dependency Injection in Services
Primary constructors shine when you have multiple injected dependencies that you only need internally.
public class OrderProcessor(
ILogger<OrderProcessor> logger,
IOrderRepository orderRepository,
IPaymentGateway paymentGateway,
IEmailService emailService)
{
public async Task ProcessOrderAsync(Order order)
{
logger.LogInformation("Processing order {OrderId}", order.Id);
var payment = await paymentGateway.ChargeAsync(order);
await orderRepository.SaveAsync(order);
await emailService.SendConfirmationAsync(order);
}
}
2. Immutable Data Containers (Records)
Records with primary constructors are perfect for DTOs, value objects, and any immutable data structure.
public record Address(
string Street,
string City,
string State,
string ZipCode);
public record ApiResponse<T>(
T Data,
bool Success,
string? ErrorMessage = null);
3. Simple Validation or Calculation Classes
public class PasswordValidator(int minLength, bool requireSpecialChar)
{
public bool IsValid(string password)
{
if (password.Length < minLength) return false;
if (requireSpecialChar && !HasSpecialChar(password)) return false;
return true;
}
private bool HasSpecialChar(string password)
=> password.Any(c => !char.IsLetterOrDigit(c));
}
When NOT to Use Primary Constructors
✗ Avoid in These Scenarios
1. When You Need Validation Logic
Primary constructors cannot contain validation code. If you need to validate parameters, use a traditional constructor.
// BAD: No way to validate in primary constructor
public class BankAccount(string accountNumber, decimal initialBalance)
{
// Where do we validate accountNumber format?
// Where do we ensure initialBalance >= 0?
}
// GOOD: Traditional constructor with validation
public class BankAccount
{
public string AccountNumber { get; }
public decimal Balance { get; private set; }
public BankAccount(string accountNumber, decimal initialBalance)
{
if (string.IsNullOrWhiteSpace(accountNumber))
throw new ArgumentException("Account number required");
if (initialBalance < 0)
throw new ArgumentException("Initial balance cannot be negative");
AccountNumber = accountNumber;
Balance = initialBalance;
}
}
2. When Parameters Need to Become Public Properties
In classes (not records), you must explicitly declare properties. This creates redundancy that defeats the purpose of primary constructors.
// AWKWARD: Redundant property declarations
public class Product(string name, decimal price)
{
// Have to declare properties anyway!
public string Name { get; } = name;
public decimal Price { get; } = price;
}
// BETTER: Use a record if you want properties
public record Product(string Name, decimal Price);
// OR use traditional class constructor
public class Product
{
public string Name { get; }
public decimal Price { get; }
public Product(string name, decimal price)
{
Name = name;
Price = price;
}
}
3. Complex Initialization Logic
When you need to perform operations during construction beyond simple assignment, traditional constructors are clearer.
// CONFUSING: Complex logic with primary constructor
public class GameSession(string playerId, DateTime startTime)
{
private readonly Guid _sessionId = Guid.NewGuid();
private readonly DateTime _createdAt = DateTime.UtcNow;
private readonly List<GameEvent> _events = new();
public string PlayerId { get; } = playerId;
// This is getting messy...
}
// CLEAR: Traditional constructor shows intent
public class GameSession
{
public Guid SessionId { get; }
public string PlayerId { get; }
public DateTime StartTime { get; }
public DateTime CreatedAt { get; }
private readonly List<GameEvent> _events = new();
public GameSession(string playerId, DateTime startTime)
{
SessionId = Guid.NewGuid();
PlayerId = playerId;
StartTime = startTime;
CreatedAt = DateTime.UtcNow;
LogSessionCreated();
}
private void LogSessionCreated() { /* ... */ }
}
4. When Working with Inheritance
Primary constructors can complicate inheritance hierarchies, especially when base classes also use primary constructors.
public class Animal(string name)
{
public string Name { get; } = name;
}
// Syntax is less intuitive with inheritance
public class Dog(string name, string breed) : Animal(name)
{
public string Breed { get; } = breed;
}
The Capture Behavior Trap
One of the most subtle issues with primary constructors in classes is their capture semantics. Parameters are captured and remain accessible throughout the class lifetime, which can lead to unexpected behavior.
public class Counter(int startValue)
{
private int _count = startValue;
public void Increment()
{
_count++;
// DANGER: startValue is still accessible!
// You might accidentally use it instead of _count
Console.WriteLine($"Count: {startValue}"); // BUG!
}
public void Reset()
{
_count = startValue; // This works, but is it intentional?
}
}
Best Practice: If you use a primary constructor parameter to initialize a field and don't need the parameter later, consider using a traditional constructor to make the scope explicit.
Performance Considerations
Primary constructors generate nearly identical IL code to traditional constructors. The performance difference is negligible. However, the captured parameter behavior in classes does create a hidden field, which has minor memory implications if you're creating millions of instances.
Team Readability Perspective
PROS: Love Them
- Significant reduction in boilerplate for dependency injection
- Forces parameters to be readonly (in practice)
- Cleaner for simple classes with few dependencies
- Excellent for records as data carriers
- Aligns with modern C# conciseness trends
CONS: Hate Them
- Obscures what's a field vs. captured parameter
- Cannot include validation or initialization logic
- Confusing behavior difference between classes and records
- Makes refactoring more difficult (changing constructor to add validation)
- Less explicit than traditional constructors
- Can reduce code searchability and navigation
The Verdict
Primary constructors are not inherently good or bad. They're a tool that works brilliantly in specific contexts and poorly in others.
Use them when: You have dependency injection scenarios with multiple dependencies used only internally, or you're working with records for immutable data structures.
Avoid them when: You need validation, complex initialization, public properties in classes, or when working in inheritance hierarchies. Also avoid when team members are unfamiliar with the feature, as the learning curve can impact maintainability.
The key is intentionality. Don't use primary constructors just because they're shorter. Use them when they make your intent clearer and your code more maintainable. And remember: the fact that you can use a feature doesn't mean you should.
Next article: Thread Safety in C# — Practical Guide with Examples