The Real Cost of Abstractions in .NET

The Real Cost of Abstractions in .NET

7 min read ·

What does context engineering have to do with shipping better code faster? CodeRabbit explains in The art and science of context engineering for AI code reviews. Give it a read or try CodeRabbit today and cut code review time and bugs in half.

Meet Auggie CLI. Augment Code is bringing the power of its AI coding agent and industry-leading context engine right to your terminal. You can use Auggie in a standalone interactive terminal session alongside your favorite editor or in any part of your software development stack. Auggie CLI is in beta...for now.

As developers, we love abstractions. Repositories, services, mappers, wrappers. They make our code look "clean," they promise testability, and they give us the sense that we're building something flexible.

Every abstraction is a loan. You pay interest the moment you write it.

Some abstractions earn their keep by isolating real volatility and protecting your system from change. Others quietly pile up complexity, slow down onboarding, and hide performance problems behind layers of indirection.

Let's explore when abstractions pay dividends and when they become technical debt.

When Abstractions Pay Off

The best abstractions isolate true volatility, the parts of your system that you genuinely expect to change.

Example: Payment Processing

Your core business logic shouldn't depend directly on Stripe's SDK. If you ever switch to Adyen or Braintree, you don't want that decision rippling through every corner of your codebase. Here, an abstraction makes perfect sense:

public interface IPaymentProcessor
{
    Task ProcessAsync(Order order, CancellationToken ct);
}

public class StripePaymentProcessor : IPaymentProcessor
{
    public async Task ProcessAsync(Order order, CancellationToken ct)
    {
        // Stripe-specific implementation
        // Handle webhooks, error codes, etc.
    }
}

public class AdyenPaymentProcessor : IPaymentProcessor
{
    public async Task ProcessAsync(Order order, CancellationToken ct)
    {
        // Adyen-specific implementation
        // Different API, same business outcome
    }
}

Now your business logic can stay focused on the domain:

public class CheckoutService(IPaymentProcessor processor)
{
    public Task CheckoutAsync(Order order, CancellationToken cancellationToken) =>
        processor.ProcessAsync(order, cancellationToken);
}

This abstraction isolates a genuinely unstable dependency (the payment provider) while keeping checkout logic independent. When Stripe changes their API or you switch providers, only one class needs to change.

That's a good abstraction. It buys you optionality where you actually need it.

When Abstractions Become Technical Debt

Problems arise when we abstract things that aren't actually volatile. We end up wrapping stable libraries or creating layers that don't add real value. The "clean" layer you added today becomes tomorrow's maintenance burden.

The Repository That Lost Its Way

Most teams start with something reasonable:

public interface IUserRepository
{
    Task<IEnumerable<User>> GetAllAsync();
}

But as requirements evolve, so does the interface:

public interface IUserRepository
{
    Task<IEnumerable<User>> GetAllAsync();
    Task<User?> GetByEmailAsync(string email);
    Task<IEnumerable<User>> GetActiveUsersAsync();
    Task<IEnumerable<User>> GetUsersByRoleAsync(string role);
    Task<IEnumerable<User>> SearchAsync(string keyword, int page, int pageSize);
    Task<IEnumerable<User>> GetUsersWithRecentActivityAsync(DateTime since);
    // ...and it keeps growing
}

Suddenly, the repository is leaking query logic into its interface. Every new way of fetching users means another method, and your "abstraction" becomes a grab bag of every possible query.

Meanwhile, Entity Framework already gives you all of this through LINQ: strongly typed queries that map directly to SQL. Instead of leveraging that power, you've introduced an indirection layer that hides query performance characteristics and often performs worse. The repository pattern made sense when ORMs were immature. Today, it's often just ceremony.

I've been guilty of this myself. But part of maturing as a developer is recognizing when patterns become anti-patterns. Repositories make sense when they encapsulate complex query logic or provide a unified API over multiple data sources. But you should strive to keep them focused on domain logic. As soon as they explode into a myriad of methods for every possible query, it's a sign that the abstraction has failed.

Service Wrappers: The Good and The Ugly

Not all service wrappers are problematic. Context matters.

✅ Good Example: External API Integration

When integrating with external APIs, a wrapper provides genuine value by centralizing concerns:

public interface IGitHubClient
{
    Task<UserDto?> GetUserAsync(string username);
    Task<IReadOnlyList<RepoDto>> GetRepositoriesAsync(string username);
}

public class GitHubClient(HttpClient httpClient) : IGitHubClient
{
    public Task<UserDto?> GetUserAsync(string username) =>
        httpClient.GetFromJsonAsync<UserDto>($"/users/{username}");

    public Task<IReadOnlyList<RepoDto>> GetRepositoriesAsync(string username) =>
        httpClient.GetFromJsonAsync<IReadOnlyList<RepoDto>>($"/users/{username}/repos");
}

This wrapper isolates GitHub's API details. When authentication changes or endpoints evolve, you update one place. Your business logic never needs to know about HTTP headers, base URLs, or JSON serialization.

❌ Bad Example: Pass-Through Services

The trouble starts when we wrap our own stable services without adding business value:

public class UserService(IUserRepository userRepository)
{
    // Just forwarding calls with no added value
    public Task<User?> GetByIdAsync(Guid id) => userRepository.GetByIdAsync(id);
    public Task<IEnumerable<User>> GetAllAsync() => userRepository.GetAllAsync();
    public Task SaveAsync(User user) => userRepository.SaveAsync(user);
}

This UserService is pure indirection. All it does is forward calls to the IUserRepository. It doesn't enforce business rules, add validation, implement caching, or provide any real functionality. It's a layer that exists only because "services are good architecture."

As these anemic wrappers multiply, your codebase becomes a maze. Developers waste time navigating layers instead of focusing on where business logic actually lives.

Making Better Decisions

Here's how to think about when abstractions are worth the investment:

Abstract Policies, Not Mechanics

  • Policies are decisions that might change: which payment provider to use, how to handle caching, retry strategies for external calls
  • Mechanics are stable implementation details: EF Core's LINQ syntax, HttpClient configuration, JSON serialization

Abstract policies because they give you flexibility. Don't abstract mechanics, they're already stable APIs that rarely change in breaking ways.

Wait for the Second Implementation

If you only have one implementation, resist the interface urge. A single implementation doesn't justify abstraction, it's premature generalization that adds complexity without benefit.

Consider this evolution:

// Step 1: Start concrete
public class EmailNotifier
{
    public async Task SendAsync(string to, string subject, string body)
    {
        // SMTP implementation
    }
}

// Step 2: Need SMS? Now abstract
public interface INotifier
{
    Task SendAsync(string to, string subject, string body);
}

public class EmailNotifier : INotifier { /* ... */ }
public class SmsNotifier : INotifier { /* ... */ }

The abstraction emerges naturally when you actually need it. The interface reveals itself through real requirements, not imaginary ones.

Keep Implementations Inside, Abstractions at Boundaries

Inside your application, prefer concrete types. Use Entity Framework directly, configure HttpClient as typed clients, work with domain entities. Only introduce abstractions where your system meets the outside world: external APIs, third-party SDKs, infrastructure services.

That's where change is most likely, and where abstractions earn their keep.

Refactoring Out Bad Abstractions

Regularly review your abstractions with this question: If I removed this abstraction, would the code become simpler or more complex?

If removing an interface or service layer would make the code clearer and more direct, that abstraction is probably costing more than it's worth. Don't be afraid to delete unnecessary layers. Simpler code is often better code.

When you identify problematic abstractions, here's how to safely remove them:

  1. Identify the real consumers. Who actually needs the abstraction?
  2. Inline the interface. Replace abstract calls with concrete implementations.
  3. Delete the wrapper. Remove the unnecessary indirection.
  4. Simplify the calling code. Take advantage of the concrete API's features.

For example, replacing a repository with direct EF Core usage:

// Before: Hidden behind repository
var users = await _userRepo.GetActiveUsersWithRecentOrders();

// After: Direct, optimized query
var users = await _context.Users
    .Where(u => u.IsActive)
    .Where(u => u.Orders.Any(o => o.CreatedAt > DateTime.Now.AddDays(-30)))
    .Include(u => u.Orders.Take(5))
    .ToListAsync();

The concrete version is more explicit about what data it fetches and how, making performance characteristics visible and optimization possible. If you need the same query in multiple places, you could move it into an extension method to make it shareable.

The Bottom Line

Abstractions are powerful tools for managing complexity and change, but they're not free. Each one adds indirection, cognitive overhead, and maintenance burden.

The cleanest architecture isn't the one with the most layers. It's the one where each layer has a clear, justified purpose.

Before adding your next abstraction, ask yourself:

  • Am I abstracting a policy or just a mechanic?
  • Do I have two implementations, or am I speculating about future needs?
  • Will this make my system more adaptable, or just harder to follow?
  • If I removed this layer, would the code become simpler?

Remember: abstractions are loans that accrue interest over time. Make sure you're borrowing for the right reasons, not just out of habit.

The goal is to use abstractions intentionally, where they solve real problems and protect against genuine volatility. Build abstractions that earn their keep. Delete the ones that don't.

Thanks for reading.

And stay awesome!


Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 4,200+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  2. Modular Monolith Architecture: Join 2,100+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. (NEW) Pragmatic REST APIs: Join 1,200+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  4. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

Become a Better .NET Software Engineer

Join 70,000+ engineers who are improving their skills every Saturday morning.