Abstraction as hiding complexity behind a simpler model, how it differs from encapsulation, and how to avoid leaky and premature abstractions.
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.
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.
| 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.
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:
Money, PaymentResult), not the provider's (Stripe or Adyen DTOs, HTTP status codes).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:
IQueryable<T> — callers write LINQ that only works with EF Core, trigger N+1 queries, or fail at run time with untranslatable expressions.IFileStorage whose Save is atomic on local disk but not on Azure Blob, so callers must know which one they have.StripeException) through the interface.Fix leaks by translating at the boundary (map exceptions and DTOs), making costs visible (async, batching methods), and documenting guarantees.
IOrderService for every OrderService, with exactly one implementation and no test that needs a substitute.IRepository<T> with GetAll, Update, Delete) wrapping EF Core, which is already a unit of work and repository — adding indirection while hiding EF features.IExportStrategy with one strategy).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").
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.
Checks conceptual clarity and practical design judgement.
Abstraction isn't only interfaces — it's how code is layered:
CalculateGst(invoice)) abstracts its body.Invoice).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.
| 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 |
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.
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.
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).
IPaymentGatewayV2 or a capability interface like ISupportsPartialRefunds) to bloating existing ones.IQueryable<T> and list what callers need to know about EF Core to use it safely.// 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()
};
}
}
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.
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.
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.
IQueryable<T> from repositoriesWhat 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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
Last updated 30 Oct 2026.