Skip to main content
SEMastery
Architectureintermediate

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.

13 min readUpdated February 22, 2026

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 IRepository or ILogger).
  • An abstract class or a virtual method 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.

An abstraction is a simple front that hides messy details 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 costWhat it meansWho feels it
Runtime costExtra CPU work and memory at run timeThe computer, then your users
Reading costExtra layers a human must follow to understand the codeYou and your teammates
Hidden-work costAn innocent-looking call secretly does something heavyEveryone, 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.

How the JIT often removes the cost of an interface call.

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

Friendly name
Looks cheap
Called in a loop
Heavy work repeats
Slow app

Steps

1

Friendly name

A property called OrderCount

2

Looks cheap

Feels like reading a field

3

Called in a loop

Used many times by mistake

4

Heavy work repeats

A DB query runs each time

5

Slow app

Users wait and wonder why

The friendly name hides heavy work, so it gets called far too often.

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.

Empty layers force a reader to jump through files that add no value.

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?

New layer?
Does it add a job?
Will it be swapped?
Keep it
Remove it

Steps

1

New layer?

You are about to add an interface

2

Does it add a job?

Validation, caching, auth, mapping

3

Will it be swapped?

Real chance of a second impl

4

Keep it

If yes to either, it earns a place

5

Remove it

If it only forwards, drop it

A quick check before you add another interface or layer.

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.

SituationGood idea?Why
Talking to email, payments, storage, or any outside toolYes, use an interfaceYou will want to swap it and to fake it in tests
A tiny helper used in a loop that runs millions of timesPrefer a concrete or sealed typeLets the JIT inline and skip dispatch
One controller calling one service that calls one repositoryOften fineClear and easy to test
Five layers that just forward the callNoPure reading cost, no value
A struct used heavily in math or parsingAvoid boxing it to an interfaceBoxing 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

Related Posts