📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

When should you not introduce an abstraction in a .NET codebase, and how do you decide whether an interface or base class is earning its keep?

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.

Senior Architecture Advanced abstraction design-judgment interfaces over-engineering

🎯 Key points

  • Every abstraction has a cost: indirection, navigation, naming and a contract to maintain
  • Abstract when there are real variants, a boundary to protect, or a test seam you need
  • An interface with one implementation is fine at an architectural boundary, wasteful elsewhere
  • Wrapping well-designed abstractions (DbContext, HttpClient, ILogger, TimeProvider) usually leaks and adds little
  • Wait for concrete examples before generalising; inline abstractions that never gained a second use
  • Prefer concrete code that is easy to change over abstract code that is hard to understand

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

"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 ISmsSender port), 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 an IOrderService with one implementation used by one endpoint inside one module, as a generic repository over EF Core (DbContext is already a unit of work and DbSet<T> already a repository), or as wrappers around HttpClient, ILogger<T> or TimeProvider, 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."


1. What an abstraction costs

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)

2. When it earns its keep

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

3. When not to abstract

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

4. Decision checklist

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

Production example

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.

🔍 What the interviewer is checking

Checks judgement and the confidence to resist cargo-cult abstraction.

Green flags

  • States the cost of abstractions concretely.
  • Distinguishes boundary ports from internal single-use interfaces.
  • Knows EF Core, HttpClient, TimeProvider are already abstractions.
  • Has removed abstractions, not just added them.

Red flags

  • "Always code to an interface."
  • Cannot name any downside of abstraction.

📚 Learn in depth

1. Where "interface everything" came from

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).

2. Leaky abstractions

Joel Spolsky: "All non-trivial abstractions, to some degree, are leaky." A repository over EF Core still leaks:

  • change tracking and SaveChanges semantics,
  • lazy/eager loading and N+1 risks,
  • query translation limits (what can and can't become SQL),
  • transaction scope and concurrency behaviour.

Callers must understand EF Core anyway, so the wrapper mostly removes features (projections, AsNoTracking, ExecuteUpdateAsync, split queries) without hiding complexity.

3. Rule of three for abstractions

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.

4. Measuring abstraction health

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

5. Removing abstractions safely

  1. List single-implementation interfaces; exclude boundary ports and public library extension points.
  2. For each, register the concrete type in DI (AddScoped<OrderQueries>()), switch consumers to the concrete type, delete the interface.
  3. Replace mocks with real objects, fakes of real boundaries, or integration tests.
  4. Do it module by module in small PRs; behaviour shouldn't change.

6. Base classes deserve the same scrutiny

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).

7. Try it yourself

  1. Count interfaces vs implementations in your solution.
  2. List single-implementation interfaces not at a boundary.
  3. Inline one; note whether any test genuinely got harder (vs just needing a different style).
  4. Replace an IClock wrapper with TimeProvider and FakeTimeProvider.
  5. Rewrite a repository-heavy query using DbContext with a projection and compare the SQL and performance.

💻 Code

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.

⚠ Common mistakes

1. An interface for every class "for testability"

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.

2. Generic repository over EF Core

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.

3. Abstracting from one example

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).

4. Wrapping framework abstractions

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.

5. Never removing abstractions

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.

6. "We might switch the database"

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.

7. Abstractions that block performance work

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.

8. Base classes for code reuse between unrelated features

What happens: BaseHandler with hooks used by everything. Why it is wrong: fragile base class; changes ripple unpredictably. Do instead: composition of small collaborators.

9. Removing boundary ports along with the noise

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.

10. Arguing by principle names

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.

🏛 Senior insight: trade-offs

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

🔗 Follow-ups the interviewer may ask

  1. When: Candidate says they wait for a real second variant Why: Probes YAGNI
    How to answer

    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.

  2. When: Candidate has removed unnecessary layers Why: Moves to detecting over-engineering
    How to answer

    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.

  3. When: Candidate keeps ports at architectural boundaries Why: Checks dependency inversion
    How to answer

    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.

🛠 Real-world problems: how you handle them

180 interfaces and a generic repository
Situation
An [ordering platform] had [180] interfaces, [170] with one implementation, and a generic repository over EF Core.
Signals
Slow onboarding; performance fixes blocked because the repository hid projections and bulk updates; developers bypassed it with raw SQL.
Investigation
Listed interfaces by implementation count and caller count; identified which sat at real boundaries.
Resolution
Removed the generic repository, used `DbContext` and query classes, inlined [120] single-use interfaces, kept vendor ports.
Prevention
Design guideline: new interfaces need a second implementation, a boundary or a stated test need.

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.

↩ Usually asked after

More in Senior-Level Judgment

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me