Beginner14 min readDesign Principles & Heuristicslive prototype

Program to Interfaces, Not Implementations

The Gang of Four motto. When you declare a variable, parameter, or return type, name the *contract* (an interface like `List` or `MessageSender`), not a *specific class* (like `ArrayList` or `EmailSender`). Then you can swap the concrete object behind it without touching the code that uses it. Nail it to a concrete class and every user is locked to that one choice forever.

The idea

What it is

This is the oldest rule in object-oriented design, and the most quoted line from the Gang of Four book: “Program to an interface, not an implementation.” It means one small, everyday habit. When you write down a type — for a variable, a function parameter, or a return value — write the contract (an interface like List), not the specific class (like ArrayList).

Think of the power socket in your wall. The socket is a standard — a contract. Any appliance with the right plug works: a toaster, a lamp, a phone charger. You can unplug one and plug in another and the wall never changes. Now imagine the opposite: someone hard-wired the toaster straight into the power station. It works, but only that toaster, forever. To change it you'd have to rewire the station. Programming to an interface is choosing the socket. Programming to an implementation is hard-wiring the toaster.

The one sentence to remember

Type your variables, parameters, and returns to the contract (List, MessageSender), not the concrete class (ArrayList, EmailSender). Then the object behind the contract can be swapped — and the code using it never has to change.

This is the everyday cousin of Dependency Inversion

They sound alike and they help each other, but they are not the same thing. Dependency Inversion (DIP) is about the direction of dependencies between big modules: high-level policy and low-level details should both point at an abstraction the policy owns. Program to interfaces is the small, hourly habit of declaring your variables, parameters, and return types as the contract instead of a concrete class. You can’t really follow DIP without this habit — but you use this habit constantly even in code that has no high/low-level split at all.

Mechanics

How it works

Two ways to declare the same thing

Suppose a function needs a list of orders. There are two ways to declare what it accepts. Watch only the type — the body is identical.

typescript
// Programming to an IMPLEMENTATION — locked to one class
function process(orders: Order[]) { ... }          // (TS arrays are concrete)

// In Java the difference is sharp:
void process(ArrayList<Order> orders) { ... }      // ⚠️ only an ArrayList fits
void process(List<Order> orders)      { ... }      // ✅ any List fits

The second version asks for a Listthe contract. Anything that is a List satisfies it: ArrayList, LinkedList, an immutable list, a list someone hands you from a library. The first version asks for an ArrayListone specific class. Now only an ArrayList is allowed in the door, and you are stuck with that decision everywhere this function is called.

What you actually buy: the swap is free

Say you start with ArrayList and later discover the code does tons of inserts at the front, where LinkedList is faster. If you programmed to the interface (List), you change one line — the place that builds the list — and every consumer keeps working untouched, because they only ever asked for a List:

typescript
List<Order> orders = new ArrayList<>();   // before
List<Order> orders = new LinkedList<>();  // after — the ONLY line that changed

// Every function that takes List<Order> keeps compiling and running.
// process(orders);  totalValue(orders);  report(orders);  // all untouched

If instead you had typed everything as ArrayList<Order>, that one change would ripple into every function signature, every field, every variable that named the concrete class. You'd be editing dozens of lines to swap one object. That is the price of hard-wiring.

The hidden trap: leaking the concrete class's extra methods

There's a subtler cost to naming the concrete type. A concrete class often has extra methods the interface doesn't promise. If you declare ArrayList, a caller might quietly start using ensureCapacity(...) — an ArrayList-only method. Now you can't swap to LinkedList even if you wanted to, because real code depends on a method the new class doesn't have. Declaring the interface keeps everyone honest: they can only call what the contract promises, so the swap stays possible.

CONCRETE — locked INTERFACE — open process(orders) param: ArrayList<Order> ArrayList ✓ LinkedList ✗ ImmutableList ✗ process(orders) param: List<Order> «interface» List ArrayList LinkedList Immutable
Left — the parameter is typed ArrayList, so only an ArrayList is accepted; LinkedList and ImmutableList are turned away even though they're perfectly good lists. Right — the parameter is typed List (the contract), and every class that implements List plugs in (open arrowheads point up at the interface). Same function body; the wider type lets three things fit where only one fit before.

The rule of thumb

  • Declare the widest type that does the job. Need to iterate and add? Declare List, not ArrayList. Need only to iterate? Declare Iterable or Collection.
  • Return the contract too. A method that returns ArrayList leaks its implementation to every caller; returning List keeps you free to change the internals later.
  • Construct with the concrete class — in one place. The single line new ArrayList<>() is fine; that's where the choice belongs. Just don't let the concrete name spread into signatures and fields.

Don't wrap everything in an interface “just in case”

This rule is about typing to existing contracts and writing interfaces where variation is real. It is not a license to invent a one-implementation interface for every class on the off chance you might need a second one (UserService + its lone UserServiceImpl). That's YAGNI — you add a layer, a file, and a confusing indirection that buys nothing because no second implementation ever arrives. Type to the contract when there genuinely is a contract; introduce a new interface when there genuinely is variation — not before.

Interactive prototype

See it. Build it. Break it.

A sandboxed, hands-on simulation — no setup, no install. Play with it as you read.

About this simulation

A consuming function declares a parameter type, shown two ways. In Concrete, the type is ArrayList<Order> — a tray of candidates (ArrayList, LinkedList, ImmutableList) tries to plug in, but only ArrayList is accepted (green); the rest are rejected (red, not an ArrayList) and locked. The values that fit count reads 1 and swap without editing this code? ✗. Flip the declared type to List<Order> in Interface mode and the tray unlocks: all three click in as accepted (green), the count jumps to 3, and the indicator flips to — the consuming code never changed. One explain panel, replaced on every click; nothing scrolls away.

Hands-on

Try these yourself

Open the prototype above, predict what happens, then verify.

try 01

Feel the lock

Open the prototype in Concrete mode. The function declares its parameter as ArrayList<Order>. Watch the tray: only ArrayList clicks in (green); LinkedList and ImmutableList are rejected with a red not an ArrayList and are locked. Read the side panel — values that fit: 1, swap without editing this code? ✗. This is what hard-wiring to a concrete class costs you.

try 02

Widen the type to the contract

Flip to Interface mode. The declared parameter changes to List<Order> — and that is the only change to the consuming code. Instantly the tray unlocks. The count jumps to 3 and the swap indicator flips to . Notice the function body never changed; only the type you wrote did.

try 03

Plug in every implementation

Still in Interface mode, click each chip — ArrayList, LinkedList, ImmutableList. Every one is accepted (green) and the explain panel narrates that the consuming code didn't move. That is the whole payoff: by depending on the contract, you made the implementation swappable for free. Toggle back to Concrete and watch two of the three lock shut again.

In practice

When to use it — and what trips people up

Type to the contract by default; introduce one where variation is real

Two separate habits live under this rule. The first is free and you should do it almost always: when a contract already exists (List, Collection, Reader, Comparator), declare your variables, parameters, and returns as that contract. The second — creating a new interface — is a design decision you make only where genuine variation exists.

  • A standard library already has the contract — declare List/Map/Iterable instead of ArrayList/HashMap/Vector. Pure upside; do it everywhere.
  • There really are multiple implementations — email vs. SMS senders, MySQL vs. in-memory repositories, real vs. fake clocks. Define an interface and type to it.
  • You want a fake for tests — if you need to substitute a stub/mock, the seam is a contract. Declare the interface so the test double can stand in.
  • A public API boundary — returning a contract keeps you free to change internals later without breaking callers.

Skip the interface when there's only ever one implementation

If a class has exactly one implementation and no foreseeable second one — a value object like Money, a tiny internal helper, glue code that will never be faked — don't manufacture a Thing + ThingImpl pair. The interface adds a file and a hop of indirection while buying zero flexibility. Add it the day a second implementation (or a needed test double) actually shows up.

What it gives you

  • Swappability — change ArrayList→LinkedList or Email→SMS by editing one construction line; every consumer keeps working.
  • Lower coupling — callers depend only on what the contract promises, so they can't accidentally lean on an implementation's extra methods.
  • Testability — a contract is a natural seam to drop a fake or mock into during unit tests.
  • Cleaner APIs — returning the interface hides internals and lets you evolve them later without breaking callers.

Common mistakes

  • Over-abstraction — wrapping single-implementation classes in interfaces “just in case” adds files and indirection for no payoff (YAGNI).
  • Lost specifics — typing to the contract hides a concrete class's extra methods; occasionally you genuinely need one and must choose the right wider type.
  • Indirection cost — ‘go to definition’ lands on the interface, so tracing a real call can take one extra hop.
  • Judgment required — picking the right width (Iterable vs. Collection vs. List) is a small design decision, not a mechanical rule.

Reference

Code & further reading

A minimal reference implementation and pointers worth bookmarking.

// Program to an interface: declare the CONTRACT, not a class.
interface MessageSender {
  send(to: string, text: string): void;
}

// ✅ Parameter typed to the interface — any sender fits.
function notify(sender: MessageSender, customer: string) {
  sender.send(customer, "Your order shipped");
}

class EmailSender implements MessageSender {
  send(to: string, text: string) { /* ...real email... */ }
}
class SmsSender implements MessageSender {
  send(to: string, text: string) { /* ...real SMS... */ }
}

// Swap the implementation — notify() never changes.
notify(new EmailSender(), "ada@x.io");
notify(new SmsSender(),   "ada@x.io");   // same call site, different object

// Same idea for collections: type the field/return to the contract.
class Cart {
  // ✅ Iterable<Order>, not Array<Order> — callers only iterate.
  private items: Order[] = [];
  all(): readonly Order[] { return this.items; }   // hand back the contract
}

References & further reading

6 sources

Knowledge check

Did it land?

Quick questions, answers revealed on submit. Sign in to save your best score.

question 01 / 05

What does “Program to an interface, not an implementation” actually tell you to do?

question 02 / 05

A function is declared as process(ArrayList<Order> orders). Later you want to pass a LinkedList<Order>. What happens, and what's the fix?

question 03 / 05

How does “program to interfaces” relate to the Dependency Inversion Principle (DIP)?

question 04 / 05

Why is it risky to declare a field as ArrayList and let callers use it freely, even if an ArrayList is what you have today?

question 05 / 05

When should you NOT introduce a new interface in front of a class?

0/5 answered