Introduction
If you've written a service, handler, or controller in .NET that takes five dependencies as constructor parameters, you know the pain of declaring each field, assigning each parameter, and keeping them in sync. Primary constructors in C# 12 eliminate that boilerplate by letting you declare constructor parameters directly in the class declaration—and they're available throughout the class body without any extra work. After building production services across e-commerce, CRM, and trading platforms, I've found primary constructors to be one of the most practical quality-of-life improvements in recent C# versions, especially when combined with dependency injection.
Parameters available throughout the class
The core appeal of primary constructors is simplicity. When you declare a primary constructor, its parameters become implicitly available as captured values throughout the entire class—no manual field declarations needed.
Here's the traditional approach:
public class OrderService
{
private readonly AppDb _db;
private readonly ILogger<OrderService> _log;
public OrderService(AppDb db, ILogger<OrderService> log)
{
_db = db;
_log = log;
}
public async Task<Order?> GetAsync(int id)
{
_log.LogInformation("Fetching order {OrderId}", id);
return await _db.Orders.FindAsync(id);
}
}With a primary constructor in C# 12:
public class OrderService(AppDb db, ILogger<OrderService> log)
{
public async Task<Order?> GetAsync(int id)
{
log.LogInformation("Fetching order {OrderId}", id);
return await db.Orders.FindAsync(id);
}
}The db and log parameters are now accessible in any method, property, or initializer throughout the class. No field declarations, no constructor body assignments—just declare the parameters once and use them everywhere. This is particularly valuable in small, focused classes where the constructor is the only place dependencies enter.
Perfect for DI in services and handlers
Dependency injection patterns benefit enormously from primary constructors. In a typical ASP.NET Core application, services, handlers, and controllers follow the same DI pattern: accept dependencies in the constructor, store them, and use them in methods. Primary constructors are a natural fit for this pattern.
Consider a command handler in a CQRS architecture:
public class CreateOrderCommandHandler(
IOrderRepository repository,
IEmailService emailService,
ILogger<CreateOrderCommandHandler> logger)
{
public async Task<OrderId> Handle(CreateOrderCommand command)
{
logger.LogInformation("Creating order for customer {CustomerId}", command.CustomerId);
var order = new Order(command.CustomerId, command.Items);
await repository.SaveAsync(order);
await emailService.SendOrderConfirmationAsync(order);
logger.LogInformation("Order {OrderId} created successfully", order.Id);
return order.Id;
}
}The handler receives three dependencies, and all are immediately usable. In a real codebase where handlers might depend on 4–6 services, this pattern scales much more cleanly than the traditional field-per-dependency approach.
The same applies to service classes in a layered architecture:
public class PaymentService(
IPaymentGateway gateway,
ITransactionLog transactionLog,
AppDb db)
{
public async Task<PaymentResult> ProcessAsync(Payment payment)
{
var result = await gateway.ChargeAsync(payment.Amount, payment.Token);
if (result.IsSuccessful)
{
await transactionLog.LogAsync(new Transaction
{
PaymentId = payment.Id,
Amount = payment.Amount,
Status = TransactionStatus.Completed
});
payment.MarkAsProcessed(result.TransactionId);
await db.SaveChangesAsync();
}
else
{
await transactionLog.LogAsync(new Transaction
{
PaymentId = payment.Id,
Amount = payment.Amount,
Status = TransactionStatus.Failed,
ErrorMessage = result.ErrorMessage
});
}
return result;
}
}This is where primary constructors shine: they reduce noise and let you focus on the business logic rather than plumbing.
Parameters are mutable captures — not readonly fields
Here's a critical detail that catches many developers: primary constructor parameters are not automatically converted to readonly fields. They're captured parameters, meaning they're mutable and live for the lifetime of the instance, but they're not fields in the traditional sense.
This distinction matters in a few scenarios. First, primary constructor parameters cannot be accessed via reflection as if they were fields—they're closure captures. Second, they're mutable by default:
public class MutableExample(string apiKey)
{
public void BreakThings()
{
apiKey = "overwritten"; // This compiles
}
}In the above example, nothing prevents you from reassigning apiKey within the class. For a configuration value like an API key that should never change, this is a silent bug waiting to happen.
Similarly, if you need to serialize the class or expose it through a public API, you might want actual properties or fields, not just captured parameters:
public class ServiceConfig(string environment, int maxRetries)
{
// This class has captured parameters, but no accessible properties
// If you need to expose them, you must add properties explicitly
public string Environment => environment; // Explicit property
public int MaxRetries => maxRetries; // Explicit property
}The parameters are accessible within the class, but they're not part of the public interface by default. If your class needs to expose these values or guarantee immutability, you need to be intentional about it.
Assign to a readonly field when immutability matters
When you need the safety of readonly fields—especially for values that should never change after construction—you can explicitly assign primary constructor parameters to readonly fields. This is the best practice for production code where immutability is a requirement:
public class SecureOrderService(
AppDb db,
ILogger<SecureOrderService> log,
string apiKey)
{
private readonly string _apiKey = apiKey;
private readonly AppDb _db = db;
private readonly ILogger<SecureOrderService> _logger = log;
public async Task<Order?> GetAsync(int id)
{
_logger.LogInformation("Fetching order {OrderId}", id);
return await _db.Orders.FindAsync(id);
}
public async Task<string> AuthenticateAsync()
{
// _apiKey is readonly; no risk of accidental mutation
return await ValidateApiKeyAsync(_apiKey);
}
}This approach gives you the best of both worlds: the concise parameter declaration of primary constructors, plus the safety guarantees of readonly fields. In production code handling sensitive data (payment tokens, API keys, database credentials), this pattern is highly recommended.
You can also use the readonly keyword at the parameter level in C# 12.1+, which implicitly creates readonly captures:
public class OrderService(
readonly AppDb db,
readonly ILogger<OrderService> log)
{
public async Task<Order?> GetAsync(int id)
{
log.LogInformation("Fetching order {OrderId}", id);
return await db.Orders.FindAsync(id); // db and log are now readonly
}
}This is syntactic sugar that automatically creates readonly captures, eliminating the risk of accidental mutation.
Common mistakes
Mistake 1: Assuming primary constructor parameters are automatically readonly. They're not. Unless you explicitly mark them as readonly or assign them to readonly fields, they can be reassigned. In production code, especially with DI, always consider whether you need that immutability guarantee.
Mistake 2: Trying to access primary constructor parameters as fields via reflection. If your code relies on reflection to discover dependencies or serialize the class, primary constructor parameters won't show up as fields. You need explicit properties or fields for that.
Mistake 3: Overusing primary constructors in complex classes. Primary constructors shine in small, focused classes with clear DI patterns. In a class with 8+ dependencies, explicit fields and a traditional constructor might actually be clearer. Don't use them just because they're new.
Mistake 4: Mixing primary and explicit constructors carelessly. If you define a primary constructor, any other constructor must call the primary constructor via this(). Forgetting this can lead to fields being uninitialized:
public class PaymentService(IPaymentGateway gateway)
{
// DON'T DO THIS
public PaymentService(IPaymentGateway gateway, string region)
{
// Missing call to primary constructor
// gateway is uninitialized
}
}Instead, chain through the primary constructor:
public class PaymentService(IPaymentGateway gateway, string region = "US")
{
public PaymentService(IPaymentGateway gateway) : this(gateway, "US") { }
}Key takeaways
- Primary constructors eliminate boilerplate by making constructor parameters implicitly available throughout the class without explicit field declarations.
- They're ideal for dependency injection in services, handlers, and controllers where the constructor is the only entry point for dependencies.
- Parameters are mutable captures by default—treat them as such, and assign to readonly fields when immutability is critical.
- Use the
readonlykeyword at the parameter level (C# 12.1+) to automatically create readonly captures without field boilerplate. - Keep primary constructors for small, focused classes; complex classes with many dependencies may still benefit from explicit field declarations and a traditional constructor body.
- Primary constructors are not magic; they're syntactic sugar that reduces ceremony in common patterns. Understand what they do and when to apply them.






Comments
Loading comments…