7 .NET 8 Features You're Not Using (But Should Be)

Find out 7 underused C# 12 and .NET 8 features for cleaner, faster, and more efficient code. Code examples included.

.NET Core .NET8 C# Tip & Tricks

7 .NET 8 Features You're Not Using (But Should Be)

  • Sunday, September 7, 2025

Find out 7 underused C# 12 and .NET 8 features for cleaner, faster, and more efficient code. Code examples included.

Staying current with .NET's rapid release cycle is a challenge every developer faces. You might be using Min and Max APIs or the new Random.Shared singleton, but the true power of .NET 8 and C# 12 often lies hidden beneath the surface—in features that don't make the headline release notes but can dramatically simplify your code and boost your application's performance. For development teams focused on delivering product value, finding the time to deep-dive into every new capability can be a significant hurdle.

After leading teams through dozens of .NET 8 migrations and modernizations at Facile Technolab, we've identified seven powerfully efficient features that many teams overlook. These aren't just syntax sugars; they are strategic tools that can reduce boilerplate, enhance performance, and make your codebase more maintainable. This is where the guidance of an experienced .NET development team can provide a critical advantage, helping you pinpoint which advancements will deliver the greatest return for your specific project. Let's dive in and unlock the full potential of your .NET 8 applications.

1. Keyed Dependency Injection: The End of Service Locator Anti-Pattern

The Problem: You have multiple implementations of the same interface (e.g., IStorageService for Azure, AWS, and local disk). Traditionally, you'd either register them with unique interfaces (cluttering your DI container) or resort to the service locator anti-pattern (services.GetServices<IStorageService>()) to resolve the right one, making your code difficult to test and reason about.

The .NET 8 Solution: Keyed Services allow you to register and resolve services using a key, typically a string or an enum.

How to Implement It:

First, register your services with keys:

// Program.cs
builder.Services.AddKeyedSingleton<IStorageService, AzureStorageService>("azure");
builder.Services.AddKeyedSingleton<IStorageService, AwsStorageService>("aws");
builder.Services.AddKeyedSingleton<IStorageService, LocalStorageService>("local");

Now, resolve them elegantly using the [FromKeyedServices] attribute:

public class FileProcessor
{
    private readonly IStorageService _storageService;

    // Constructor injection: The container injects the correct implementation based on the key.
    public FileProcessor([FromKeyedServices("azure")] IStorageService storageService)
    {
        _storageService = storageService;
    }

    public async Task ProcessFileAsync()
    {
        await _storageService.SaveAsync(...);
    }
}

You can also resolve them manually from the IServiceProvider if needed:

var azureService = _serviceProvider.GetKeyedService<IStorageService>("azure");

2. The IPipelineBehavior Powerhouse: Simplifying Cross-Cutting Concerns

While not new to .NET 8, IPipelineBehavior from the MediatR library is critically underused and is the perfect fit for modern .NET architectures. It allows you to implement cross-cutting concerns like logging, caching, validation, and performance monitoring in a single, reusable place.

The Problem: You find yourself writing the same try-catch logs, validation checks, and cache logic across dozens of handlers and services.

The .NET Solution: Create behaviors that wrap around your application's core logic.

How to Implement It:

First, define a behavior. This one adds logging and performance tracking:

public class LoggingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
    where TRequest : notnull
{
    private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _logger;

    public LoggingBehavior(ILogger<LoggingBehavior<TRequest, TResponse>> logger)
    {
        _logger = logger;
    }

    public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
    {
        var requestName = typeof(TRequest).Name;
        _logger.LogInformation("Starting request: {RequestName}", requestName);
        var stopwatch = Stopwatch.StartNew();

        try
        {
            var response = await next(); // This calls the next behavior or the actual handler
            stopwatch.Stop();
            _logger.LogInformation("Completed request: {RequestName} in {ElapsedMilliseconds}ms", requestName, stopwatch.ElapsedMilliseconds);
            return response;
        }
        catch (Exception ex)
        {
            stopwatch.Stop();
            _logger.LogError(ex, "Request failed: {RequestName} in {ElapsedMilliseconds}ms", requestName, stopwatch.ElapsedMilliseconds);
            throw;
        }
    }
}

Register it in your Program.cs. The order of registration matters.

// Register MediatR and the behavior
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblyContaining<Program>());
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
// Add other behaviors (e.g., ValidationBehavior, CachingBehavior)

Why You Should Use It: It promotes the Single Responsibility Principle. Your handlers only contain business logic, and your cross-cutting concerns are managed in one place. Adding a new global feature like performance tracking becomes a 5-minute task.

3. Frozen Collections: A Performance Booster for Immutable Data

The Problem: You use ImmutableArray or ImmutableDictionary for shared, read-only data (like configuration or cached reference data), but you're not getting the maximum read performance possible.

The .NET 8 Solution: The System.Collections.Frozen namespace introduces FrozenDictionary and FrozenSet. These collections are optimized for read-only scenarios after they're created. The "freezing" process is computationally expensive, but it results in the fastest possible read performance.

How to Implement It:

// Imagine you have a dictionary you need to share across the application
var countryCodes = new Dictionary<string, string>
{
    ["US"] = "United States",
    ["IN"] = "India",
    ["CA"] = "Canada",
    // ... many more
};

// Freeze it at startup (e.g., after loading from a DB or config)
FrozenDictionary<string, string> frozenCountryCodes = countryCodes.ToFrozenDictionary();

// Now use it anywhere - reads are incredibly fast
string countryName = frozenCountryCodes["US"]; // Blazing fast lookup

// This will throw RuntimeError - collection is read-only!
// frozenCountryCodes.Add("UK", "United Kingdom");

Why You Should Use It: For data that is populated once and read millions of times (e.g., application lookup data, cached configurations), Frozen collections provide the best read performance in .NET. It's a simple change for a potentially significant performance gain in high-throughput scenarios.

4. Primary Constructors: Beyond DTOs and Records

Everyone uses primary constructors with records (record Product(string Name, decimal Price)). Their power in regular classes is often missed.

The Problem: You have service classes or entity classes with boilerplate constructor code just to assign incoming parameters to fields.

The C# 12 Solution: Primary Constructors for non-record types allow you to declare parameters directly on the class declaration. These parameters are in scope throughout the entire class body.

How to Implement It:

Before (Boilerplate Code):

public class OrderProcessor
{
    private readonly ILogger<OrderProcessor> _logger;
    private readonly IPaymentGateway _paymentGateway;
    private readonly IInventoryService _inventoryService;

    public OrderProcessor(ILogger<OrderProcessor> logger, IPaymentGateway paymentGateway, IInventoryService inventoryService)
    {
        _logger = logger;
        _paymentGateway = paymentGateway;
        _inventoryService = inventoryService;
    }

    public void ProcessOrder() { ... }
}

After (Clean and Concise):

public class OrderProcessor(ILogger<OrderProcessor> logger, IPaymentGateway paymentGateway, IInventoryService inventoryService)
{
    public void ProcessOrder()
    {
        // You can use the parameters directly throughout the class
        logger.LogInformation("Processing order...");
        paymentGateway.Charge(...);
        // ...
    }

    // You can still use the parameters to initialize properties or other fields if needed
    private string TransactionId { get; } = GenerateId(paymentGateway.DefaultCurrency);
}

Why You Should Use It: It drastically reduces ceremonial code for dependency injection and makes classes more readable. It’s a perfect fit for service classes, controllers, and even entities in Domain-Driven Design (DDD).

5. Interceptors (Advanced): The Compiler-Powered AOP

Warning: This is an advanced feature, but it's incredibly powerful for library authors and for solving complex problems cleanly.

The Problem: You need to add code to existing methods without modifying the original source code—think of adding logging, caching, or telemetry to methods in a third-party library or in a large, established codebase.

The C# 12 Solution: Interceptors. They let you, at compile time, redirect a method call to a different method. It's a lightweight form of compile-time Aspect-Oriented Programming (AOP).

How to Implement It (Simplified):

Define the Interceptor Method: This method must have the same signature as the original and be static and partial.

// This is in your source file
public partial class MyClass
{
    public void MyMethod()
    {
        Console.WriteLine("Original method");
    }
}

// This can be in a separate file, perhaps added during a build step
public partial class MyClass
{
    [System.Runtime.CompilerServices.InterceptsLocation("path/to/original/file.cs", line: 13, character: 16)]
    public static void MyMethodInterceptor(this MyClass instance)
    {
        Console.WriteLine("Interceptor is running first!");
        instance.MyMethod(); // You can choose to call the original or not
        Console.WriteLine("Interceptor is running after!");
    }
}

How it Works: The InterceptsLocation attribute tells the compiler to redirect calls to MyMethod() at that specific location in the source code to MyMethodInterceptor instead.

Why You Should Use It: While complex, it's invaluable for building advanced frameworks, performance profiling tools, or non-invasive monitoring systems where modifying the original source is impossible or undesirable. (Internal Link: Learn about our .NET Modernization Services)

6. Configuration Binding Source Generators: A Type-Safe Config Revolution

The Problem: You use IConfiguration.GetSection("MySection").Get<MyOptions>() or the Options pattern, but you lose compile-time safety. Misspelling a property name in your appsettings.json leads to runtime errors, not build errors.

The .NET 8 Solution: Source generators for configuration binding. This generates the binding code at compile time, so if your config class changes, your JSON will be validated.

How to Implement It:

  1. Make your class partial and add the [ConfigurationSection] attribute.

  2. Ensure your appsettings.json has a matching section.

// MyOptions.cs
[ConfigurationSection("MySettings")]
public partial class MyOptions
{
    public string ApiKey { get; set; }
    public int TimeoutInSeconds { get; set; }
    public string ConnectionString { get; set; }
}

In your Program.cs, the binding is now strongly typed and checked at compile time:

// The source generator creates the .Bind() method for you!
var myOptions = builder.Configuration.BindMyOptions();

// Use it with the Options pattern
builder.Services.ConfigureMyOptions(builder.Configuration); // Also generated!

Why You Should Use It: It eliminates a whole class of runtime configuration errors. Refactoring your options class will now correctly flag mismatches in your JSON files, making your application more robust from the start.

7. TimeProvider: Taming Time for Testable Code

The Problem: Your code directly uses DateTime.UtcNow or Task.Delay, making it incredibly difficult to write unit tests for time-dependent logic (e.g., "did this cache expire?" or "retry after a delay").

The .NET 8 Solution: The new TimeProvider abstract class and SystemTimeProvider singleton. This allows you to mock time in your tests.

How to Implement It:

In Your Production Code:

public class CacheService(TimeProvider timeProvider) // Inject TimeProvider
{
    private readonly Dictionary<string, (object Item, DateTimeOffset Expiry)> _cache = new();

    public void Set(string key, object item, TimeSpan duration)
    {
        // Use the injected time provider
        var expiry = timeProvider.GetUtcNow().Add(duration);
        _cache[key] = (item, expiry);
    }

    public bool TryGet(string key, out object item)
    {
        item = null;
        if (_cache.TryGetValue(key, out var value))
        {
            // Check expiry using the injected time
            if (value.Expiry > timeProvider.GetUtcNow())
            {
                item = value.Item;
                return true;
            }
        }
        return false;
    }
}

// In Program.cs, register the default system time provider
builder.Services.AddSingleton<TimeProvider>(TimeProvider.System);

In Your Unit Tests:

// Use the MockTimeProvider from Microsoft.Extensions.TimeProvider.Testing
var testTimeProvider = new FakeTimeProvider();
var cacheService = new CacheService(testTimeProvider);

// Set an item with a 1-minute duration
cacheService.Set("test", "data", TimeSpan.FromMinutes(1));

// Fast-forward time by 2 minutes
testTimeProvider.Advance(TimeSpan.FromMinutes(2));

// Now the item should be expired
var result = cacheService.TryGet("test", out var item);
Assert.False(result); // The test passes!

Why You Should Use It: It finally makes time-dependent code 100% testable. It's a simple pattern that dramatically improves the reliability and test coverage of your application.

How to Start Implementing These Features

  1. Audit Your Codebase: Do a solution-wide search for DateTime.UtcNow, new Random(), and manual DI resolution. These are the best candidates for immediate improvement.

  2. Prioritize: Start with TimeProvider and Primary Constructors for the quickest wins and greatest impact on code quality.

  3. Plan: Use Keyed Services and IPipelineBehavior when you refactor or add new modules.

  4. Profile: Use Frozen collections only where performance profiling shows a hot path with dictionary lookups.

Adopting these features isn't just about using the latest shiny tools; it's about writing more maintainable, performant, and robust code. At Facile Technolab, we leverage these advanced capabilities to build enterprise-grade applications that scale efficiently and are easy to maintain.

Ready to modernize your .NET application and leverage these features?
Schedule a free code review with our architects to identify the highest-impact opportunities in your codebase.

Explore our .NET development services to see how we can help you build better software, faster.

Contact Facile Team

Signup for monthly updates and stay in touch!

Subscribe to Facile Technolab's monthly newsletter to receive updates on our latest news, offers, promotions, resources, source code, jobs and other exciting updates.