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.
| Capability | Interface | Abstract class |
|---|---|---|
| Method signatures (the contract) | Yes | Yes |
| Concrete instance state (fields) | No (Java: only static final constants) | Yes |
| Shared implementation of methods | Limited (default methods only) | Yes, freely |
| Constructors / initialization logic | No | Yes |
| How many can one class adopt? | Many | One (single inheritance) |
| Models the relationship | ”can do X” (capability) | “is a kind of” (substance) |
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 —ArrayListisList,Iterable,Cloneable, andSerializablesimultaneously, 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, implementsiterator()and bounds-checking on top of the abstract hookget(int)it declares — whilesize()stays abstract andisEmpty()is inherited fromAbstractCollection(which defines it assize() == 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.
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
interfacekeyword — an interface is just an abstract class with only pure virtual methods and avirtualdestructor. Python has no compile-time interface either —abc.ABCwith@abstractmethodis 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 declareimplements). 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
FakeGatewayto test checkout logic with no network. - Parallel work — two engineers can build
OrderServiceandStripeGatewayagainst the agreed interface independently.
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.
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:
- Stated in the consumer’s vocabulary, not the provider’s. A reporting module wants
getMonthlyRevenue(), notrunSql(query). If the method signature exposes the implementation’s mechanism, the abstraction leaks. - 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. - 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 withvirtualinheritance. Pure interfaces with no method bodies have no such ambiguity. - Versioned interfaces (
PaymentGatewayV2) or capability-probing when you cannot break existing clients.
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
Repositoryinterface 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 capability | Interface |
| Shared state + shared default logic across a family | Abstract class (Template Method) |
| To swap implementations without editing callers | Program to the interface + inject |
| The core to not depend on infrastructure | DIP — interface owned by the policy layer |
| Consumers to depend only on what they use | Interface Segregation — split roles |
| To supply a dependency from outside | Dependency 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.