10 Hidden Features in .NET Developers Are Sleeping On

Author: Pushpa Raj Dangi

Pushpa Raj Dangi

Thumbnail for 10 Hidden Features in .NET Developers Are Sleeping On

Stop writing boilerplate code and discover the framework features that’ll make your colleagues ask “wait, .NET can do that?”

Look, I get it. You’ve been writing .NET code for years. You know LINQ like the back of your hand, you’ve memorized the async/await patterns, and you can scaffold an API in your sleep. But here’s the thing — .NET is absolutely massive, and buried in its documentation are features so useful, so time-saving, that once you discover them, you’ll wonder how you ever lived without them.

These aren’t some obscure edge cases or experimental features. These are production-ready tools that Microsoft shipped, documented (sort of), and then… didn’t really tell anyone about. Let’s fix that.

Feature #1

CallerArgumentExpression: Debug Like You’re From the Future

Ever written a validation method and gotten an exception like “Value cannot be null” with absolutely zero context about which value? Yeah, we’ve all been there, squinting at stack traces at 2 AM.

Enter CallerArgumentExpression — a hidden attribute that captures the actual expression used when calling a method. No more generic error messages.

Before: Generic and unhelpful

public static void ThrowIfNull(object argument)
{
if (argument is null)
throw new ArgumentNullException(nameof(argument));
}
// Usage
ThrowIfNull(user.Email); // Exception: "Value cannot be null. (Parameter 'argument')"
// Cool, but WHICH argument?!

After: Crystal clear context

public static void ThrowIfNull(
object argument,
[CallerArgumentExpression(nameof(argument))] string? paramName = null)
{
if (argument is null)
throw new ArgumentNullException(paramName);
}
// Usage
ThrowIfNull(user.Email); // Exception: "Value cannot be null. (Parameter 'user.Email')"
// Now we're talking!
Pro Tip:
This works brilliantly with assertion libraries. Combine it with your logging framework and you’ll get stack traces that actually make sense. Your future self will thank you.

Feature #2

CollectionsMarshal.AsSpan(): Zero-Copy List Access

Okay, this one’s a bit nerdy, but stay with me. You know how every time you iterate a List<T>, there's a tiny bit of overhead with bounds checking and potential allocations? Most of the time it's negligible. But when you're processing thousands of items in a hot path, it adds up.

CollectionsMarshal.AsSpan() gives you direct memory access to the list’s underlying array as a Span. Zero copies. Zero allocations. Pure speed.

The performance difference is wild

using System.Runtime.InteropServices;
var numbers = Enumerable.Range(1, 10_000).ToList();
// Old way: Safe but slower
foreach (var num in numbers)
{
ProcessNumber(num);
}
// New way: Unsafe but blazing fast
var span = CollectionsMarshal.AsSpan(numbers);
for (int i = 0; i < span.Length; i++)
{
ProcessNumber(span[i]);
}
Warning:
This is advanced territory. If you modify the list while holding the span, things will break spectacularly. Use this in performance-critical sections where you control the data flow.

In my testing, this approach was about 30–40% faster for large collections in tight loops. Your mileage may vary, but when performance matters, this is your secret weapon.

Feature #3

Required Members: Make Constructors Great Again

How many times have you written a class with 6 properties that absolutely must be set, so you create a constructor with 6 parameters, and suddenly your code looks like this:

var user = new User(
"john@example.com",
"John",
"Doe",
DateTime.Now,
true,
"Premium"
);
// What does 'true' mean again? 🤔

Enter the required keyword in C# 11. It’s like object initializers, but the compiler actually enforces that you set the important stuff.

Now we’re talking readable code

public class User
{
public required string Email { get; init; }
public required string FirstName { get; init; }
public required string LastName { get; init; }
public DateTime CreatedAt { get; init; } = DateTime.UtcNow;
public bool IsActive { get; init; } = true;
public string Tier { get; init; } = "Free";
}
// Usage - Crystal clear
var user = new User
{
Email = "john@example.com",
FirstName = "John",
LastName = "Doe",
Tier = "Premium"
};
// Forgot Email? Compiler error. Beautiful.

The best part? It works with inheritance and gives you compile-time safety without verbose constructors. It’s 2026 — stop making people guess what position 4 in your constructor parameter list means.

Feature #4

String Interpolation Handlers: Custom Formatting on Steroids

You know string interpolation — we all use it every day. But did you know you can create custom interpolation handlers that change how those $"..." strings actually work?

This is honestly mind-blowing. You can create a SQL query builder that prevents injection, a logging system that skips expensive ToString() calls when logging is disabled, or a templating engine — all using familiar syntax.

Example: A logging handler that’s actually smart

[InterpolatedStringHandler]
public ref struct LogInterpolatedStringHandler
{
private StringBuilder builder;
private bool enabled;
public LogInterpolatedStringHandler(
int literalLength,
int formattedCount,
Logger logger,
LogLevel level,
out bool enabled)
{
this.enabled = logger.IsEnabled(level);
enabled = this.enabled;
builder = this.enabled ? new StringBuilder(literalLength) : null!;
}
public void AppendLiteral(string s) 
{
if (enabled) builder.Append(s);
}
public void AppendFormatted(T value) 
{
if (enabled) builder.Append(value?.ToString());
}
public string GetFormattedText() => builder?.ToString() ?? string.Empty;
}
// Now your expensive logging calls only happen when logging is actually on
logger.LogDebug($"User {user.Id} performed {ExpensiveCalculation()} operations");
// If Debug logging is off, ExpensiveCalculation() is NEVER called!

This pattern is what makes StringBuilder work with interpolated strings in .NET 6+. You can do the same for your domain-specific needs.

Feature #5

Keyed Services in Dependency Injection

Alright, real talk: before .NET 8, if you needed multiple implementations of the same interface in your DI container, you had to do some weird gymnastics. Factory patterns, custom resolution logic, sacrificing a rubber duck to the debugging gods — you name it.

Not anymore. Keyed services just… solve it. Elegantly.

Setup: Register services with keys

services.AddKeyedSingleton<IPaymentGateway, StripeGateway>("stripe");
services.AddKeyedSingleton<IPaymentGateway, PayPalGateway>("paypal");
services.AddKeyedSingleton<IPaymentGateway, BraintreeGateway>("braintree");

Usage: Inject exactly what you need

public class PaymentController : ControllerBase
{
private readonly IPaymentGateway _gateway;
public PaymentController(
[FromKeyedServices("stripe")] IPaymentGateway gateway)
{
_gateway = gateway;
}
// Or resolve dynamically
public async Task ProcessPayment(
string provider,
[FromKeyedServices] IServiceProvider services)
{
var gateway = services.GetRequiredKeyedService<IPaymentGateway>(provider);
await gateway.ProcessPayment(...);
}
}

No more factory classes. No more service locator anti-patterns. Just clean, testable code that does exactly what it says.

Feature #6

Random.Shared: Stop Newing Up Random Instances

Quick question: how many times have you seen this in a codebase?

var random = new Random();
var value = random.Next(1, 100);

Seems innocent, right? Wrong. Every time you create a new Random() instance in quick succession, they often get seeded with the same value (because the seed is time-based), and you get... the same "random" numbers. Classic.

Since .NET 6, there’s Random.Shared — a thread-safe, globally shared instance that just works.

Better randomness, zero allocations

// That's it. That's the whole feature.
var value = Random.Shared.Next(1, 100);
var shuffled = items.OrderBy(_ => Random.Shared.Next()).ToList();
var randomElement = items[Random.Shared.Next(items.Count)];
Bonus Feature:
Need better randomness for security? Use RandomNumberGenerator.GetInt32() for cryptographically secure random numbers. Perfect for tokens, API keys, or anything security-sensitive.

Feature #7

Generic Math: Yes, Math With Type Parameters

Remember when you wanted to write a generic Sum() method that works for int, double, decimal, and everything else, but you couldn't because operators aren't interfaces? Well, grab your chair, because C# 11 fixed this with static abstract members in interfaces.

Write mathematical code that works for ANY numeric type

public static T Average<T>(IEnumerable<T> values) 
where T : INumber<T>
{
T sum = T.Zero;
int count = 0;

foreach (var value in values)
{
sum += value; // Yes, this works!
count++;
}

return sum / T.CreateChecked(count);
}
// Usage
var intAvg = Average(new[] { 1, 2, 3, 4, 5 }); // Works
var doubleAvg = Average(new[] { 1.5, 2.5, 3.5 }); // Works
var decimalAvg = Average(new[] { 1.5m, 2.5m, 3.5m }); // Works!

You can write generic algorithms that use +, -, *, /, comparison operators, and even mathematical functions. No more copy-pasting the same algorithm for different numeric types.

  • INumber<T> for basic arithmetic
  • IFloatingPoint<T> for sin, cos, sqrt, etc.
  • IBinaryInteger<T> for bitwise operations
  • IMinMaxValue<T> for Min/Max constants

This is the feature that makes numerical computing in C# actually enjoyable.

Feature #8

PriorityQueue: Stop Rolling Your Own

How many projects have you seen with a custom priority queue implementation? Or worse, using a SortedDictionary and pretending it's the same thing?

Since .NET 6, there’s a proper PriorityQueue<TElement, TPriority> in the BCL. It’s a min-heap by default, performant, and actually works correctly.

Perfect for task scheduling, pathfinding, event processing…

var taskQueue = new PriorityQueue<string, int>();
// Lower priority value = processed first
taskQueue.Enqueue("Send email", priority: 3);
taskQueue.Enqueue("Process payment", priority: 1); // Highest priority
taskQueue.Enqueue("Update cache", priority: 5);
taskQueue.Enqueue("Generate report", priority: 2);
while (taskQueue.Count > 0)
{
var task = taskQueue.Dequeue();
Console.WriteLine($"Processing: {task}");
}
// Output:
// Processing: Process payment
// Processing: Generate report
// Processing: Send email
// Processing: Update cache

Need a max-heap instead? Just use a custom comparer or negative priorities. Need stable ordering? Include a sequence number as part of the priority. This data structure is criminally underused.

Feature #9

Interceptors: Compile-Time Method Replacement

Okay, this one’s still experimental (available via preview features in .NET 8+), but it’s too cool not to mention. Interceptors let you replace method calls at compile time. Not runtime. Compile time.

Think about the possibilities: automatic mocking for tests, compile-time dependency injection, zero-cost abstractions, source generators that transform your code before it even runs.

Example: Auto-optimize specific calls

// Your regular code
var result = Calculator.Add(5, 3);
// But at the exact file position, intercept and replace it
[InterceptsLocation("Program.cs", line: 10, column: 14)]
public static int OptimizedAdd(this Calculator calc, int a, int b)
{
// This runs instead, with zero runtime overhead
return a + b;
}

Source generators like Dapper AOT and others are already using this to eliminate reflection and boost performance. It’s like aspect-oriented programming, but baked into the compiler.

Fair Warning:
This is still preview. The API might change. But keep an eye on it — this could revolutionize how we write certain types of code.

Feature #10

ConfigureAwait(ConfigureAwaitOptions): Fine-Tuned Async Control

We all know ConfigureAwait(false) for library code. But .NET 8 introduced ConfigureAwaitOptions that gives you surgical precision over async behavior.

More than just true/false

// Continue on captured context (the default)
await DoWorkAsync().ConfigureAwait(ConfigureAwaitOptions.ContinueOnCapturedContext);
// Classic library code behavior
await DoWorkAsync().ConfigureAwait(ConfigureAwaitOptions.None);
// Force continuation onto thread pool
await DoWorkAsync().ConfigureAwait(ConfigureAwaitOptions.ForceYielding);
// Combine options
await DoWorkAsync().ConfigureAwait(
ConfigureAwaitOptions.SuppressThrowing |
ConfigureAwaitOptions.ForceYielding);

The SuppressThrowing option is particularly interesting—it lets you check task status without exception handling overhead:

var task = RiskyOperationAsync();
await task.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
if (task.IsCompletedSuccessfully)
{
var result = task.Result; // Safe, we know it succeeded
}
else
{
// Handle failure without try-catch overhead
Log.Error($"Operation failed: {task.Exception}");
}

For high-performance async code, this level of control is a game-changer.

The Hidden Cost of Not Knowing

Here’s the thing about these features: they’re not just “nice to have.” Each one solves a real problem that you’ve probably worked around in messier ways. How many hours have we collectively spent writing boilerplate validation methods that CallerArgumentExpression makes obsolete? How much performance have we left on the table by not using CollectionsMarshal or PriorityQueue?

The .NET team ships these features for a reason. They’re born from real-world pain points, refined through community feedback, and battle-tested in production environments. The documentation might not always be great (I’m looking at you, Microsoft Learn), but these tools are solid.

Your Challenge: Pick one feature from this list and use it in your next PR. Just one. See how it feels. I guarantee you’ll find yourself reaching for these more and more.

The .NET ecosystem is huge, and it’s constantly evolving. While we’re all busy shipping features and fixing bugs, it’s worth taking a moment to explore what’s already in our toolbox. Sometimes the best productivity hack isn’t a new framework or tool — it’s just knowing what’s already there.

What hidden .NET features have you discovered that changed how you write code? Drop them in the comments. Let’s build a list that’s actually useful instead of another “10 LINQ tricks” article.

Buy Me Coffee 😊

Happy coding, and may your stack traces always be meaningful. 🚀