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:
| Aspect | Behavior |
|---|---|
| Accessibility | Private to the classโnot accessible from outside |
| Lifetime | Exist for the entire object lifetime (captured) |
| Storage | Compiler creates hidden fields as needed |
| Scope | Available 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:
| Feature | Classes | Records |
|---|---|---|
| Property Generation | Noneโyou must create properties manually | Automatic public init-only properties |
| Deconstruction | Not generated | Deconstruct method auto-generated |
| Default Behavior | Parameters remain private | Parameters become public API |
| Equality | Reference 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:
- Dog's primary constructor receives
nameandbreed nameis passed to Animal's primary constructor via: Animal(name)- 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:
- Base class primary constructor
- Field initializers (in declaration order)
- Property initializers (in declaration order)
- Primary constructor parameters become available
- 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
contextandloggerare 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
| Feature | Detail |
| Syntax | class TypeName(params) { } |
| Availability | C# 12+ (all types), C# 9+ (records only) |
| Parameter Scope | Private, available in all instance members |
| Lifetime | Captured for entire object lifetime |
| Classes | No auto-properties, parameters stay private |
| Records | Auto-generate public init-only properties |
| Additional Constructors | Must chain with : this(...) |
| Inheritance | Pass parameters to base: : Base(param) |
| Mutability | Parameters are read-only (immutable) |
| Best For | DI, 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
- Microsoft Documentation - Primary Constructors: https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-12#primary-constructors
- C# Language Design - Primary Constructors Proposal: https://github.com/dotnet/csharplang/blob/main/proposals/csharp-12.0/primary-constructors.md
- 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.