Back to the path
Level 0 intermediate 23 min #abstraction#interfaces#abstract-classes#dependency-inversion#dependency-injection#oop#interface-segregation#lld-fundamentals

Abstraction Boundaries: Interfaces, Abstract Classes, and Programming to a Contract

Master the most fundamental LLD skill: deciding where to draw an abstraction boundary, choosing between interfaces and abstract classes, and depending on contracts instead of concrete classes so your designs stay swappable, testable, and open to extension.

Why this is the first thing in LLD, not the last

High-level system design asks “what boxes exist and how do bytes flow between them?” Low-level design asks “what is the shape of the seam between two pieces of code, and who is allowed to depend on whom?” That seam is an abstraction boundary, and in object-oriented LLD it is almost always expressed as an interface or an abstract class.

Get this wrong and every design pattern you layer on top inherits the mistake: Strategy needs a clean strategy interface, the Dependency Inversion Principle needs an abstraction to invert toward, and “swap the implementation in a test” needs a seam to swap at. So before factories, observers, or visitors, you master this.

The one-sentence definition. An abstraction is a promise about behavior (a set of method signatures and a contract) that is deliberately separated from how that behavior is delivered. Code that depends only on the promise can survive any change to the delivery.

Interface vs abstract class: the actual difference

Both let callers depend on a type rather than a concrete class. The difference is what they are allowed to carry. The table below is framed in Java/C#-flavored terms because that is where the distinction is sharpest; the per-language nuances follow it.

CapabilityInterfaceAbstract class
Method signatures (the contract)YesYes
Concrete instance state (fields)No (Java: only static final constants)Yes
Shared implementation of methodsLimited (default methods only)Yes, freely
Constructors / initialization logicNoYes
How many can one class adopt?ManyOne (single inheritance)
Models the relationshipcan do X” (capability)is a kind of” (substance)
Watch out

The “fields/constants” row is a Java rule and does not transfer literally. A TypeScript interface is a pure compile-time structural type — it is erased at runtime and carries no fields, no constants, and no code at all. A C++ “interface” is just an abstract class, so it can technically hold static members. The lesson’s real point — an interface is a behavioral contract, not a carrier of state — sits above any one language’s mechanics.

Two heuristics carry most of the decision:

  • An interface is a contract about capability. Comparable, Iterable, Closeable, Serializable. A class proves it can do something. A class can be many things at once — ArrayList is List, Iterable, Cloneable, and Serializable simultaneously, because those are independent capabilities.
  • An abstract class is a partially-built thing. It exists because several concrete classes share real state and real code. AbstractList, for example, implements iterator() and bounds-checking on top of the abstract hook get(int) it declares — while size() stays abstract and isEmpty() is inherited from AbstractCollection (which defines it as size() == 0). The pattern is the same either way: the base provides the algorithm, subclasses fill a couple of holes and inherit the rest. This is the Template Method shape.
Key idea

Default rule of thumb: reach for an interface first. Only escalate to an abstract class when you have concrete state to share or non-trivial implementation you want subclasses to inherit by default. Interfaces keep your types swappable; abstract classes commit your subclasses to their single inheritance slot.

Tri-language: the same boundary, three syntaxes

Note how each language idiom expresses “this is a pure capability contract.” The amountCents/token parameters in the C++ stub are placeholders for a real integration, hence the /*...*/ comments to keep it -Wall-clean.

// TypeScript: interface = pure structural contract, erased at runtime
interface PaymentGateway {
charge(amountCents: number, token: string): ChargeResult;
}

interface ChargeResult {
ok: boolean;
reference: string;
}

// Note: TS uses STRUCTURAL typing. 'implements' is optional documentation;
// any object with a matching shape already satisfies PaymentGateway
// (duck typing at compile time). Java/C# are NOMINAL: you must 'implements'.
class StripeGateway implements PaymentGateway {
charge(amountCents: number, token: string): ChargeResult {
  // talk to Stripe...
  return { ok: true, reference: "ch_123" };
}
}

Language note for interviews. C++ has no interface keyword — an interface is just an abstract class with only pure virtual methods and a virtual destructor. Python has no compile-time interface either — abc.ABC with @abstractmethod is the idiomatic equivalent, and duck typing gives you implicit interfaces. A sharp discriminator to mention: TypeScript and Python are structurally / duck typed (shape is enough), while Java and C# are nominally typed (you must explicitly declare implements). Saying this shows the concept sits above any one language’s keyword.

Programming to an interface, not an implementation

This is the single most important habit in LLD. It means: declare variables, parameters, return types, and fields in terms of the abstract type — never the concrete class.

   BAD: caller is welded to a concrete class
   +--------------------+        +------------------+
   |   OrderService     | -----> |  StripeGateway   |   (concrete)
   +--------------------+        +------------------+
            depends on a CLASS -- swapping requires editing OrderService

   GOOD: caller depends on the promise
   +--------------------+        +------------------+
   |   OrderService     | -----> | <<interface>>    |
   +--------------------+        | PaymentGateway   |
                                 +------------------+
                                          ^
                          +---------------+---------------+
                   +--------------+ +--------------+ +--------------+
                   | StripeGateway| | PayPalGateway| | FakeGateway  |
                   +--------------+ +--------------+ +--------------+
class OrderService {
// depend on the contract, inject the concrete one from outside
constructor(private gateway: PaymentGateway) {}

checkout(amountCents: number, token: string): ChargeResult {
  // real code PROPAGATES the result (reference + ok) to the caller,
  // rather than swallowing it into a bare boolean.
  const result = this.gateway.charge(amountCents, token);
  if (!result.ok) throw new Error("charge failed");
  return result; // caller gets result.reference for receipts/audit
}
}

// production wiring
const svc = new OrderService(new StripeGateway());
// test wiring -- no real network call
// const svc = new OrderService(new FakeGateway());

OrderService never names StripeGateway. The concrete choice is injected from the outside. That one move buys you three things at once:

  • Swappability — switch Stripe for PayPal by changing one wiring line.
  • Testability — inject a FakeGateway to test checkout logic with no network.
  • Parallel work — two engineers can build OrderService and StripeGateway against the agreed interface independently.
Tip

In an interview, the moment you write a new ConcreteClass() inside a class that should be reusable, narrate: “I’m hard-coding this dependency, so let me invert it — accept the abstraction in the constructor instead.” Interviewers are explicitly listening for this reflex.

Key idea

Don’t conflate DI with DIP — interviewers probe this. Dependency Injection is a wiring technique: pass a dependency in from outside (constructor arg) instead of constructing it internally. Dependency Inversion Principle is an ownership/direction rule: the high-level policy owns the abstraction, and details depend on it. You can do DI while still violating DIP — e.g. inject a concrete StripeGateway whose type is owned by the vendor’s low-level module. DI is how you supply the object; DIP is which way the dependency arrow points and who defines the contract.

What an abstraction boundary is — and how to choose one

An abstraction boundary is the line you draw across which one side does not know the other’s concrete details. Choosing a good boundary is a design skill; choosing a bad one creates a leaky interface that has to change every time an implementation changes — which defeats the entire point.

A good boundary has these properties:

  1. Stated in the consumer’s vocabulary, not the provider’s. A reporting module wants getMonthlyRevenue(), not runSql(query). If the method signature exposes the implementation’s mechanism, the abstraction leaks.
  2. Narrow (Interface Segregation). Many small role interfaces beat one fat interface. A class that only needs to read should not depend on a save() it never calls.
  3. Stable while implementations vary. The right test: “If I add a third implementation, does the interface change?” If yes, you’ve leaked an implementation detail into the contract.

Interface Segregation, worked concretely

Suppose you start with one Repository and later realize a ReportGenerator only ever reads, while an Importer only ever writes. A fat interface forces both to depend on methods they never call, so a read-only fake must still stub save(). Split it:

interface UserReader {
findById(id: string): User | null;
}
interface UserWriter {
save(user: User): void;
}

// A class may implement BOTH; consumers depend only on the role they need.
class SqlUserRepository implements UserReader, UserWriter {
findById(id: string): User | null { /* SELECT ... */ return null; }
save(user: User): void { /* INSERT/UPDATE ... */ }
}

// ReportGenerator can only ever read -> cannot accidentally write.
class ReportGenerator {
constructor(private readonly users: UserReader) {}
}

Now ReportGenerator cannot call save() — the type system enforces least privilege, and its test fake only implements findById.

The cost of interfaces: churn and versioning

Interfaces are not free. A published interface is a contract with every implementer, so changing it is expensive: add a method and every implementer breaks until updated. Senior-grade answers acknowledge this and name mitigations:

  • Additive-only evolution. Add new interfaces or new optional methods rather than mutating existing signatures.
  • Default methods (Java 8+) let you add a method with a body so existing implementers compile unchanged — but they introduce a risk: if a class inherits the same default-signature method from two interfaces, Java raises a diamond/ambiguity error you must resolve with an explicit override (Interface.super.method()). This is Java’s narrow version of the C++ multiple-inheritance diamond problem; C++ resolves its broader version with virtual inheritance. Pure interfaces with no method bodies have no such ambiguity.
  • Versioned interfaces (PaymentGatewayV2) or capability-probing when you cannot break existing clients.
Common pitfall

Premature abstraction is as harmful as missing abstraction. Don’t introduce an interface for a class with exactly one implementation and no test seam need — you pay the churn cost with none of the swappability benefit. Wait for the second reason (a real second implementation, or a test double you actually need).

Who owns the boundary? (Dependency Inversion)

The Dependency Inversion Principle says: high-level policy should not depend on low-level details; both should depend on an abstraction — and the abstraction belongs to the high-level side.

   Without DIP                With DIP (abstraction owned by policy)
   +----------+               +-------------------------+
   | Policy   |               | Policy layer            |
   +----+-----+               |   +-----------------+   |
        | depends on          |   | <<interface>>   |   |  <- defined HERE,
        v                     |   | Repository      |   |     in the policy layer
   +----------+               |   +-----------------+   |
   | MySqlDB  |  (detail)     +------------^------------+
   +----------+                            | implements
                              +------------+-----------+
                              | MySqlRepository        | (detail depends on policy)
                              +------------------------+

The arrow of dependency inverts: the database adapter now depends on the policy’s interface, not the other way around. Concretely, Repository is declared in the same module as the business logic that consumes it; MySqlRepository lives in the persistence module and implements it. The persistence module imports the policy module — never the reverse.

Why this matters in practice:

  • The core domain has zero compile-time dependency on infrastructure. You can build, type-check, and unit-test the policy with no database driver on the classpath at all.
  • Infrastructure is replaceable. Swap MySQL for Postgres, or for an in-memory store in tests, by writing a new adapter that implements the same interface. Nothing in the domain changes.
  • The dependency direction matches the stability direction. Stable, valuable business rules should not be dragged into recompilation or redesign every time a volatile detail (a vendor SDK, a schema) shifts.

In an interview, say: “I’ll define the Repository interface in the domain layer, because the domain owns the contract it needs. The MySQL adapter in the infrastructure layer implements it. That way the dependency points inward, toward the stable policy — that’s Dependency Inversion, and it’s what makes the core testable and the infrastructure swappable.”

Putting it together

You want…Reach for…
Many unrelated classes to share a capabilityInterface
Shared state + shared default logic across a familyAbstract class (Template Method)
To swap implementations without editing callersProgram to the interface + inject
The core to not depend on infrastructureDIP — interface owned by the policy layer
Consumers to depend only on what they useInterface Segregation — split roles
To supply a dependency from outsideDependency Injection (the wiring)

The throughline: find the seam, name it as a contract in the consumer’s language, depend only on the contract, and inject the concrete thing from the edge of the system. Every behavioral design pattern you learn next is a specialization of this one idea.

Assessment

Pass mark: 70% on the concept questions unlocks the next lesson.

1. You are designing a `PaymentProcessor` component. Two unrelated concrete classes — `StripeProcessor` and an existing `LegacyBankClient` (which already extends a vendor base class) — both need to fulfill the payment contract. Which construct should define the contract, and why?

2. What is the core idea behind 'programming to an interface, not an implementation'?

3. Which of the following are legitimate reasons to choose an ABSTRACT BASE CLASS over an interface? (Select all that apply) (select all)

4. Where should an abstraction boundary (the interface) live to best follow the Dependency Inversion Principle?

Design problem 5

Design the abstraction boundary for a notification system that today sends Email, but must soon support SMS and Slack without changing the code that decides WHEN to notify. Define the contract and show how the high-level `NotificationService` uses it. Specify (a) whether you use an interface or abstract class and why, (b) the method signature(s) on the boundary, (c) how a new channel is added, and (d) how you would unit-test `NotificationService` without sending real messages.