You are viewing a preview of this lesson. Sign in to start learning
Back to C# Programming

Primary Constructors

Use constructor parameters directly in class body for cleaner initialization

Primary Constructors in C#

Primary constructors bring a cleaner, more concise syntax to C# class definitions, letting you declare constructor parameters directly in the type declaration. This lesson covers the syntax of primary constructors, their behavior with records and classes, parameter scope and lifetime, and best practices for using this modern C# featureโ€”essential knowledge for writing elegant, maintainable code with free flashcards to reinforce your learning.

Welcome to Primary Constructors! ๐Ÿ’ป

Introduced in C# 12, primary constructors represent a significant evolution in how we define types in C#. Instead of writing traditional constructor methods with parameter lists and initialization code, you can now declare constructor parameters directly alongside your class or struct name. This feature was initially available only for records (introduced in C# 9), but has now expanded to all reference and value types.

Primary constructors reduce boilerplate code, make your intentions clearer, and encourage immutable design patterns. They're particularly powerful when combined with modern C# features like required properties, init-only setters, and expression-bodied members.

Core Concepts ๐ŸŽฏ

What Are Primary Constructors?

A primary constructor is a constructor whose parameters are declared directly in the type declaration itself. Instead of this traditional approach:

public class Person
{
    private readonly string _name;
    private readonly int _age;
    
    public Person(string name, int age)
    {
        _name = name;
        _age = age;
    }
}

You can write:

public class Person(string name, int age)
{
    // Parameters 'name' and 'age' are automatically available
}

The parameters declared in parentheses after the type name become available throughout the entire class body. This is fundamentally different from traditional constructorsโ€”these parameters aren't just initialization values, they're captured variables that remain accessible.

Syntax and Declaration ๐Ÿ“

The basic syntax follows this pattern:

[modifiers] class|struct|record TypeName(ParameterList) [: BaseType(args)]
{
    // Type body
}

Key points:

  • Parameters go in parentheses immediately after the type name
  • You can have any number of parameters with any valid parameter modifiers
  • Primary constructor parameters are implicitly private
  • They're available throughout all instance members (methods, properties, nested functions)
  • They exist for the entire lifetime of the object

Here's a more complete example:

public class Logger(string logPath, LogLevel minimumLevel = LogLevel.Info)
{
    public void Log(string message)
    {
        // Primary constructor parameters are directly accessible
        if (GetCurrentLevel() >= minimumLevel)
        {
            File.AppendAllText(logPath, $"{DateTime.Now}: {message}\n");
        }
    }
    
    private LogLevel GetCurrentLevel() => LogLevel.Debug;
}

Parameter Scope and Lifetime ๐Ÿ”„

Primary constructor parameters have unique scoping rules:

AspectBehavior
AccessibilityPrivate to the classโ€”not accessible from outside
LifetimeExist for the entire object lifetime (captured)
StorageCompiler creates hidden fields as needed
ScopeAvailable in all instance members, property initializers, and field initializers

Important: Primary constructor parameters are NOT properties. They're captured parameters that behave more like private fields. If you want to expose them publicly, you must explicitly create properties:

public class Product(string name, decimal price)
{
    // Expose as public properties
    public string Name { get; } = name;
    public decimal Price { get; } = price;
    
    // Or use expression-bodied properties
    public string DisplayName => $"{name} - ${price}";
}

๐Ÿ’ก Tip: The compiler generates hidden backing fields only when necessary (when parameters are captured in ways that require storage beyond initialization). Simple assignments to properties don't create extra fields.

Primary Constructors with Classes vs Records ๐Ÿ†š

While the syntax looks similar, primary constructors behave differently for classes and records:

FeatureClassesRecords
Property GenerationNoneโ€”you must create properties manuallyAutomatic public init-only properties
DeconstructionNot generatedDeconstruct method auto-generated
Default BehaviorParameters remain privateParameters become public API
EqualityReference equality (default)Value-based equality using all properties

Records example:

// Record automatically creates public properties
public record Person(string Name, int Age);

// Equivalent to:
public record Person
{
    public string Name { get; init; }
    public int Age { get; init; }
    
    public Person(string Name, int Age)
    {
        this.Name = Name;
        this.Age = Age;
    }
    
    // Plus: Deconstruct, Equals, GetHashCode, ToString, etc.
}

Classes example:

// Class does NOT create properties automatically
public class Person(string name, int age)
{
    // Parameters are private, captured variables
    // Must explicitly create properties if needed
    public string Name => name;
    public int Age => age;
}

Combining with Additional Constructors ๐Ÿ”ง

You can define additional constructors alongside a primary constructor, but they must call the primary constructor using : this(...) syntax:

public class Rectangle(double width, double height)
{
    // Additional constructor must chain to primary
    public Rectangle(double size) : this(size, size)
    {
        // Optional additional logic
    }
    
    public double Area => width * height;
}

This ensures that the primary constructor always executes, initializing the captured parameters that the rest of the class depends on.

Inheritance and Base Class Initialization ๐Ÿ—๏ธ

Primary constructors integrate seamlessly with inheritance. You can pass primary constructor parameters to the base class:

public class Animal(string name)
{
    public string Name { get; } = name;
    public virtual void Speak() => Console.WriteLine($"{name} makes a sound");
}

public class Dog(string name, string breed) : Animal(name)
{
    public string Breed { get; } = breed;
    
    public override void Speak() => Console.WriteLine($"{name} barks!");
}

The flow:

  1. Dog's primary constructor receives name and breed
  2. name is passed to Animal's primary constructor via : Animal(name)
  3. Both parameters remain accessible in Dog's body

Field and Property Initializers ๐ŸŽจ

Primary constructor parameters are available in field and property initializers, executing before any constructor body:

public class Configuration(string environment, int timeout)
{
    // Parameters available in initializers
    private readonly string _configPath = $"/config/{environment}.json";
    private readonly TimeSpan _timeout = TimeSpan.FromSeconds(timeout);
    
    public bool IsProduction { get; } = environment.Equals("Production", 
                                        StringComparison.OrdinalIgnoreCase);
    
    // Also available in methods
    public void Display()
    {
        Console.WriteLine($"Env: {environment}, Timeout: {timeout}s");
    }
}

Initialization order:

  1. Base class primary constructor
  2. Field initializers (in declaration order)
  3. Property initializers (in declaration order)
  4. Primary constructor parameters become available
  5. Additional constructor bodies (if any)

Detailed Examples ๐Ÿ’ก

Example 1: Dependency Injection Container

Primary constructors shine in dependency injection scenarios:

public class OrderService(IOrderRepository repository, 
                          IEmailService emailService,
                          ILogger<OrderService> logger)
{
    public async Task<Order> CreateOrderAsync(OrderRequest request)
    {
        logger.LogInformation("Creating order for customer {CustomerId}", 
                             request.CustomerId);
        
        var order = new Order
        {
            CustomerId = request.CustomerId,
            Items = request.Items,
            CreatedAt = DateTime.UtcNow
        };
        
        await repository.SaveAsync(order);
        await emailService.SendOrderConfirmationAsync(order);
        
        return order;
    }
    
    public async Task<Order?> GetOrderAsync(int orderId)
    {
        logger.LogDebug("Fetching order {OrderId}", orderId);
        return await repository.GetByIdAsync(orderId);
    }
}

Why this works well:

  • Dependencies are clearly declared upfront
  • No boilerplate field declarations needed
  • No explicit assignment code
  • All dependencies remain accessible throughout the class
  • Follows constructor injection best practices

Example 2: Configuration-Based Component

Primary constructors excel with configuration objects:

public class CacheManager(CacheOptions options)
{
    private readonly Dictionary<string, CacheEntry> _cache = new();
    private readonly Timer _cleanupTimer = new(CleanupCallback, null, 
                                               options.CleanupInterval, 
                                               options.CleanupInterval);
    
    public void Set(string key, object value)
    {
        if (key.Length > options.MaxKeyLength)
            throw new ArgumentException($"Key exceeds maximum length of {options.MaxKeyLength}");
        
        if (_cache.Count >= options.MaxItems)
            EvictOldest();
        
        _cache[key] = new CacheEntry
        {
            Value = value,
            ExpiresAt = DateTime.UtcNow.Add(options.DefaultExpiration)
        };
    }
    
    public object? Get(string key)
    {
        if (!_cache.TryGetValue(key, out var entry))
            return null;
        
        if (DateTime.UtcNow > entry.ExpiresAt)
        {
            _cache.Remove(key);
            return null;
        }
        
        return entry.Value;
    }
    
    private void EvictOldest()
    {
        // Implementation using options.EvictionStrategy
        var strategy = options.EvictionStrategy;
        // ... eviction logic
    }
    
    private static void CleanupCallback(object? state)
    {
        // Periodic cleanup logic
    }
}

public record CacheOptions
{
    public int MaxItems { get; init; } = 1000;
    public int MaxKeyLength { get; init; } = 256;
    public TimeSpan DefaultExpiration { get; init; } = TimeSpan.FromMinutes(30);
    public TimeSpan CleanupInterval { get; init; } = TimeSpan.FromMinutes(5);
    public string EvictionStrategy { get; init; } = "LRU";
}

Benefits demonstrated:

  • Configuration object passed once, available everywhere
  • Field initializers can use options immediately
  • Methods reference options directly without storing multiple fields
  • Clear separation between configuration and state

Example 3: Immutable Domain Model

Combining primary constructors with init-only properties creates clean immutable types:

public class BankAccount(string accountNumber, string ownerName, decimal initialBalance)
{
    public string AccountNumber { get; } = accountNumber;
    public string OwnerName { get; } = ownerName;
    public decimal Balance { get; private set; } = initialBalance;
    
    private readonly List<Transaction> _transactions = new()
    {
        new Transaction(DateTime.UtcNow, "Initial Deposit", initialBalance, initialBalance)
    };
    
    public IReadOnlyList<Transaction> Transactions => _transactions.AsReadOnly();
    
    public void Deposit(decimal amount)
    {
        if (amount <= 0)
            throw new ArgumentException("Deposit amount must be positive", nameof(amount));
        
        Balance += amount;
        _transactions.Add(new Transaction(DateTime.UtcNow, "Deposit", amount, Balance));
    }
    
    public bool Withdraw(decimal amount)
    {
        if (amount <= 0)
            throw new ArgumentException("Withdrawal amount must be positive", nameof(amount));
        
        if (Balance < amount)
            return false;
        
        Balance -= amount;
        _transactions.Add(new Transaction(DateTime.UtcNow, "Withdrawal", -amount, Balance));
        return true;
    }
    
    public override string ToString() =>
        $"Account {accountNumber} ({ownerName}): ${Balance:F2}";
}

public record Transaction(DateTime Timestamp, string Description, 
                          decimal Amount, decimal BalanceAfter);

Design patterns showcased:

  • Primary constructor establishes immutable identity
  • Properties expose immutable data
  • Internal state (Balance) can change, but identity cannot
  • Parameters remain accessible for methods like ToString()
  • Record used for truly immutable Transaction type

Example 4: Generic Type with Constraints

Primary constructors work seamlessly with generic types:

public class Repository<T>(DbContext context, ILogger<Repository<T>> logger) 
    where T : class
{
    private readonly DbSet<T> _dbSet = context.Set<T>();
    
    public async Task<T?> GetByIdAsync(int id)
    {
        logger.LogDebug("Fetching {EntityType} with ID {Id}", typeof(T).Name, id);
        return await _dbSet.FindAsync(id);
    }
    
    public async Task<List<T>> GetAllAsync()
    {
        logger.LogDebug("Fetching all {EntityType} entities", typeof(T).Name);
        return await _dbSet.ToListAsync();
    }
    
    public async Task<T> AddAsync(T entity)
    {
        logger.LogInformation("Adding new {EntityType} entity", typeof(T).Name);
        _dbSet.Add(entity);
        await context.SaveChangesAsync();
        return entity;
    }
    
    public async Task UpdateAsync(T entity)
    {
        logger.LogInformation("Updating {EntityType} entity", typeof(T).Name);
        _dbSet.Update(entity);
        await context.SaveChangesAsync();
    }
    
    public async Task DeleteAsync(T entity)
    {
        logger.LogWarning("Deleting {EntityType} entity", typeof(T).Name);
        _dbSet.Remove(entity);
        await context.SaveChangesAsync();
    }
}

Key observations:

  • Generic type parameters don't interfere with primary constructor syntax
  • Both context and logger are type-safe and fully accessible
  • Field initializer (_dbSet) uses primary constructor parameter
  • Combines modern C# features: generics, async/await, primary constructors

Common Mistakes โš ๏ธ

Mistake 1: Expecting Properties to Be Generated (Classes)

โŒ Wrong:

public class Person(string name, int age)
{
    // Nothing here
}

// Later...
var person = new Person("Alice", 30);
Console.WriteLine(person.name); // โŒ ERROR: 'name' is not accessible

โœ… Correct:

public class Person(string name, int age)
{
    // Explicitly expose as properties
    public string Name { get; } = name;
    public int Age { get; } = age;
}

// Now works
var person = new Person("Alice", 30);
Console.WriteLine(person.Name); // โœ… "Alice"

Why it matters: Unlike records, classes don't auto-generate properties. Primary constructor parameters in classes are private captured variables.

Mistake 2: Not Chaining Additional Constructors

โŒ Wrong:

public class Rectangle(double width, double height)
{
    public double Area => width * height;
    
    // โŒ ERROR: Must call primary constructor
    public Rectangle(double size)
    {
        // This doesn't compile!
    }
}

โœ… Correct:

public class Rectangle(double width, double height)
{
    public double Area => width * height;
    
    // โœ… Chain to primary constructor
    public Rectangle(double size) : this(size, size)
    {
        // Optional additional initialization
    }
}

Mistake 3: Modifying Parameters (They're Not Variables)

โŒ Wrong:

public class Product(string name, decimal price)
{
    public void ApplyDiscount(decimal percentage)
    {
        // โŒ ERROR: Cannot assign to primary constructor parameter
        price = price * (1 - percentage / 100);
    }
}

โœ… Correct:

public class Product(string name, decimal price)
{
    public decimal CurrentPrice { get; private set; } = price;
    
    public void ApplyDiscount(decimal percentage)
    {
        // โœ… Modify the property, not the parameter
        CurrentPrice = CurrentPrice * (1 - percentage / 100);
    }
}

Why: Primary constructor parameters are read-only captured values. If you need mutability, use properties.

Mistake 4: Confusion About Scope in Static Members

โŒ Wrong:

public class Logger(string logPath)
{
    // โŒ ERROR: Cannot use instance parameter in static context
    public static void LogStatic(string message)
    {
        File.AppendAllText(logPath, message);
    }
}

โœ… Correct:

public class Logger(string logPath)
{
    // โœ… Instance method can access parameter
    public void Log(string message)
    {
        File.AppendAllText(logPath, message);
    }
    
    // โœ… Static method takes its own parameter
    public static void LogStatic(string logPath, string message)
    {
        File.AppendAllText(logPath, message);
    }
}

Mistake 5: Overusing Primary Constructors When Logic Is Needed

โŒ Wrong approach:

public class User(string email, string password)
{
    // โŒ Validation happens AFTER construction
    public string Email { get; } = ValidateEmail(email);
    
    private static string ValidateEmail(string email)
    {
        if (!email.Contains('@'))
            throw new ArgumentException("Invalid email");
        return email;
    }
}

โœ… Better:

public class User
{
    public string Email { get; }
    public string PasswordHash { get; }
    
    // โœ… Traditional constructor allows clear validation logic
    public User(string email, string password)
    {
        if (string.IsNullOrWhiteSpace(email))
            throw new ArgumentException("Email required", nameof(email));
        if (!email.Contains('@'))
            throw new ArgumentException("Invalid email format", nameof(email));
        if (password.Length < 8)
            throw new ArgumentException("Password too short", nameof(password));
        
        Email = email.ToLowerInvariant();
        PasswordHash = HashPassword(password);
    }
    
    private static string HashPassword(string password) => 
        /* hashing logic */;
}

When to use traditional constructors:

  • Complex validation logic
  • Multiple steps of initialization
  • Need to transform parameters significantly
  • Conditional initialization paths

Key Takeaways ๐ŸŽฏ

๐Ÿ“‹ Primary Constructors Quick Reference

FeatureDetail
Syntaxclass TypeName(params) { }
AvailabilityC# 12+ (all types), C# 9+ (records only)
Parameter ScopePrivate, available in all instance members
LifetimeCaptured for entire object lifetime
ClassesNo auto-properties, parameters stay private
RecordsAuto-generate public init-only properties
Additional ConstructorsMust chain with : this(...)
InheritancePass parameters to base: : Base(param)
MutabilityParameters are read-only (immutable)
Best ForDI, simple initialization, immutable types

๐Ÿง  Memory Device - "PILE":

  • Parameters are private (classes)
  • Immutable captured values
  • Lifetime of the object
  • Exposed via properties (if needed)

When to Use Primary Constructors โœ…

  • Dependency injection - Clean service constructors
  • Immutable types - Combined with init-only properties
  • Simple initialization - No complex logic needed
  • Records - Natural fit for data-centric types
  • Configuration passing - Options pattern implementations

When to Use Traditional Constructors ๐Ÿ”„

  • Complex validation - Multiple validation steps
  • Parameter transformation - Significant processing needed
  • Conditional logic - Different initialization paths
  • Multiple constructor overloads - With different logic in each
  • Backwards compatibility - Existing codebases

๐Ÿ’ก Pro Tip: You can mix both approaches! Use a primary constructor for dependencies and add traditional constructors for complex factory methods.

๐Ÿ“š Further Study

  1. Microsoft Documentation - Primary Constructors: https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-12#primary-constructors
  2. C# Language Design - Primary Constructors Proposal: https://github.com/dotnet/csharplang/blob/main/proposals/csharp-12.0/primary-constructors.md
  3. Records in C# (Background Context): https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/record

๐Ÿ”ฅ You're now ready to write cleaner, more expressive C# code with primary constructors! Practice by refactoring some of your existing classes to use this feature where appropriate, and watch your code become more concise and maintainable.