Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Refactoring Inheritance into Composition

Hello! Last lesson focused on the types an API accepts. Now the boundary is between objects: when a class inherits an implementation, which behaviors does it expose to callers?

In roughly 40 minutes, you’ll inspect a Java subclass that inherits too much, replace that inheritance with a contained object, and check that the refactor protects the class’s rules. This is a useful mid-level interview skill because “prefer composition” is not, by itself, a design justification. You need to show what can go wrong, what changes, and what trade-off remains.


Spot the inherited behavior you did not ask for

Suppose an order-submission batch may hold at most three nonblank order IDs. A quick implementation extends ArrayList<String> to reuse its storage and operations:

import java.util.ArrayList;

class OrderIdBatch extends ArrayList<String> {
    private static final int LIMIT = 3;

    @Override
    public boolean add(String id) {
        if (id == null || id.isBlank()) {
            throw new IllegalArgumentException("Order ID must be nonblank");
        }
        if (size() >= LIMIT) {
            throw new IllegalStateException("Batch is full");
        }
        return super.add(id);
    }

    public boolean readyToSubmit() {
        return size() == LIMIT;
    }
}

The override looks reasonable until a caller uses a different inherited method:

OrderIdBatch batch = new OrderIdBatch();
batch.add("order-1");
batch.add("order-2");
batch.add("order-3");

batch.add(0, "order-4"); // Inherited indexed insertion: size is now 4
batch.set(0, " ");       // Inherited replacement: blank ID is now present

Neither call goes through this class’s add(String) validation. The problem is not merely that inheritance supplied more methods than needed. Those methods let callers break the very rules OrderIdBatch claims to enforce.

Watch CodeAesthetic’s The Flaws of Inheritance for a second example of the same design pressure. Its image-processing hierarchy is different from our batch, but the diagnostic question is the same: does the child genuinely support the whole parent contract, or does it only want some reusable code?

The Flaws of Inheritance

CodeAesthetic’s The Flaws of Inheritance shows how an initially convenient hierarchy becomes awkward when a new kind of object cannot meaningfully support all inherited operations. Watch it for the design diagnosis and the composition-based alternative.

Start with the brittle hierarchy, focusing on why a drawable image should not have to provide file loading and saving. Continue through the refactor to see shared image data used without subclassing. Finish with interfaces and reuse: notice how an interface can retain a needed client contract without forcing classes to inherit an implementation.

An “is-a” description can help identify inheritance, but it is not sufficient. For this example, the sharper test is operational: should an OrderIdBatch be usable wherever callers expect all the mutation operations of an ArrayList<String>? No—at least not while its size and validity rules matter.


Replace the parent with a collaborator

Refactoring.Guru calls this move Replace Inheritance with Delegation. Read its short description and procedure before looking at the revised Java class. Pay particular attention to its instruction to delegate only methods clients need, rather than reproducing the parent’s entire public API.

Replace Inheritance with Delegation

Read Refactoring.Guru’s Replace Inheritance with Delegation for a compact refactoring procedure and its main cost: forwarding methods take work to write.

In “Problem” and “Solution,” read the diagnosis. Then, under “Why Refactor,” “Benefits,” and “Drawbacks,” read the trade-offs. Finally, under “How to Refactor,” follow the five steps. In our example, the field will hold a fresh list rather than expose the former parent as part of the batch’s type.

The accompanying diagram makes the key distinction visible. Its conceptual Stack holds one Vector and forwards isEmpty() to it; it does not inherit every Vector operation. This is an illustration of the refactoring, not a depiction of the Java library’s java.util.Stack implementation.

Conceptual Stack with one contained Vector: `Stack.isEmpty()` delegates to the vector, while the stack controls which operations its own API exposes.

Apply the same idea to the batch. A private list handles storage; the batch decides which operations are legal:

import java.util.ArrayList;
import java.util.List;

public final class OrderIdBatch {
    private static final int LIMIT = 3;

    private final List<String> ids = new ArrayList<>();

    public void add(String id) {
        if (id == null || id.isBlank()) {
            throw new IllegalArgumentException("Order ID must be nonblank");
        }
        if (ids.size() >= LIMIT) {
            throw new IllegalStateException("Batch is full");
        }
        ids.add(id);
    }

    public int size() {
        return ids.size();
    }

    public boolean readyToSubmit() {
        return ids.size() == LIMIT;
    }

    public List<String> snapshot() {
        return List.copyOf(ids);
    }
}

This is composition: OrderIdBatch has a list instead of being an ArrayList. Its add method delegates storage to that list only after enforcing the batch rules. There is no public indexed insertion or replacement method through which a caller can bypass validation. The list field is private, and snapshot() returns an unmodifiable copy rather than the mutable backing list—a direct application of the defensive-copying work from earlier in the module.

Notice what we did not do: implement List<String> and forward every method. That would recreate the broad mutation contract we are trying to remove. Nor does this one private ArrayList require a new storage interface; an abstraction is useful when it expresses a real boundary, not simply because composition is present.


Check the boundary, then plan the migration

The quickest verification is to test both the intended operations and the prohibited effects. With JUnit 5, these tests exercise the refactored class:

import static org.junit.jupiter.api.Assertions.*;
import java.util.List;
import org.junit.jupiter.api.Test;

class OrderIdBatchTest {
    @Test
    void rejectsInvalidIdsAndOverflow() {
        OrderIdBatch batch = new OrderIdBatch();

        assertThrows(IllegalArgumentException.class, () -> batch.add(" "));
        batch.add("order-1");
        batch.add("order-2");
        batch.add("order-3");

        assertTrue(batch.readyToSubmit());
        assertThrows(IllegalStateException.class, () -> batch.add("order-4"));
    }

    @Test
    void snapshotDoesNotExposeStorage() {
        OrderIdBatch batch = new OrderIdBatch();
        batch.add("order-1");

        List<String> first = batch.snapshot();
        assertThrows(UnsupportedOperationException.class,
                () -> first.add("order-2"));

        batch.add("order-2");
        assertEquals(List.of("order-1"), first);
        assertEquals(List.of("order-1", "order-2"), batch.snapshot());
    }
}

There is also a useful compile-time check. The old call batch.add(0, "order-4") no longer compiles: indexed insertion is not part of the new batch API. A failed compilation here is evidence that an invalid operation has been removed, not a test failure to work around.

In an existing service, this refactor can break legitimate callers too. Search for places that pass OrderIdBatch as an ArrayList or List, or call inherited methods such as addAll, set, and clear. Decide what each caller actually needs. A caller that only reads IDs can receive snapshot(); one that performs a meaningful batch operation may need a deliberately named method with validation. If code truly requires an unrestricted mutable list, a constrained batch is the wrong object to pass it. Preserve needed behavior deliberately rather than forwarding methods merely to make compilation succeed.

Composition is not an automatic ban on inheritance. A carefully designed base class can be appropriate when every subclass genuinely honors its full contract and its extension points are intentional. But when inheritance exists chiefly to borrow storage or a few methods, ask whether a private collaborator and a smaller public API express the domain more accurately.


Key takeaways

Inheritance gives a subclass the parent’s public operations, including ones that may bypass the subclass’s rules. Composition lets a class reuse an implementation while exposing only its own valid operations. In a refactor, verify the invariant, remove unintended methods, and inspect affected callers rather than mechanically forwarding the old API.

Next, you’ll build on this separation of responsibilities by refactoring a Java service to receive its dependencies through constructor injection.

Can't find a good explanation? Sign up and we'll make it for you

Sign up