The Real Cost of Abstractions in .NET (A Beginner-Friendly Guide)
A simple, friendly guide to what abstractions really cost in .NET, when interfaces help, when they hurt, and how the JIT makes most of the cost vanish.
A water filter at home
Think about the water filter in many Indian homes.
You turn the tap, water goes into the filter, clean water comes out. You do not think about the carbon, the membrane, or the UV lamp inside. You just want clean water. The filter is a box that hides the messy work.
This is exactly what an abstraction is in code. An abstraction is a simple front that hides a complicated inside. An interface like IEmailSender is a tap. You call Send(...), and you do not care whether the email goes through Gmail, SendGrid, or a fake test sender. The details are hidden inside the box.
Filters are great. They keep your kitchen simple. But a filter is not free. It costs money to buy. It slows the water down a little. And if you stack five filters in a row, the water trickles out, and you have to clean five things instead of one.
Abstractions in .NET are the same. They are useful, but they are not free. This article explains what they really cost, when the cost is so small you should ignore it, and when it grows big enough to care about. We will keep the words simple and back them up with real .NET facts.
What we mean by an abstraction
In .NET, an abstraction is usually one of these:
- An interface (like
IRepositoryorILogger). - An abstract class or a
virtualmethod you can override. - A wrapper that sits in front of another thing and forwards the call.
- A generic type that works for many shapes of data.
All of them share one idea: you talk to a simple front, and the real work happens somewhere behind it.
The good part is clear. Your code does not change when you swap Gmail for SendGrid. You can test with a fake sender. You can read the code without drowning in detail. These are real wins, and most of the time they are worth far more than the cost.
But to spend wisely, you need to know what the cost is made of.
The three kinds of cost
People often think "cost" means only "slower." That is just one part. There are really three buckets.
| Kind of cost | What it means | Who feels it |
|---|---|---|
| Runtime cost | Extra CPU work and memory at run time | The computer, then your users |
| Reading cost | Extra layers a human must follow to understand the code | You and your teammates |
| Hidden-work cost | An innocent-looking call secretly does something heavy | Everyone, often by surprise |
The surprising truth is that for most apps, the reading cost and the hidden-work cost hurt more than the runtime cost. Let us look at each.
Runtime cost: the part everyone worries about (and mostly should not)
When you call a method on a concrete class, the computer often knows exactly which code to run. This is a direct call, and it is very fast.
When you call a method through an interface, the computer sometimes has to look up which real method to run, because the same interface could point to many classes. This lookup is called virtual dispatch or indirect dispatch. It costs a few extra CPU cycles. It can also stop the JIT from inlining the method, which means copying a small method's body right into the caller to skip the call entirely.
Here is the same idea in code.
// Direct call: the runtime knows exactly what runs.
public sealed class TaxCalculator
{
public decimal Apply(decimal amount) => amount * 1.18m;
}
// Through an interface: the runtime may need to look up the real method.
public interface ITaxCalculator
{
decimal Apply(decimal amount);
}
public sealed class GstCalculator : ITaxCalculator
{
public decimal Apply(decimal amount) => amount * 1.18m;
}So an interface call can be slower than a direct call. How much slower? Usually a few nanoseconds. A nanosecond is one-billionth of a second. To feel that, you would need to make the call millions or billions of times in a tight loop.
Now the happy news. The modern .NET JIT (the part that turns your code into machine code while the program runs) is very smart about this.
Two words to know here:
- Devirtualization means the JIT figures out the one real type behind an interface and turns the indirect call into a direct call.
- Inlining means the JIT copies a small method's body into the caller so there is no call at all.
With Dynamic PGO (Profile-Guided Optimization, on by default in recent .NET), the runtime watches your program as it runs. If a call site almost always uses one type, the JIT can do guarded devirtualization: it bets on that one type, makes a fast direct path, and keeps a safe fallback for the rare other types. In .NET 10, these tricks reached even further, so idiomatic C# with interfaces, foreach, and lambdas now runs very close to hand-tuned code.
So the runtime cost of abstractions is real, but small, and the JIT erases a lot of it for you. For a normal web API, the database round trip and the network are thousands of times slower than any interface call. Worrying about interface speed there is like worrying about the few seconds the water filter adds while your real wait is the bus to school.
The cost that hides: allocations and surprise work
Here is a cost that is easy to miss and often bigger than dispatch.
Some abstractions quietly create lots of small objects, or do heavy work that looks free.
// Looks harmless. But IEnumerable<T> can hide a lot.
public IEnumerable<Order> GetBigOrders(IEnumerable<Order> orders)
{
return orders.Where(o => o.Total > 10000); // creates an iterator object
}
// And this property looks free but secretly hits the database every time.
public class CartViewModel
{
private readonly IOrderRepository _repo;
public CartViewModel(IOrderRepository repo) => _repo = repo;
// DANGER: a "property" that runs a query. Calling it in a loop is a trap.
public int OrderCount => _repo.GetAll().Count();
}The first method is fine for normal use, but in a hot loop those iterator objects add up and create garbage that the garbage collector must clean up later. The second one is worse: a property named OrderCount looks like reading a field, but it runs a full query every single time you touch it. Put it in a loop and you have a hidden disaster.
This is the leaky side of abstraction. The simple front made you forget the heavy work behind it. The fix is not to delete the abstraction. The fix is to make the cost honest: name it GetOrderCountAsync() so it looks like work, cache the result, or load the data once.
How a hidden cost sneaks in
Steps
Friendly name
A property called OrderCount
Looks cheap
Feels like reading a field
Called in a loop
Used many times by mistake
Heavy work repeats
A DB query runs each time
Slow app
Users wait and wonder why
The reading cost: layers that do nothing
The biggest day-to-day cost is often not the computer's time. It is your time.
Picture a request that has to pass through a controller, then a service, then another service, then a manager, then a repository, then finally the database. If three of those layers just take the call and pass it along without adding anything, you now have to open and read five files to understand one simple action.
Each of those hops is an abstraction. If a hop adds a real job (validation, caching, a permission check), it earns its place. If it only forwards the call, it is pure cost: more files, more jumping around, more places for bugs to hide, and a slower day for the next person who reads it.
A good rule: an abstraction should pay rent. If a layer cannot tell you what useful job it does, it should not exist.
Does this abstraction pay its rent?
Steps
New layer?
You are about to add an interface
Does it add a job?
Validation, caching, auth, mapping
Will it be swapped?
Real chance of a second impl
Keep it
If yes to either, it earns a place
Remove it
If it only forwards, drop it
A simple way to decide
You do not need to fear abstractions. You need a small habit: add them on purpose, not by reflex.
Here is a table to guide everyday choices.
| Situation | Good idea? | Why |
|---|---|---|
| Talking to email, payments, storage, or any outside tool | Yes, use an interface | You will want to swap it and to fake it in tests |
| A tiny helper used in a loop that runs millions of times | Prefer a concrete or sealed type | Lets the JIT inline and skip dispatch |
| One controller calling one service that calls one repository | Often fine | Clear and easy to test |
| Five layers that just forward the call | No | Pure reading cost, no value |
| A struct used heavily in math or parsing | Avoid boxing it to an interface | Boxing allocates and undoes the speed of a struct |
Two small, safe habits help the JIT and the reader at the same time:
// 1) Mark classes sealed unless you truly mean them to be a base class.
// This helps the JIT devirtualize and inline, and shows clear intent.
public sealed class PriceService
{
public decimal Final(decimal net) => net * 1.18m;
}
// 2) For small, hot value types, use a readonly struct so the JIT can
// optimize hard. Just don't store it in an interface variable,
// because that "boxes" it onto the heap and creates garbage.
public readonly struct Money
{
public Money(decimal amount) => Amount = amount;
public decimal Amount { get; }
}sealed is a free, friendly hint. It tells both the compiler and the next human, "no surprises here, nothing overrides this." Boxing a struct into an interface, on the other hand, quietly puts it on the heap and creates garbage, which can erase the very speed a struct gives you.
Measure, do not guess
The most important rule of all: never guess about performance. Humans are bad at guessing which line is slow. The interface you suspect is usually fine, and the real cost is hiding in a database query or a loop you did not notice.
The .NET world has great free tools for this:
- BenchmarkDotNet measures tiny pieces of code fairly and tells you nanoseconds and bytes allocated.
- A profiler (in Visual Studio, JetBrains Rider, or
dotnet-trace) shows you where real time goes in a running app.
using BenchmarkDotNet.Attributes;
public class DispatchBenchmark
{
private readonly TaxCalculator _direct = new();
private readonly ITaxCalculator _viaInterface = new GstCalculator();
[Benchmark(Baseline = true)]
public decimal DirectCall() => _direct.Apply(1000m);
[Benchmark]
public decimal InterfaceCall() => _viaInterface.Apply(1000m);
}Run that, and on modern .NET you will often see the two are nearly the same, because the JIT optimizes the interface path. That result, not a hunch, is what should drive your decision. Write clear code first. Measure. Then fix only the small part that the numbers say is slow.
When the cost is truly worth paying
Step back and look at the whole picture. The runtime cost of a single abstraction is tiny and shrinking every release. The reading cost and the hidden-work cost are the ones to watch. And against all of these costs, abstractions buy you very real things:
- You can test your logic with fakes instead of a real database or a real email server.
- You can swap a tool, like moving from one payment provider to another, without rewriting your features.
- You can read the top-level flow without every gritty detail in your face.
- You can change one part without breaking the rest.
For the vast majority of business apps, those benefits beat a few nanoseconds every time. The goal is not zero abstractions. The goal is the right number, each one paying its rent. One clean filter for clean water is wonderful. Five filters stacked for no reason just make the water trickle.
Quick recap
- An abstraction is a simple front that hides messy details, like a water filter for your code.
- Abstractions are useful but not free. The cost comes in three flavors: runtime cost, reading cost, and hidden-work cost.
- Runtime cost (interface or virtual calls) is usually tiny, just a few nanoseconds, and the modern .NET JIT erases much of it with devirtualization and inlining, helped by Dynamic PGO.
- The hidden-work cost is sneaky: a friendly name (like a property) can secretly run a database query or allocate many objects. Make heavy work look heavy.
- The reading cost is often the biggest. Layers that only forward calls are pure cost. Every abstraction should pay its rent.
- Small safe habits: mark classes sealed, use readonly struct for hot value types, and avoid boxing structs into interfaces.
- Measure, never guess. Use BenchmarkDotNet and a profiler. Write clear code first, then optimize only what the numbers prove is slow.
References and further reading
- Performance Improvements in .NET 10 — .NET Blog
- What's new in the .NET 10 runtime — Microsoft Learn
- The Real Cost of Abstractions in .NET — Milan Jovanović
- Understanding the Cost of Abstractions in .NET — DEV Community
- JIT: De-abstraction in .NET 10 — dotnet/runtime issue 108913
Related Posts
Clean Architecture Folder Structure in .NET: A Simple Guide
Learn how to organise a .NET solution with Clean Architecture. Understand the Domain, Application, Infrastructure, and Presentation layers, the dependency rule, and the exact folders, with diagrams and examples.
Vertical Slice Architecture in .NET: The Easy Guide
Learn Vertical Slice Architecture in .NET in simple words. Organise code by feature instead of by layer, with diagrams, real examples, a comparison with Clean Architecture, and when to use each.
Balancing Cross-Cutting Concerns in Clean Architecture (.NET)
Learn how to handle logging, validation, caching, and security in Clean Architecture with .NET, using simple words, diagrams, and real code examples.
Scaling Monoliths: A Practical Guide for Growing .NET Systems
Learn how to scale a .NET monolith step by step: vertical scaling, stateless apps, load balancing with YARP, caching with Redis, and read replicas — in simple words.
Synchronous vs Asynchronous Communication in Microservices (.NET Guide)
A simple, friendly guide to synchronous vs asynchronous communication in microservices, with .NET examples, diagrams, tables, and clear rules on when to use each.
Understanding Microservices: Core Concepts and Benefits for .NET
A beginner-friendly guide to microservices in .NET: what they are, the core ideas behind them, their real benefits and trade-offs, and when to use them.