Abstraction

Hide *how* behind *what*. Interfaces and abstract classes as the language-level expression of the idea.

Concept Foundational
9 min read
oop abstraction interfaces abstract-classes dip

Summary#

Abstraction is the act of naming what something does so callers never have to think about how it does it. The name lives in a type — an interface or an abstract class — and the how lives in one or more concrete implementations on the other side. Calling code holds the abstract type; the concrete type is chosen, ideally, at exactly one place in the system.

In one line: abstraction hides decisions; encapsulation hides state. The two are siblings, not synonyms. Encapsulation keeps a single object’s internals private from its callers. Abstraction keeps a decision private from a whole module — “we use Stripe for payments today” should be visible in one file, not three hundred.

The payoff is the same payoff information hiding always pays: the parts of the system that don’t need to know don’t know, so they don’t break when the answer changes. Swap Stripe for Razorpay, swap an in-memory store for DynamoDB, swap a sync caller for an async one — none of the calling code moves, because none of it ever named the concrete thing.

Why it matters#

LLD interviews almost always include a follow-up that takes the form “now suppose X is different.” The pricing model changes from flat to dynamic. The storage moves from in-memory to a real database. The notification channel grows from email to email plus SMS. The reviewer is not testing whether you can predict the future. They are testing whether your design has named the right decisions — so that when the future arrives, exactly one class moves.

Three things abstraction buys you concretely:

The dependency direction becomes a choice, not an accident. Without abstraction, Checkout depends on StripePaymentGateway because that’s what you typed. With abstraction, Checkout depends on PaymentMethod, and StripePaymentGateway depends on PaymentMethod too — both arrows now point at the same abstract type, and the concrete dependency is gone from Checkout entirely. This is the move the Dependency Inversion Principle is named after.

Test doubles become free. A Checkout that depends on PaymentMethod is trivially testable with an in-memory implementation. A Checkout that depends on StripePaymentGateway requires either real network calls or a heavy mock framework.

Reasoning becomes local. When you read code that calls paymentMethod.charge(...), you do not have to know which gateway is wired in to understand what the line means. The interface is the contract; the contract is enough.

Get this wrong — couple to concrete types everywhere — and small changes cascade. Get it right and changes stay where they belong: behind the abstraction’s seam.

How it works#

The mechanical move is to introduce a type for the role and program against it. Two language-level shapes carry this in Java: interface (pure contract, no state) and abstract class (contract plus partial default behaviour). The choice between them is a craft decision rather than a deep one; the abstraction is the point either way.

A worked example: an Amazon Online Shopping checkout that needs to charge a customer. The decision being hidden is which payment provider.

public interface PaymentMethod {
PaymentResult charge(Money amount, OrderId orderId);
void refund(OrderId orderId);
}
public final class StripePayment implements PaymentMethod {
private final StripeClient client;
public StripePayment(StripeClient client) { this.client = client; }
@Override
public PaymentResult charge(Money amount, OrderId orderId) {
StripeCharge ch = client.charges().create(amount.minor(), amount.currency(), orderId.toString());
return ch.succeeded() ? PaymentResult.ok(ch.id()) : PaymentResult.failed(ch.errorCode());
}
@Override
public void refund(OrderId orderId) {
client.refunds().create(orderId.toString());
}
}
public final class RazorpayPayment implements PaymentMethod {
private final RazorpayClient client;
public RazorpayPayment(RazorpayClient client) { this.client = client; }
@Override
public PaymentResult charge(Money amount, OrderId orderId) {
var resp = client.orders().capture(amount.minor(), orderId.toString());
return resp.captured() ? PaymentResult.ok(resp.id()) : PaymentResult.failed(resp.code());
}
@Override
public void refund(OrderId orderId) {
client.refunds().issue(orderId.toString());
}
}
public final class Checkout {
private final PaymentMethod payment; // depends on the *role*, not the gateway
private final OrderRepository orders;
public Checkout(PaymentMethod payment, OrderRepository orders) {
this.payment = payment;
this.orders = orders;
}
public OrderId place(ShoppingCart cart, CustomerId customer) {
OrderId id = OrderId.fresh();
Order order = Order.from(cart, customer, id);
PaymentResult result = payment.charge(order.total(), id);
if (!result.ok()) {
throw new PaymentFailedException(result.errorCode());
}
orders.save(order.markPaid(result.transactionId()));
return id;
}
}

Read Checkout and ask: what does it know about Stripe? Nothing. What does it know about Razorpay? Nothing. Could you swap in a TestDoublePayment that records calls in memory for a unit test? Yes, trivially. Could you add a third provider next quarter without touching Checkout? Yes. That is the property abstraction was introduced to buy.

The boundary also constrains the implementations: any class that calls itself a PaymentMethod must support both charge and refund. Half-implementing the interface is not a thing — the language enforces total coverage of the contract.

Where an abstract class would have been a better choice: if every payment provider shared a non-trivial helper — say, a common retry-with-backoff wrapper around the network call — you could lift that into an AbstractPaymentMethod with a protected abstract chargeOnce(...) and a final charge(...) that calls chargeOnce inside the retry loop. Concrete classes then implement only the bare network call. This is the Template Method pattern; the abstraction is doing two jobs at once (contract + shared skeleton).

Variants and trade-offs#

Abstractions vary along two axes: how much they promise (narrow interface vs. wide interface) and how many implementations they expect to have (one, a few, many).

Narrow interface, many implementations. The strongest abstraction. Comparable<T> is one method; tens of millions of types implement it. The contract is so small that every implementation is obviously valid. This is the shape to aim for when the variation axis is real.

Wide interface, few implementations. A repository with twenty methods, one in-memory and one SQL implementation. Defensible — both implementations are needed and the methods are genuinely the surface — but watch for unused methods on one side; that is the Interface Segregation Principle quietly asking to split the interface.

Narrow interface, one implementation. Defensible only when you genuinely expect a second implementation later (a test double counts) or when the abstraction is documenting a role even though only one player exists. Otherwise it is ceremony.

Wide interface, one implementation. The anti-pattern. You have introduced indirection for no design benefit; the interface tracks the concrete class change-for-change.

A. Premature abstraction. Every class has an interface. Every dependency is wired through a factory. Test doubles for code that never had a second implementation. The abstractions track the concrete code with one extra hop; the indirection costs reading time and buys nothing.

B. Abstraction at the seams. Interfaces appear at module boundaries — payment, storage, notifications. Inside a module, classes refer to each other concretely. The cost of indirection is paid only where the seam is real.

The cost worth naming: abstraction makes navigation harder. “Find usages of this method” jumps to the interface, then you have to find which implementation is wired in. A small project pays this cost for little gain; a large project pays it for a great deal of gain. The honest rule is “abstract at module seams, not at every class.”

A second cost: every abstraction is a commitment to a contract. Once PaymentMethod is in five callers, you cannot trivially add a method to it — every implementation must move with you. This is why narrow interfaces age better than wide ones.

When this is asked in interviews#

Abstraction shows up under three different prompts, and recognising them lets you give the same answer with three different framings.

“How would you swap X for Y later?” The interviewer is asking whether your design has the seam for X already. Point at the interface. If there is no interface, name the one you’d introduce and which classes would depend on it instead of the concrete X.

“What does this class depend on?” Read your dependency list aloud. If you hear yourself saying “MySQL,” “Stripe,” or “AWS SNS,” the reviewer’s next question will be “and what abstraction sits between this class and that?” Have the answer ready: OrderRepository, PaymentMethod, NotificationChannel. Concrete names below the seam, abstract roles above.

“What’s the difference between abstraction and encapsulation?” A frequent direct question. Answer in one sentence each and give one example each:

Encapsulation hides state inside an object — BankAccount.balance is private; callers go through deposit and withdraw. Abstraction hides a decision behind a type — Checkout depends on PaymentMethod, not on StripePayment. One is about an object’s integrity; the other is about a module’s seams.

The Liskov check on abstract base classes. If you reach for an abstract class, expect a follow-up on whether your subclasses can actually be substituted for the base. Abstract Bird with fly() and a Penguin extends Bird is the canonical wrong answer; the right answer is to put fly() on a narrower abstraction (FlyingBird, or better, an Aviator capability) so non-flyers don’t have to lie.

Two warning signs the reviewer will spot before you do: an interface with one implementation and no plausible second one is ceremony; an interface that every implementation only partially honours is a Liskov violation waiting to be asked about.

Abstraction is the bridge from a single well-encapsulated class to a system that can absorb change at its seams.

Search ESC

Keyboard shortcuts

Shortcuts are disabled while typing in inputs.