Costs of abstraction, the one-implementation interface debate, test seams versus speculative layers, leaky abstractions over EF Core and HttpClient, and criteria for adding or removing an abstraction.
I do not abstract when there is no second variant, no boundary to protect and no testing need, because an abstraction costs indirection, a name, a contract and cognitive load. Typical examples are an IOrderService with one implementation used by one controller, a generic repository over EF Core, or a wrapper around HttpClient or ILogger<T>, which are already abstractions. I do abstract at architecture boundaries (ports for payment vendors, email, storage) and where tests need a seam for something slow or non-deterministic. If an abstraction has had one implementation and one caller for a long time and is not at a boundary, I inline it.
"Every abstraction is an investment with a carrying cost: one more level of indirection, a name, a contract that implementations and fakes must keep in sync, and a shape that may turn out wrong for the next case. So I ask what it lets us do today that we couldn't without it. It earns its keep when there are real variants now (three payment providers), when it protects an architectural boundary (the application layer must not depend on a vendor SDK, so it owns an
ISmsSenderport), when tests need a seam for something slow, external or non-deterministic, or when it removes real duplication of knowledge. It doesn't earn its keep as anIOrderServicewith one implementation used by one endpoint inside one module, as a generic repository over EF Core (DbContextis already a unit of work andDbSet<T>already a repository), or as wrappers aroundHttpClient,ILogger<T>orTimeProvider, which are already abstractions with test support. 'We might swap the database' rarely happens, and when it does the abstraction is usually the wrong shape. I'm as willing to remove an abstraction as to add one: if it's had one implementation and one caller for a long time and isn't at a boundary, I inline it."
| Cost | Effect |
|---|---|
| Indirection | "Go to definition" lands on an interface; reading and debugging take longer |
| Contract maintenance | Each change updates the interface, implementations, fakes and DI registration |
| Leaks | Wrappers hide useful features yet still leak the original's behaviour |
| Wrong shape | A premature abstraction fits the first case and fights the second |
| Cognitive load | More concepts for every new team member |
| Performance (sometimes) | Generic layers block framework optimisations (projections, bulk updates, split queries) |
| Reason | Example |
|---|---|
| Multiple real implementations now | IPaymentProvider with card, PayID, PayPal |
| Architectural boundary (port) | IEmailSender, IBlobStore, IPaymentGateway owned by the application layer |
| Test seam that's otherwise hard | External API without a test environment |
| Removes duplicated knowledge | One ITaxPolicy used by checkout and invoicing |
| Published library extension point | Consumers genuinely plug in their own implementations |
| Situation | Better |
|---|---|
| One implementation, one caller, inside one module | Concrete class |
| Generic repository over EF Core | DbContext directly, or focused query classes |
IHttpClientWrapper |
Typed HttpClient; stub HttpMessageHandler in tests |
IClock / IDateTimeProvider |
TimeProvider (.NET 8) + FakeTimeProvider |
ILoggerWrapper |
ILogger<T> (+ FakeLogger in tests) |
| "We might switch databases/clouds" | Clean boundaries; abstract when the switch is real |
| A base class shared by unrelated features | Composition |
| Question | Yes means |
|---|---|
| Is there a second real implementation today? | Abstract |
| Is this an architectural boundary (vendor, I/O, infrastructure)? | Abstract as a consumer-owned port |
| Do tests need control, and no existing tool provides it? | Abstract |
| Does it wrap an existing well-designed abstraction? | Probably don't |
| Would the next variant force the abstraction to change shape? | Wait |
| Has it had one implementation and one caller for months, not at a boundary? | Inline it |
Our [ordering platform] had [180] interfaces, [170] of them with exactly one implementation, plus a generic IRepository<T> over EF Core. Onboarding was slow, and performance work was blocked: the repository hid projections, AsSplitQuery and ExecuteUpdateAsync, so developers bypassed it with raw SQL in places. I listed interfaces by implementation count and caller count and marked which sat at real boundaries (payment, SMS, email, blob storage). We kept those ports, removed the generic repository in favour of DbContext plus focused query classes, and inlined [about 120] single-use interfaces module by module. That deleted [roughly 9,000] lines, let us fix the slow queries with projections and bulk updates, and we added a guideline: a new interface needs a second implementation, a boundary, or a stated test need.
Checks judgement and the confidence to resist cargo-cult abstraction.
Green flags
HttpClient, TimeProvider are already abstractions.Red flags
| Influence | Effect |
|---|---|
| Early mocking frameworks could only fake interfaces/virtual members | Teams added an interface per class "for testability" |
| Misreading DIP as "depend on interfaces, not classes" | IFoo/Foo pairs in the same project, no change in dependency direction |
| Templates and sample apps | Copied structure without the reasons |
Modern alternatives reduce the need: TimeProvider/FakeTimeProvider, FakeLogger, HttpMessageHandler stubs (or WireMock.Net), WebApplicationFactory for in-process API tests, and Testcontainers (throwaway real databases/brokers in Docker for integration tests).
Joel Spolsky: "All non-trivial abstractions, to some degree, are leaky." A repository over EF Core still leaks:
SaveChanges semantics,Callers must understand EF Core anyway, so the wrapper mostly removes features (projections, AsNoTracking, ExecuteUpdateAsync, split queries) without hiding complexity.
| Occurrence | Action |
|---|---|
| 1st | Concrete code |
| 2nd | Notice; maybe tolerate duplication |
| 3rd | Abstract, now that the real shape is visible |
At architectural boundaries (vendors, I/O), abstract from the start: the boundary itself is the reason, not the number of implementations.
| Signal | How to find it |
|---|---|
| Interfaces with one implementation | IDE "Find implementations", Roslyn script, NDepend query |
| Interfaces with one caller | Find references |
| Pass-through methods (wrapper just forwards) | Code review, simple analysis |
| Abstractions bypassed (raw SQL beside repository) | Search for direct framework usage |
| Fakes larger than the code under test | Test review |
AddScoped<OrderQueries>()), switch consumers to the concrete type, delete the interface.A BaseService<T> shared by unrelated features accumulates protected hooks and virtual methods; subclasses depend on its internals (fragile base class problem). Prefer composition (inject the shared collaborator) unless subclasses are true specialisations that honour the base's contract (LSP).
IClock wrapper with TimeProvider and FakeTimeProvider.DbContext with a projection and compare the SQL and performance.using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Time.Testing;
// No IOrderQueries interface: one implementation, used inside one module.
public sealed class OrderQueries(ShopDb db)
{
public Task<List<OrderSummary>> RecentAsync(int customerId, CancellationToken ct) =>
db.Orders
.Where(o => o.CustomerId == customerId)
.OrderByDescending(o => o.PlacedAt)
.Select(o => new OrderSummary(o.Id, o.Total)) // EF Core projection still available
.Take(20)
.ToListAsync(ct);
}
// TimeProvider is already an abstraction; no IClock wrapper needed.
public sealed class TrialPolicy(TimeProvider time)
{
public bool IsExpired(DateTimeOffset startedAt) => time.GetUtcNow() > startedAt.AddDays(14);
}
// Abstraction that does earn its keep: an architectural port to a vendor.
public interface ISmsSender { Task SendAsync(string to, string message, CancellationToken ct); }
public sealed record OrderSummary(int Id, decimal Total);
public sealed class Order { public int Id { get; set; } public int CustomerId { get; set; } public decimal Total { get; set; } public DateTimeOffset PlacedAt { get; set; } }
public sealed class ShopDb(DbContextOptions<ShopDb> o) : DbContext(o) { public DbSet<Order> Orders => Set<Order>(); }
public static class Demo
{
public static void Main()
{
var fake = new FakeTimeProvider(new DateTimeOffset(2026, 10, 23, 0, 0, 0, TimeSpan.Zero));
var policy = new TrialPolicy(fake);
Console.WriteLine(policy.IsExpired(fake.GetUtcNow().AddDays(-15))); // True
}
}
Requires Microsoft.EntityFrameworkCore and Microsoft.Extensions.TimeProvider.Testing.
What happens: IFoo/Foo pairs everywhere.
Why it is wrong: navigation cost and fakes that test implementation details.
Do instead: abstract at boundaries; test the rest through behaviour.
What happens: IRepository<T> with GetAll, Find, Add, Update, Delete.
Why it is wrong: hides projections, bulk operations and query tuning while still leaking EF behaviour.
Do instead: DbContext directly or focused query classes.
What happens: a "generic" engine designed from the first use case. Why it is wrong: it takes the first case's shape and fights the second. Do instead: wait for two or three real cases (except at boundaries).
What happens: IClock, ILoggerWrapper, IHttpClientWrapper.
Why it is wrong: duplicates abstractions that already have test support.
Do instead: TimeProvider, ILogger<T>, typed HttpClient with handler stubs.
What happens: dead indirection accumulates for years. Why it is wrong: carrying cost with no benefit. Do instead: inline abstractions that never gained a second use.
What happens: layers to make the database swappable. Why it is wrong: rarely happens; when it does, the abstraction is the wrong shape. Do instead: clean boundaries and focused data access; abstract when a switch is real.
What happens: developers can't use projections or bulk operations through the layer. Why it is wrong: forces bypasses (raw SQL beside the abstraction) and inconsistency. Do instead: remove or narrow the abstraction so the framework's capabilities are available.
What happens: BaseHandler with hooks used by everything.
Why it is wrong: fragile base class; changes ripple unpredictably.
Do instead: composition of small collaborators.
What happens: "we inlined all interfaces", including the payment gateway port. Why it is wrong: vendor details leak into the application layer. Do instead: keep ports at architectural boundaries.
What happens: "SOLID says we need an interface". Why it is wrong: no stated benefit, no way to judge. Do instead: state what the abstraction enables today and what it costs.
Seniors treat abstractions as investments with a carrying cost. I ask what the abstraction lets us do today that we could not without it, and I am as willing to remove one as to add one.
| Abstraction | Verdict |
|---|---|
| Port for payment, SMS, email vendor | Keep |
IFooService with one impl used by one endpoint |
Inline |
| Generic repository over EF Core | Remove |
IClock wrapper |
Replace with TimeProvider |
| Strategy with three real variants | Keep |
| Base class shared by two unrelated features | Split, compose instead |
YAGNI (You Aren't Gonna Need It, from Extreme Programming) says do not build a capability until you actually need it. Speculative features cost time to build, delay what is needed now, add code to maintain, and are often wrong when the real requirement arrives. YAGNI does not mean skipping tests or refactoring; it is those practices that make adding the feature later cheap. I make an exception for decisions that are expensive to reverse, such as public API contracts, persisted data formats and security, where some upfront design pays off.
Over-engineering is complexity that today's requirements do not justify. I detect it through symptoms with numbers behind them: a one-field change touching many files, interfaces with one implementation, configuration options nobody sets, patterns like CQRS or event sourcing without the problems they solve, and microservices that always deploy together. I reverse it incrementally rather than with a rewrite: inline single-use abstractions, merge layers that only pass data through, consolidate services that share a release cycle, and delete unused options, each step behind tests. I also address why it happened, because otherwise it grows back.
DIP says high-level policy should not depend on low-level details; both should depend on abstractions, and the abstraction belongs to the high-level side. For example, the domain or application layer defines IPaymentGateway, and the infrastructure project implements it with Stripe; the arrow of source dependency points inwards. Dependency injection is a technique for supplying those implementations (constructor injection), and the .NET DI container is a tool that automates it. You can use a container without inverting anything (if interfaces live next to their implementations in infrastructure), and you can invert dependencies without a container.
How to tell it (STAR)
Our codebase had an interface for almost every class and a generic repository that blocked EF Core features, so people worked around it with raw SQL. I mapped which abstractions sat at real boundaries and which were single-use. We kept the vendor ports, removed the rest, deleted about [9,000] lines, and finally fixed the slow queries.
Last updated 31 Oct 2026.