DEV Community

IronSoftware
IronSoftware

Posted on

C# Patterns Every .NET Developer Should Know

You know C# basics. Here are the patterns and practices that separate experienced .NET developers from beginners.

// Pattern matching with switch expressions
var discount = customer switch
{
    { IsVip: true, YearsActive: > 5 } => 0.25m,
    { IsVip: true } => 0.15m,
    { YearsActive: > 10 } => 0.10m,
    _ => 0m
};
Enter fullscreen mode Exit fullscreen mode

Modern C# is expressive. Use it.

What Is Pattern Matching?

Pattern matching lets you test values against patterns and extract data:

// Type pattern
if (obj is string text)
{
    Console.WriteLine(text.Length);
}

// Property pattern
if (person is { Age: >= 18, Country: "US" })
{
    Console.WriteLine("Eligible to vote");
}

// List pattern (C# 11+)
if (numbers is [1, 2, .. var rest])
{
    Console.WriteLine($"Starts with 1, 2. Rest: {rest.Length} items");
}
Enter fullscreen mode Exit fullscreen mode

Replace complex conditionals with readable patterns.

When Should I Use Records?

For immutable data transfer objects:

// Record type - immutable by default
public record Person(string Name, int Age);

// Usage
var person = new Person("Alice", 30);
var older = person with { Age = 31 };  // Non-destructive mutation

// Value equality (not reference)
var p1 = new Person("Alice", 30);
var p2 = new Person("Alice", 30);
Console.WriteLine(p1 == p2);  // True
Enter fullscreen mode Exit fullscreen mode

Use records for:

  • DTOs
  • API responses
  • Event data
  • Configuration objects

Use classes for:

  • Entities with identity
  • Mutable state
  • Complex behavior

How Do I Use Init-Only Properties?

Set during initialization only:

public class Order
{
    public int Id { get; init; }
    public DateTime CreatedAt { get; init; }
    public decimal Total { get; set; }  // Can change
}

var order = new Order
{
    Id = 1,
    CreatedAt = DateTime.UtcNow,
    Total = 100m
};

// order.Id = 2;  // Compile error - init only
order.Total = 150m;  // OK - has setter
Enter fullscreen mode Exit fullscreen mode

Enforce immutability where it matters.

What Is the Null Object Pattern?

Avoid null checks with default implementations:

public interface ILogger
{
    void Log(string message);
}

public class ConsoleLogger : ILogger
{
    public void Log(string message) => Console.WriteLine(message);
}

public class NullLogger : ILogger
{
    public void Log(string message) { }  // Do nothing
}

// Usage - no null checks needed
public class Service
{
    private readonly ILogger _logger;

    public Service(ILogger logger = null)
    {
        _logger = logger ?? new NullLogger();
    }

    public void DoWork()
    {
        _logger.Log("Starting work");  // Always safe
    }
}
Enter fullscreen mode Exit fullscreen mode

How Do I Implement the Options Pattern?

Configuration in ASP.NET Core:

// Configuration class
public class EmailSettings
{
    public string SmtpServer { get; set; }
    public int Port { get; set; }
    public string FromAddress { get; set; }
}

// appsettings.json
{
    "Email": {
        "SmtpServer": "smtp.example.com",
        "Port": 587,
        "FromAddress": "noreply@example.com"
    }
}

// Registration
builder.Services.Configure<EmailSettings>(
    builder.Configuration.GetSection("Email"));

// Usage via DI
public class EmailService
{
    private readonly EmailSettings _settings;

    public EmailService(IOptions<EmailSettings> options)
    {
        _settings = options.Value;
    }
}
Enter fullscreen mode Exit fullscreen mode

What Is the Result Pattern?

Return success/failure without exceptions:

public class Result<T>
{
    public bool IsSuccess { get; }
    public T Value { get; }
    public string Error { get; }

    private Result(T value) { IsSuccess = true; Value = value; }
    private Result(string error) { IsSuccess = false; Error = error; }

    public static Result<T> Success(T value) => new(value);
    public static Result<T> Failure(string error) => new(error);
}

// Usage
public Result<User> GetUser(int id)
{
    var user = _db.Users.Find(id);
    return user != null
        ? Result<User>.Success(user)
        : Result<User>.Failure("User not found");
}

// Consuming
var result = GetUser(1);
if (result.IsSuccess)
{
    Console.WriteLine(result.Value.Name);
}
else
{
    Console.WriteLine(result.Error);
}
Enter fullscreen mode Exit fullscreen mode

Use for operations that can legitimately fail.

How Do I Use the Specification Pattern?

Encapsulate query logic:

public interface ISpecification<T>
{
    Expression<Func<T, bool>> ToExpression();
}

public class ActiveCustomerSpec : ISpecification<Customer>
{
    public Expression<Func<Customer, bool>> ToExpression()
        => c => c.IsActive && !c.IsDeleted;
}

public class HighValueCustomerSpec : ISpecification<Customer>
{
    private readonly decimal _minValue;

    public HighValueCustomerSpec(decimal minValue)
        => _minValue = minValue;

    public Expression<Func<Customer, bool>> ToExpression()
        => c => c.TotalPurchases >= _minValue;
}

// Usage with EF Core
var spec = new ActiveCustomerSpec();
var customers = await _context.Customers
    .Where(spec.ToExpression())
    .ToListAsync();
Enter fullscreen mode Exit fullscreen mode

What Are Primary Constructors?

C# 12 feature for cleaner dependency injection:

// Old way
public class OrderService
{
    private readonly IOrderRepository _repository;
    private readonly ILogger<OrderService> _logger;

    public OrderService(IOrderRepository repository, ILogger<OrderService> logger)
    {
        _repository = repository;
        _logger = logger;
    }
}

// C# 12 - Primary constructor
public class OrderService(IOrderRepository repository, ILogger<OrderService> logger)
{
    public async Task<Order> GetOrder(int id)
    {
        logger.LogInformation("Getting order {Id}", id);
        return await repository.GetByIdAsync(id);
    }
}
Enter fullscreen mode Exit fullscreen mode

Less boilerplate. Same functionality.

How Do I Use Collection Expressions?

C# 12 unified collection syntax:

// Array
int[] numbers = [1, 2, 3, 4, 5];

// List
List<string> names = ["Alice", "Bob", "Charlie"];

// Span
Span<int> span = [1, 2, 3];

// Spread operator
int[] first = [1, 2, 3];
int[] second = [4, 5, 6];
int[] combined = [..first, ..second];  // [1, 2, 3, 4, 5, 6]

// Empty collection
List<int> empty = [];
Enter fullscreen mode Exit fullscreen mode

One syntax for all collection types.

What Is the Dispose Pattern?

Properly clean up resources:

public class FileProcessor : IDisposable
{
    private readonly StreamReader _reader;
    private bool _disposed;

    public FileProcessor(string path)
    {
        _reader = new StreamReader(path);
    }

    public void Process()
    {
        ObjectDisposedException.ThrowIf(_disposed, this);
        // Use _reader
    }

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing)
    {
        if (_disposed) return;

        if (disposing)
        {
            _reader?.Dispose();
        }

        _disposed = true;
    }
}

// Usage
using var processor = new FileProcessor("data.txt");
processor.Process();
// Automatically disposed
Enter fullscreen mode Exit fullscreen mode

How Do I Implement Async Dispose?

For async cleanup:

public class AsyncService : IAsyncDisposable
{
    private readonly HttpClient _client = new();

    public async ValueTask DisposeAsync()
    {
        await FlushAsync();
        _client.Dispose();
    }

    private async Task FlushAsync()
    {
        // Async cleanup logic
    }
}

// Usage
await using var service = new AsyncService();
Enter fullscreen mode Exit fullscreen mode

What Is the Factory Pattern?

Create objects without specifying exact class:

public interface INotificationSender
{
    Task SendAsync(string message);
}

public class EmailSender : INotificationSender { /* ... */ }
public class SmsSender : INotificationSender { /* ... */ }
public class PushSender : INotificationSender { /* ... */ }

public class NotificationFactory
{
    public INotificationSender Create(NotificationType type) => type switch
    {
        NotificationType.Email => new EmailSender(),
        NotificationType.Sms => new SmsSender(),
        NotificationType.Push => new PushSender(),
        _ => throw new ArgumentException($"Unknown type: {type}")
    };
}
Enter fullscreen mode Exit fullscreen mode

How Do I Use Extension Methods Effectively?

Add functionality to existing types:

public static class StringExtensions
{
    public static bool IsNullOrEmpty(this string value)
        => string.IsNullOrEmpty(value);

    public static string Truncate(this string value, int maxLength)
        => value.Length <= maxLength ? value : value[..maxLength] + "...";
}

public static class EnumerableExtensions
{
    public static IEnumerable<T> WhereNotNull<T>(this IEnumerable<T?> source)
        where T : class
        => source.Where(x => x != null)!;

    public static async Task<List<T>> ToListAsync<T>(
        this IAsyncEnumerable<T> source,
        CancellationToken ct = default)
    {
        var list = new List<T>();
        await foreach (var item in source.WithCancellation(ct))
        {
            list.Add(item);
        }
        return list;
    }
}

// Usage
var text = "Hello".IsNullOrEmpty();
var truncated = longString.Truncate(100);
Enter fullscreen mode Exit fullscreen mode

Quick Reference: Modern C# Features

Feature Version Example
Records C# 9 record Person(string Name)
Init-only C# 9 public int Id { get; init; }
Pattern matching C# 8+ if (x is { Prop: > 5 })
Switch expressions C# 8 var x = y switch { ... }
Primary constructors C# 12 class Foo(IService svc)
Collection expressions C# 12 int[] x = [1, 2, 3]
Raw string literals C# 11 """JSON here"""
Required members C# 11 required string Name
File-scoped types C# 11 file class Helper

What Should I Avoid?

Don't overuse inheritance. Prefer composition.

Don't catch Exception. Catch specific exceptions.

Don't use async void. Use async Task.

Don't ignore CancellationToken. Pass it through.

Don't block async code. No .Result or .Wait().

These patterns make your code more maintainable, testable, and idiomatic. Use them consistently.


Written by Jacob Mellor, CTO at Iron Software. Jacob created IronPDF and leads a team of 50+ engineers building .NET document processing libraries.

Top comments (0)