C# 12 Features — Primary Constructors, Collection Expressions and Aliases

C# 12 (released November 2023, shipped with .NET 8) introduced several features that reduce boilerplate for the patterns most common in ASP.NET Core. Primary constructors on classes and structs extend the convenience previously reserved for records to all type declarations — particularly useful for service classes that receive dependencies via constructor injection. Collection expressions unify the syntax for creating all collection types. Together these features make ASP.NET Core service and controller code noticeably cleaner without changing any runtime semantics.

Primary Constructors on Classes (C# 12)

// ── Traditional constructor injection boilerplate ─────────────────────────
public class PostService
{
    private readonly IPostRepository _repo;
    private readonly IEmailSender    _email;
    private readonly ILogger<PostService> _logger;

    public PostService(
        IPostRepository       repo,
        IEmailSender          email,
        ILogger<PostService>  logger)
    {
        _repo   = repo;
        _email  = email;
        _logger = logger;
    }
}

// ── Primary constructor (C# 12) — eliminates the boilerplate ─────────────
public class PostService(
    IPostRepository      repo,
    IEmailSender         email,
    ILogger<PostService> logger)
{
    // Parameters are directly available throughout the class
    public async Task<Post> GetByIdAsync(int id, CancellationToken ct = default)
    {
        logger.LogInformation("Fetching post {Id}", id);
        return await repo.GetByIdAsync(id, ct)
            ?? throw new NotFoundException(nameof(Post), id);
    }

    public async Task PublishAsync(int id, string authorEmail)
    {
        var post = await GetByIdAsync(id);
        post.IsPublished = true;
        await repo.UpdateAsync(post);
        await email.SendAsync(authorEmail, "Your post is live!", $"'{post.Title}' is published.");
    }
}

// Note: if you need to store a parameter as a field (e.g., for mutation),
// still declare the field explicitly:
public class CachedPostService(IPostRepository repo, IMemoryCache cache)
{
    private readonly IMemoryCache _cache = cache;   // explicit field for cache
    // repo is used directly as the primary constructor parameter
}
Note: Primary constructor parameters on classes are not automatically stored as fields — they are in scope for the entire class body (methods, properties, field initialisers) but the compiler may or may not emit a backing field depending on how the parameter is used. If you need to store a parameter for later mutation or if you need a field for serialisation, declare an explicit field and initialise it from the primary constructor parameter. This is different from records where positional parameters always become init properties.
Tip: Primary constructors work well for simple dependency injection but become harder to read if you need to add argument validation. If a service requires validation like ArgumentNullException.ThrowIfNull(repo), put that in an explicit constructor body. The sweet spot for primary constructors is simple services with 2–4 injected dependencies and no validation — exactly the most common pattern in ASP.NET Core service layers.
Warning: Primary constructor parameters captured by a lambda or local function create a closure — the compiler generates a hidden backing field to support the closure. Be aware that if a parameter is captured in a lambda that is stored as a field or event, the parameter’s lifetime is extended to the object’s lifetime. This is usually the intended behaviour for injected services but can surprise you with value types or large objects you expected to be temporary.

Collection Expressions (C# 12)

// ── Unified syntax for creating collections with [element, element, ...] ──
int[]           array  = [1, 2, 3, 4, 5];
List<string>    list   = ["alpha", "beta", "gamma"];
ImmutableArray<int> immutable = [10, 20, 30];
Span<byte>      span   = [0xFF, 0xFE, 0x00];

// Spread operator .. — include elements from another collection
int[] first   = [1, 2, 3];
int[] second  = [4, 5, 6];
int[] merged  = [..first, ..second];        // [1, 2, 3, 4, 5, 6]
int[] prepend = [0, ..first];               // [0, 1, 2, 3]
int[] append  = [..first, 7, 8];            // [1, 2, 3, 7, 8]

// In method calls
ProcessItems([1, 2, 3]);    // passes a ReadOnlySpan<int> or List<int> depending on param type
SetTags(["dotnet", "csharp"]);  // IEnumerable<string>, List<string>, or string[]

Using Aliases for Complex Types (C# 12)

// using alias for complex generic types — C# 12 extends this to any type
using StringDictionary  = Dictionary<string, string>;
using PostLookup        = Dictionary<int, List<Post>>;
using ValidationErrors  = Dictionary<string, string[]>;
using UserIdSet         = HashSet<string>;

// Use the alias anywhere in the file
StringDictionary config = new();
config["key"] = "value";

ValidationErrors errors = new()
{
    ["Title"] = ["Title is required", "Title max 200 chars"],
    ["Body"]  = ["Body cannot be empty"],
};

Common Mistakes

Mistake 1 — Expecting primary constructor parameters to be stored as fields automatically

❌ Wrong — the parameter may not be a field; assigning to it does not persist:

public class Service(IRepository repo)
{
    public void Replace(IRepository newRepo) { repo = newRepo; }  // does NOT update a stored field!
}

✅ Correct — declare an explicit private field if you need mutation.

Mistake 2 — Confusing collection expressions with array literals (they look similar)

Collection expressions [1, 2, 3] adapt to the target type — they can produce arrays, lists, spans, or immutable collections. Old array literals new[] { 1, 2, 3 } always produce arrays. Prefer collection expressions in modern code; use new[] only when array type is specifically required.

🧠 Test Yourself

A PostService uses a primary constructor with an IPostRepository repo parameter. Can you call repo.GetByIdAsync() inside a method defined in the class body?