C# 12 Primary Constructors in Classes and Structs

C# 12 expands primary constructors beyond record types, allowing developers to define constructor parameters directly on any class or struct declaration. This article explains how primary constructors work for regular classes and structs, how they differ from record constructors, and how they simplify dependency injection and boilerplate field initialization.

Syntax and Basic Usage

In traditional C#, declaring a constructor requires declaring backing fields, setting up parameters in a dedicated constructor block, and assigning those parameters manually. With C# 12, parameters are placed directly in parentheses following the type name:

public class UserService(IUserRepository userRepository, ILogger logger)
{
    public void DeleteUser(int id)
    {
        logger.Log($"Deleting user {id}");
        userRepository.Delete(id);
    }
}

The parameters userRepository and logger are available throughout the body of the class, including inside methods, properties, and initializers.

Key Characteristics and Scope

Unlike records, primary constructor parameters in standard classes and structs do not automatically become public properties. Instead, their behavior is governed by the following rules:

  • Capture Semantics: If a primary constructor parameter is used inside a method, local function, or property accessor, the compiler automatically generates a hidden private backing field to store the value. If it is only used during object initialization (such as initializing a property), no field is generated.
  • Mutability: The parameters behave like mutable variables within the class scope. If you reassign a primary constructor parameter within a method, the captured state updates. However, it is standard practice to treat them as read-only.
  • Inheritance and Base Calls: If a derived class uses a primary constructor, it must pass arguments to the base constructor directly on the class declaration line:
public class BaseService(string serviceName)
{
}

public class CustomService(string serviceName, int timeout) 
    : BaseService(serviceName)
{
}

Chaining Auxiliary Constructors

If a class or struct requires additional, overloaded constructors, each auxiliary constructor must explicitly invoke the primary constructor using this(...):

public struct Point(int x, int y)
{
    // Auxiliary constructor chaining to the primary constructor
    public Point() : this(0, 0)
    {
    }
}

Primary Constructors vs. Records

The core distinction between primary constructors in standard types versus records comes down to public interface generation:

  • Records: Parameters generate public, init-only properties (public int X { get; init; }) by default, along with compiler-generated equality members and deconstructors.
  • Classes and Structs: Parameters do not generate public properties. They simply act as in-scope arguments across the type definition, primarily serving to eliminate repetitive private field boilerplate.

Primary Benefits

  1. Reduced Boilerplate: Eliminates repetitive field declarations and constructor assignments.
  2. Cleaner Dependency Injection: Makes declaring dependencies in services concise and readable.
  3. Consistency: Unifies parameter initialization syntax across records, classes, and structs.