Introduction
In the evolving landscape of .NET development, “less is more” has become a guiding principle. Over the years, C# has consistently introduced features aimed at reducing verbosity—from Auto-implemented Properties in C# 3 to Records in C# 9.
With the release of C# 12, Microsoft introduced Primary Constructors for all classes and structs. This feature isn’t just a syntactic sugar; it’s a fundamental shift in how we handle object initialization and Dependency Injection (DI). In this post, we will explore the mechanics of Primary Constructors, compare them with traditional patterns, and discuss where they fit best in your enterprise architecture.
The Evolution of Constructor Syntax
Traditionally, C# developers followed a repetitive pattern to handle dependencies. You would declare a private field, define a constructor with parameters, and then assign those parameters to the fields.
While this works, it creates “noise” that obscures the actual logic of the class. Let’s look at a typical Service in an ASP.NET Core application:
The Verbose Way (Pre-C# 12):
C#
public class OrderService : IOrderService
{
private readonly IOrderRepository _repository;
private readonly INotificationService _notification;
private readonly ILogger<OrderService> _logger;
public OrderService(
IOrderRepository repository,
INotificationService notification,
ILogger<OrderService> logger)
{
_repository = repository;
_notification = notification;
_logger = logger;
}
public async Task ProcessOrder(Order order)
{
_logger.LogInformation("Processing order {Id}", order.Id);
await _repository.SaveAsync(order);
}
}
As the number of dependencies grows, the constructor becomes a wall of text. Primary Constructors solve this by merging the class declaration and the constructor into a single line.
Primary Constructors in Action
By moving the parameters to the class header, the compiler automatically handles the capture of these variables.
The Modern Way (C# 12):
C#
public class OrderService(
IOrderRepository repository,
INotificationService notification,
ILogger<OrderService> logger) : IOrderService
{
public async Task ProcessOrder(Order order)
{
logger.LogInformation("Processing order {Id}", order.Id);
await repository.SaveAsync(order);
}
}
In this version, repository, notification, and logger are available throughout the class body. Notice how we no longer need the _ prefix for private fields because the parameters themselves act as the storage.
Technical Comparison: Primary Constructors vs. Traditional Constructors
| Feature | Traditional Constructor | Primary Constructor (C# 12) |
| Declaration | Inside the class body | At the class header |
| Field Assignment | Manual (_field = param) | Automatic (Captured by compiler) |
| Boilerplate Level | High | Minimal |
| Readability | Can be cluttered | Clean and focused |
| Inheritance | Uses base() syntax | Uses : BaseClass(params) |
Advanced Use Cases
1. Elegant Base Class Initialization
When working with complex inheritance trees, passing data to the base class used to be clunky. Primary Constructors make the relationship explicit and concise.
C#
public abstract class DataAccess(string connectionString)
{
protected string DbPath = connectionString;
}
public class SqlRepository(string connectionString, ILogger logger)
: DataAccess(connectionString)
{
public void Connect() => logger.LogInformation("Connecting to {Path}", DbPath);
}
2. Field Initialization and Logic
You can still use the parameters to initialize other fields or properties if you need to transform the data before storage:
C#
public class CachedClient(string apiSecret)
{
private readonly string _encodedSecret = Convert.ToBase64String(Encoding.UTF8.GetBytes(apiSecret));
public void Authenticate() { /* Use _encodedSecret */ }
}
Critical Considerations: The “Gotchas”
Before you refactor your entire codebase, there are two important things to remember:
- Capturing is not “Read-Only” by default: Unlike the private
readonlyfields we used to manually create, Primary Constructor parameters are mutable within the class. If you accidentally reassignlogger = null;inside a method, it will stay null for the rest of the object’s lifecycle. - Multiple Constructors: If you define additional constructors (overloads), they must call the Primary Constructor using the
this(...)syntax. This ensures that the primary parameters are always initialized.
C#
public class MyService(int id)
{
// Overloaded constructor must call the primary one
public MyService() : this(0)
{
}
}
Best Practices for NashTech Developers
To maintain high code quality, we recommend the following when using this feature:
- Use for DI: It is most effective in Services, Controllers, and Repositories where you are injecting interfaces.
- Naming Conventions: Since these parameters act like private fields, some teams prefer keeping the parameter names as they are (camelCase) to distinguish them from public properties (PascalCase).
- Keep it Simple: If a class has 10+ dependencies, the class header might become too long. In such cases, consider if the class is violating the Single Responsibility Principle (SRP).
Conclusion
Primary Constructors are a welcome addition to the C# ecosystem. They align perfectly with the modern .NET philosophy of building lightweight, high-performance applications. By stripping away the boilerplate, we allow the business logic to stand front and center, making our code more maintainable and enjoyable to write.