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

📚 Interview question

What is abstraction in object-oriented programming, and how is it different from encapsulation? How do you design a good abstraction in C#, and what does a leaky or premature abstraction look like?

Abstraction as hiding complexity behind a simpler model, how it differs from encapsulation, and how to avoid leaky and premature abstractions.

Mid-Level Theory Intermediate abstraction design interfaces leaky-abstraction oop

🎯 Key points

  • Abstraction shows what something does and hides how; encapsulation protects state and invariants
  • C# expresses abstractions with interfaces, abstract classes and intent-named methods
  • Good abstractions use the caller's language, are small, stable and justified
  • Leaky abstractions expose implementation types, errors or query details to callers
  • Adapters should translate external errors into the abstraction's own types
  • Premature abstractions (one implementation, no test need) add cost without value
  • Abstract at volatility boundaries: external systems, time, things that will vary
  • Keep each method at a single level of abstraction

⏱ 30-second answer

Abstraction means showing only what a caller needs and hiding how it is done: an IPaymentGateway.ChargeAsync(amount) hides HTTP calls, retries and provider formats. Encapsulation protects an object's state and invariants; abstraction simplifies how others think about and use something. In C# you express abstractions with interfaces, abstract classes and well-named public methods. A good abstraction is named in the caller's terms, small, stable, and has more than one real reason to exist. A leaky abstraction forces callers to know the hidden details (provider-specific exceptions, SQL in a repository method name). A premature abstraction is an interface invented for one implementation that never changes, adding indirection without value.

🗣 Full answer (senior)

How I would answer this in an interview

Abstraction is about what something does, from the caller's point of view, while hiding how. Encapsulation is about protecting an object's state and invariants. They work together but answer different questions: abstraction decides the shape of the contract; encapsulation decides who can touch the data. Then I'd show what makes an abstraction good, and what leaky and premature abstractions look like.

1. Abstraction vs encapsulation

Abstraction Encapsulation
Question What does the caller need to know? Who can change this state, and how?
Focus Simplifying a concept to its essentials Protecting invariants and hiding representation
Tools in C# Interfaces, abstract classes, well-named methods, modules Access modifiers, private setters, methods that enforce rules
Example IPaymentGateway.ChargeAsync(amount, card) Order.AddLine() keeps totals consistent

You can have encapsulation without a separate abstraction (a sealed class with private state), and an abstraction over a poorly encapsulated implementation.

2. What a good abstraction looks like

public interface IPaymentGateway
{
    Task<PaymentResult> ChargeAsync(Money amount, PaymentMethodToken token, IdempotencyKey key, CancellationToken ct);
    Task<RefundResult> RefundAsync(PaymentId payment, Money amount, IdempotencyKey key, CancellationToken ct);
}

Qualities:

  • Expressed in the caller's language (domain concepts: Money, PaymentResult), not the provider's (Stripe or Adyen DTOs, HTTP status codes).
  • Small and cohesive: the operations the consumer actually needs (Interface Segregation).
  • Owned by the consumer: defined where it's used (application layer), implemented in infrastructure (Dependency Inversion).
  • Stable: changes in the provider don't ripple to callers.
  • Honest about failure, latency and cost: async, cancellation, explicit results for declines, idempotency keys because retries happen.
  • Substitutable: a fake or second provider can implement it without surprises (Liskov).

3. Leaky abstractions

Joel Spolsky's "Law of Leaky Abstractions": all non-trivial abstractions leak to some degree. Leaks become a problem when callers must know implementation details to use the abstraction correctly:

  • A repository returning IQueryable<T> — callers write LINQ that only works with EF Core, trigger N+1 queries, or fail at run time with untranslatable expressions.
  • An IFileStorage whose Save is atomic on local disk but not on Azure Blob, so callers must know which one they have.
  • A gateway that throws provider-specific exceptions (StripeException) through the interface.
  • Methods that are synchronous in the interface but do network I/O underneath (forcing sync-over-async).

Fix leaks by translating at the boundary (map exceptions and DTOs), making costs visible (async, batching methods), and documenting guarantees.

4. Premature abstractions

  • An IOrderService for every OrderService, with exactly one implementation and no test that needs a substitute.
  • Generic repositories (IRepository<T> with GetAll, Update, Delete) wrapping EF Core, which is already a unit of work and repository — adding indirection while hiding EF features.
  • "Pluggable" frameworks for a single use case (IExportStrategy with one strategy).
  • Deep inheritance hierarchies designed for hypothetical future variants.

Costs: more files to navigate, harder debugging, and — worst — the abstraction is shaped around the first implementation and fits the second one badly when it finally arrives. My rule: abstract when there's a real reason — a second implementation, a boundary to an external system, a test seam that matters, or a volatile dependency. Otherwise, wait for duplication to show the right shape ("rule of three").

5. A production example

A team built IStorageProvider with methods modelled on the local file system (OpenWrite, Move, directory listing). When moving to Azure Blob Storage, Move became copy+delete (not atomic), directory listing had no real directories, and large listings needed paging. Callers had relied on atomic moves for "processing" folders, which now created duplicate processing. We redesigned the abstraction around what callers needed — ClaimNextAsync with a lease, CompleteAsync — implemented with blob leases, and the duplication stopped.

🔍 What the interviewer is checking

Checks conceptual clarity and practical design judgement.

  • Green flags: distinguishes abstraction from encapsulation, gives a concrete interface example, explains leaky and premature abstractions, and abstracts at volatility boundaries.
  • Red flags: says "abstraction is using abstract classes", creates interfaces for everything, or leaks SDK types through interfaces.

📚 Learn in depth

1. Levels of abstraction

Abstraction isn't only interfaces — it's how code is layered:

  • A well-named method (CalculateGst(invoice)) abstracts its body.
  • A class abstracts a concept (Invoice).
  • A module or bounded context abstracts a business capability (Billing).
  • An API abstracts a service.

The Single Level of Abstraction Principle: a method should read at one level — either orchestration (ValidateOrder(); ReserveStock(); Charge();) or detail (string parsing, SQL), not both mixed.

2. Abstractions in C#

Mechanism Strength Use when
Interface Pure contract, multiple implementations, mockable Boundaries to I/O, strategies, plug-ins
Abstract class Shared state/behaviour + contract, template method Family of related types with common logic
Delegate (Func<>) Lightweight single-operation abstraction Policies, callbacks, small strategies
Generics + constraints Abstraction over types at compile time Algorithms over many types (INumber<T>)
Module/assembly boundary Coarse-grained, internal hides details Modular monoliths, libraries

3. Deep vs shallow modules

John Ousterhout (A Philosophy of Software Design) describes a good abstraction as a deep module: a simple interface hiding a lot of functionality. File.ReadAllTextAsync(path) is deep — one call, hiding buffering, encoding detection, OS APIs. A shallow module has an interface almost as complex as its implementation — e.g. a pass-through IOrderRepository whose methods mirror DbSet one-to-one. Shallow abstractions add cognitive load without hiding complexity.

4. Where to put the abstraction: Dependency Inversion

The consumer owns the interface. If Ordering needs payments, IPaymentGateway lives in Ordering's application layer, and StripePaymentGateway lives in infrastructure and depends on it. This keeps the domain free of provider details and lets you replace the provider without touching business logic. Putting the interface in the provider's project ("interface per class") inverts nothing.

5. Testing as a signal

If testing a class requires mocking five interfaces with deep setups, either the abstractions are too fine-grained (mocking implementation details) or the class has too many responsibilities. Good abstractions make tests simpler: fake the boundary (IPaymentGateway, TimeProvider, IClock), use real objects inside. .NET 8's TimeProvider is a nice BCL example of an abstraction introduced specifically for a real seam (time).

6. Evolving abstractions

  • Adding a member to an interface breaks implementers (default interface methods mitigate this for libraries).
  • Prefer adding new interfaces (IPaymentGatewayV2 or a capability interface like ISupportsPartialRefunds) to bloating existing ones.
  • For internal code, refactor freely — abstractions in applications should evolve with understanding.

Try it yourself

  1. Find an interface in your codebase with a single implementation and no test usage; consider removing it.
  2. Find a repository returning IQueryable<T> and list what callers need to know about EF Core to use it safely.
  3. Rewrite a provider-shaped interface in domain language and map exceptions at the boundary.
  4. Identify one method mixing orchestration and details, and split it into levels.

💻 Code

// Abstraction in the caller's terms; no SDK types leak out
public interface IPaymentGateway
{
    Task<PaymentResult> ChargeAsync(Money amount, string cardToken, CancellationToken ct);
    Task<PaymentResult> RefundAsync(string paymentId, Money amount, CancellationToken ct);
}

public abstract record PaymentResult
{
    public sealed record Approved(string PaymentId) : PaymentResult;
    public sealed record Declined(string Reason) : PaymentResult;
}

public readonly record struct Money(decimal Amount, string Currency);

// Adapter: hides HTTP, SDK, retries and provider error codes
public sealed class StripeGateway(StripeClient client) : IPaymentGateway
{
    public async Task<PaymentResult> ChargeAsync(Money amount, string cardToken, CancellationToken ct)
    {
        try
        {
            var charge = await client.CreateChargeAsync(
                (long)(amount.Amount * 100), amount.Currency.ToLowerInvariant(), cardToken, ct);
            return new PaymentResult.Approved(charge.Id);
        }
        catch (StripeCardException ex)
        {
            return new PaymentResult.Declined(ex.DeclineCode ?? "card_declined");  // translated
        }
    }

    public Task<PaymentResult> RefundAsync(string paymentId, Money amount, CancellationToken ct) =>
        throw new NotImplementedException();
}

// Caller works at one level of abstraction
public sealed class CheckoutService(IPaymentGateway payments, TimeProvider clock)
{
    public async Task<string> CheckoutAsync(Cart cart, string cardToken, CancellationToken ct)
    {
        var result = await payments.ChargeAsync(cart.Total, cardToken, ct);
        return result switch
        {
            PaymentResult.Approved a => $"Paid {a.PaymentId} at {clock.GetUtcNow():O}",
            PaymentResult.Declined d => $"Declined: {d.Reason}",
            _ => throw new UnreachableException()
        };
    }
}

⚠ Common mistakes

1. Treating abstraction and encapsulation as the same thing

What happens: candidates define both as "hiding details". Why it is wrong: misses that abstraction shapes the contract while encapsulation protects state. Do instead: explain the different questions each answers, with an example of each.

2. An interface for every class

What happens: IOrderService/OrderService pairs everywhere with one implementation. Why it is wrong: adds indirection and navigation cost without substitution value. Do instead: introduce interfaces at boundaries, for real variation, or where tests need a seam.

3. Leaking provider details through interfaces

What happens: interfaces return provider DTOs or throw provider exceptions. Why it is wrong: callers become coupled to the provider; switching providers breaks them. Do instead: translate to domain types and domain-level errors at the boundary.

4. Returning IQueryable<T> from repositories

What happens: callers compose queries on an abstraction that is really EF Core. Why it is wrong: callers must know EF translation rules; queries become unpredictable and untestable. Do instead: return materialised results or expose specific query methods.

5. Designing abstractions from the first implementation

What happens: an interface mirrors the only implementation's methods exactly. Why it is wrong: the second implementation doesn't fit; the abstraction leaks the first's assumptions. Do instead: design from the consumer's needs, and wait for a second case when possible.

6. Generic repositories over EF Core

What happens: IRepository<T> with GetAll, Update, Delete for every entity. Why it is wrong: shallow abstraction that hides EF features (tracking, projections) and encourages loading too much. Do instead: use DbContext directly in the application layer or write intention-revealing repositories per aggregate.

7. Hiding costs behind abstractions

What happens: a synchronous method or property that performs network calls. Why it is wrong: callers use it in loops, causing N+1 calls and blocking. Do instead: make costs visible (async, batch APIs, explicit names like LoadAsync).

8. Mixing levels of abstraction in one method

What happens: a method orchestrates a workflow and also parses strings and builds SQL. Why it is wrong: hard to read and change. Do instead: extract details into well-named methods so each reads at one level.

🏛 Senior insight: trade-offs

Seniors treat abstractions as long-lived contracts and weigh their cost.

Trade-off Too little abstraction Too much abstraction
Change cost ripple through many callers many layers to update
Readability details everywhere hard to find the real code
Testability hard to isolate I/O mocks of mocks
Performance n/a extra indirection, allocations

Their rule of thumb: abstract at volatility boundaries (external systems, time, configuration, things the business says will vary) and keep the rest concrete. They also name abstractions after roles in the domain, not after technologies.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate defines abstraction Why: Checks the encapsulation side of the comparison
    How to answer

    Encapsulation means an object owns its state and only lets it change through behaviour that keeps it valid. In C# you keep fields private, expose read access through properties (often get-only or private set), and offer methods like Withdraw(amount) that check rules before changing state, instead of a public setter on Balance. Collections are exposed as IReadOnlyList<T> with Add... methods. The benefit is that invariants ("balance never goes below the overdraft limit") are enforced in one place, so callers cannot put the object into an invalid state, and the internals can change without breaking callers.

  2. When: Candidate says callers depend on an interface Why: Moves into how polymorphism makes abstractions work
    How to answer

    Polymorphism means one interface, many forms: code written against a base type or interface works with any implementation, and each object behaves according to its actual type. Run-time (subtype) polymorphism uses virtual/override and interfaces; the call is dispatched through the object's method table (a vtable slot for virtual methods, an interface dispatch stub for interfaces), so shape.Area() runs Circle.Area or Square.Area. Compile-time polymorphism is chosen by the compiler: method overloading, generics (and static abstract interface members for generic maths). Pattern matching on types (switch on shape) is the alternative: good for closed sets of data types and operations added often; virtual methods are better when new types are added often.

  3. When: Candidate mentions depending on abstractions Why: Probes the Dependency Inversion Principle and DI
    How to answer

    A dependency is the weakest relationship: class A uses class B, so a change to B may affect A, but A does not keep B as part of its state. It shows up as a method parameter, a local variable, a return type, a static call or a new B() inside a method. Association is stronger: A stores a reference to B. Direction matters: high-level policy (business rules) should not depend on low-level details (SQL, HTTP). The Dependency Inversion Principle says both should depend on abstractions owned by the high-level side. Dependency injection supplies those abstractions from outside (usually through constructors), so classes do not create or find their own dependencies, which makes them testable and replaceable.

🛠 Real-world problems: how you handle them

Payment SDK calls scattered across checkout blocked a provider switch
Situation
A checkout service called a payment provider's SDK directly in [eight] places, using its types and catching its exceptions. The business needed a second provider for a new region.
Signals
Estimates of weeks for the change, SDK types in controller signatures, provider-specific error handling copied across files.
Investigation
Mapped every SDK use and found three patterns: charge, refund and status check. Error handling depended on provider error codes in several places.
Resolution
Introduced `IPaymentGateway` with domain types (`Money`, `PaymentResult`), moved all SDK use into a `StripeGateway` adapter that translates errors, then added an `AdyenGateway` and chose the adapter per region.
Prevention
An architecture test ensures SDK namespaces are referenced only by adapter projects; new external integrations start with an interface in domain terms.

How to tell it (STAR)

**Situation:** At [company] we needed a second payment provider for [a new region], but the existing provider's SDK was used all over our checkout code. **Task:** I led the refactor so we could add the provider without rewriting checkout. **Action:** I found the SDK used for only three operations, so I designed `IPaymentGateway` around those in our own terms, with a result type instead of provider exceptions. I moved all SDK code into one adapter, then wrote the second adapter. An architecture test now stops SDK references leaking outside adapters. **Result:** The second provider went live in [two days of integration work] instead of the [several weeks] estimated, and a later SDK upgrade touched only the adapter.

↩ Usually asked after

More in Core OOP

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me