skip to content

From an API-design and evolution standpoint, what are the trade-offs of exposing an interface versus an abstract class as your public type, and how do default methods change the calculus?

level: principalimportance: should knowfreq 42%

answer

  1. interface = freedom + default-method evolution; doesn't eat the extends slot
  2. abstract class = shared state but forces your hierarchy (single slot)
  3. adding abstract method = breaking; default/concrete addition = compatible
  4. JDK pattern: interface + optional Abstract... skeleton
  5. defaults add behavior+evolution but not state/constructors

basics

~20 s

An interface is a flexible public contract: clients can implement it freely and you can add new methods later via default methods without breaking them. An abstract class lets you share code but locks implementers into your single-inheritance hierarchy. For public APIs, prefer an interface and optionally ship an abstract 'skeleton' class for reuse.

solid answer

~60 s

When you publish a type as a library boundary, the choice shapes how clients adopt and how you can evolve it. An interface maximizes implementer freedom — a client can implement it on a class that already extends something else, and you can grow the contract later by adding default methods, which is source- and binary-compatible for existing implementers. Its cost is that you can't add abstract methods later without breaking everyone, and historically interfaces couldn't share code. An abstract class lets you centralize shared state and implementation and add new concrete methods over time without breaking subclasses, but it consumes the implementer's single extends slot and forces them into your hierarchy, which is a heavy, hard-to-reverse commitment. The mature pattern, embodied by the JDK (Collection + AbstractCollection), is to make the public type an interface and provide an optional abstract skeletal implementation for those who want the shared code. Default methods narrow but don't erase the gap: they let interfaces evolve and carry behavior, yet only abstract classes hold per-instance state and constructors.

code

java · 15 lines
java
// Public contract as an interface, evolvable via default methods:
public interface Repository<T> {
    Optional<T> findById(long id);
    List<T> findAll();
    // Added in v2 without breaking existing implementers:
    default long count() { return findAll().size(); }
}

// Optional skeletal abstract class for reuse (clients may extend OR implement bare):
public abstract class AbstractRepository<T> implements Repository<T> {
    protected final Map<Long, T> store = new HashMap<>(); // shared state
    public Optional<T> findById(long id) { return Optional.ofNullable(store.get(id)); }
    public List<T> findAll() { return List.copyOf(store.values()); }
    // subclasses fill in domain specifics; count() inherited from the interface default
}

go deeper

for a junior

Can say interfaces are contracts and abstract classes share code, and that interfaces are usually preferred for public types; may not articulate evolution/compatibility.

for a middle

Explains that interfaces don't consume the extends slot and that default methods let interfaces evolve, while abstract classes centralize shared state; knows the basic 'prefer interface' guidance.

for a senior

Reasons about source/binary compatibility, the interface-plus-skeletal-class JDK pattern, and the trade-off between implementer freedom and code reuse; uses defaults for compatibility additions.

for a principal

Frames the decision around long-term API stewardship: irreversibility of imposing a hierarchy, fragile base class, evolution strategy via defaults vs concrete additions, library/module boundary coupling, and when composition beats both.

## The lens: a type as a contract you must live with When a type is *public API* — exported from a library or module that others depend on — every decision becomes a long-term commitment, because changing it can break compilation (**source compatibility**) or already-compiled callers/implementers (**binary compatibility**). The interface-vs-abstract-class choice is therefore as much about *evolution* as about modelling. ## Interface as the public type **Strengths:** - **Implementer freedom.** A client can `implements YourType` on a class that already `extends` something else. You don't consume their one inheritance slot. This is decisive for adoption — you're not forcing your hierarchy on them. - **Multiple inheritance of type.** A single class can satisfy several of your interfaces at once. - **Evolution via default methods (Java 8+).** You can add a *new* method with a `default` body to an existing interface, and every existing implementer keeps compiling and running — source- and binary-compatible. This is exactly how the JDK retrofitted `stream()`, `forEach`, `removeIf` onto `Collection`/`Iterable` without breaking the world. **Costs / hazards:** - **Adding an *abstract* method is a breaking change** — every implementer must now write it. Once published, the abstract surface is effectively frozen; you can only grow it with defaults. - **Default-method conflicts** can arise when clients compose multiple of your interfaces (the bounded diamond), and a default can be silently shadowed by a client's superclass method ('classes win'). - **No shared state/constructor**, so common implementation can't live in the interface itself. - **Default methods weaken the contract**: a default is an implementation you're imposing; it can be inappropriate for some implementers and can conflict with their invariants. ## Abstract class as the public type **Strengths:** - **Shared state + implementation** in one controlled place; constructors enforce invariants. - **Easier evolution of *concrete* behavior**: you can add new concrete (non-abstract) methods over time and existing subclasses keep working — historically an advantage interfaces lacked before defaults. - **Controlled hierarchy**: you can make members `protected`, hide internals, and template the algorithm (Template Method). **Costs / hazards:** - **Consumes the single `extends` slot.** Any client subtype is now married to your hierarchy and can't extend anything else — a heavy, often irreversible commitment. - **Fragile base class problem**: changes to the abstract class's non-final methods can break subclasses that depended on the old call sequence; you must design `protected` hooks and document the self-use contract carefully. - **Adding an *abstract* method still breaks subclasses**, same as interfaces. ## How default methods change the calculus Before Java 8, the rule of thumb was crisp: interfaces for contracts, abstract classes when you need shared code, accepting you couldn't evolve an interface without breaking implementers. Default methods shifted the balance toward interfaces by giving them two former abstract-class powers: **carrying behavior** and **evolving without breaking implementers**. But two discriminators survive: interfaces still have **no per-instance state and no constructors**, and defaults are a public, imposed implementation rather than hidden shared internals. So defaults are best used for *convenience/compatibility* methods, not as a backdoor to a stateful base class. ## The idiomatic synthesis The JDK answer — and the standard senior/principal recommendation — is to **publish the public type as an interface and ship an optional abstract *skeletal* implementation** (the interface-plus-`Abstract...` pattern: `Collection`/`AbstractCollection`, `List`/`AbstractList`, `Map`/`AbstractMap`). Clients who can spend the inheritance slot extend the skeleton and get most methods for free by implementing a few primitives; clients who can't still implement the bare interface. This keeps the contract maximally adoptable while offering reuse, and it lets you evolve the interface via defaults and the skeleton via concrete methods independently. ## Decision heuristics - Default to an **interface** for any cross-cutting capability or any type many independent parties will implement. - Reach for an **abstract class** only when shared *state* or substantial shared *implementation with enforced invariants* justifies imposing your hierarchy, and prefer to *also* expose an interface in front of it. - Consider **composition/delegation** instead of an abstract base when you want reuse without the inheritance commitment. - Treat every published *abstract* method (interface or class) as permanent; plan evolution around default/concrete additions.

  • Why is adding a default method to a published interface considered safe, while adding an abstract method is not?
    A default method carries a body, so existing implementers inherit a working implementation and keep compiling and running — it's source- and binary-compatible. An abstract method has no body, so every existing implementer is suddenly missing a required method and fails to compile (and breaks at link time), making it a breaking change.
  • What's the downside of leaning on default methods to evolve an interface long-term?
    Defaults impose a concrete implementation on every implementer, which may violate some implementers' invariants or be semantically wrong for them; they can collide when interfaces are composed (the bounded diamond) and be silently shadowed by a superclass method ('classes win'). They also can't carry state, so they tempt you toward awkward stateless designs. Use them for compatibility/convenience, not as a stateful base-class substitute.

saying these in an interview costs you the question

  • Claiming abstract classes are 'more flexible' for public APIs — they impose the single-inheritance slot on clients.
  • Saying adding any method to an interface breaks clients — only adding an *abstract* method does; defaults are compatible.
  • Forgetting the fragile-base-class risk when evolving an abstract class's non-final methods.
  • Treating default methods as a way to add per-instance state to interfaces.
  • Recommending an abstract class without also exposing an interface in front of it for adoptability.

context