NashTech Blog

Deep Dive into Primary Constructors: Simplifying Dependency Injection and Boilerplate Code in C# 12

Picture of Anh Trinh Tuan
Anh Trinh Tuan
Table of Contents

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

FeatureTraditional ConstructorPrimary Constructor (C# 12)
DeclarationInside the class bodyAt the class header
Field AssignmentManual (_field = param)Automatic (Captured by compiler)
Boilerplate LevelHighMinimal
ReadabilityCan be clutteredClean and focused
InheritanceUses base() syntaxUses : 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:

  1. Capturing is not “Read-Only” by default: Unlike the private readonly fields we used to manually create, Primary Constructor parameters are mutable within the class. If you accidentally reassign logger = null; inside a method, it will stay null for the rest of the object’s lifecycle.
  2. 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.

Picture of Anh Trinh Tuan

Anh Trinh Tuan

Leave a Comment

Suggested Article

Discover more from NashTech Blog

Subscribe now to keep reading and get access to the full archive.

Continue reading