Replacing Inheritance with Delegation
A class needs three useful methods from another class, so extending that class seems convenient. Months later, the subclass also inherits methods it shouldn’t expose, depends on initialization details it doesn’t control, and breaks when the superclass changes an internal assumption.
The problem isn’t inheritance itself. The problem is using an is-a relationship to obtain code reuse when the real relationship is uses-a. Replacing inheritance with delegation makes that relationship explicit: the object keeps a collaborator and forwards only the behavior it actually needs.
This article shows how to recognize that mismatch, perform the refactoring in small steps, and decide when inheritance should stay.
Start with the relationship, not the syntax
Inheritance creates a strong relationship between two types. A subclass receives behavior from its superclass and, in conventional subtype-oriented designs, can often be used where the superclass is expected. That relationship is useful when the subtype genuinely supports the parent’s contract.
Delegation creates a different relationship. One object holds or receives another object and asks it to perform a specific job. The delegating object doesn’t become a kind of its collaborator.
The distinction is easier to see as two questions:
inheritance: is this object a valid kind of the parent?
delegation: does this object need this collaborator's behavior?If the honest answer is only the second one, inheritance is probably carrying more meaning than the design needs.
See the mismatch in a small example
Suppose a reporting component extends a general file store because it needs file-reading behavior:
class ReportLoader extends FileStore:
loadReport(name):
text = read(name)
return parseReport(text)Assume FileStore also exposes operations such as:
read(name)
write(name, content)
delete(name)
listFiles()ReportLoader only needs read. Yet inheritance gives it the rest of the superclass interface as well. Code that receives a ReportLoader may now be able to call delete, even though deleting files has nothing to do with loading reports.
There is another clue: the sentence “a report loader is a file store” doesn’t describe the domain relationship. “A report loader uses a file store” does.
The smallest structural improvement is therefore:
class ReportLoader:
constructor(fileStore):
this.fileStore = fileStore
loadReport(name):
text = this.fileStore.read(name)
return parseReport(text)This is delegation. ReportLoader owns its report-loading responsibility and asks fileStore for the narrower capability it needs.
The example is intentionally simple. Production code may use interfaces, dependency injection, factories, or language-specific ownership mechanisms. Those choices don’t change the core relationship.
What the refactoring actually changes
Replacing inheritance with delegation changes more than where a method call is written. It changes which promises the class makes to its callers.
With inheritance, the subclass is coupled to the superclass’s accessible contract. Depending on the language, it may also depend on protected members, lifecycle hooks, constructor rules, overridable methods, or inherited state. Some of those dependencies can be subtle because no explicit field points to them.
With delegation, the dependency becomes an ordinary collaborator:
ReportLoader -> FileReaderThe outer class decides which operations to expose. The collaborator can evolve behind that boundary as long as the operations the outer class relies on remain compatible.
This doesn’t remove coupling. ReportLoader still depends on file-reading behavior. The improvement is that the dependency can match the capability actually required instead of automatically including the entire parent abstraction.
Refactor in small, observable steps
A safe migration starts by identifying exactly what the subclass uses from its parent. Look for inherited method calls, inherited fields, overridden methods, constructor dependencies, and places where callers treat the subclass as the parent type.
The last case matters most. If callers rely on substitutability, removing inheritance changes an externally visible relationship. That may require changing callers first rather than treating the refactoring as a local edit.
For a class used only through its own API, a practical sequence is:
- Add a collaborator that provides the required parent behavior.
- Change the subclass’s implementation to call that collaborator explicitly.
- Remove dependencies on inherited state and protected implementation details.
- Remove the
extendsor equivalent inheritance declaration. - Narrow the collaborator’s interface if the class needs only a small part of it.
For the report example, the intermediate state might still inherit from FileStore while also delegating reads to a supplied FileReader. That temporary duplication can make the change easier to verify. Once no behavior depends on the parent relationship, inheritance can be removed.
Tests should focus on observable behavior during the transition. A refactoring should preserve what callers can legitimately observe unless changing that contract is an explicit goal.
Prefer the narrow capability the caller needs
Delegation is most useful when it also improves the dependency boundary. Replacing a broad superclass with an equally broad collaborator changes the mechanism but may leave unnecessary knowledge in place.
Suppose ReportLoader receives a FileStore even though it only calls read. If the design benefits from a smaller contract, define the required capability conceptually as:
interface FileReader:
read(name)Then both a local file store and another implementation can satisfy that capability without becoming part of the report loader’s public identity.
The point isn’t to create an interface for every method. A new abstraction earns its place when it expresses a meaningful boundary, supports multiple relevant implementations, isolates change, or makes the dependency easier to understand. If FileStore is already a small and stable abstraction, injecting it directly may be simpler.
Watch for hidden superclass dependencies
The easiest inheritance relationships to replace are those that already use only public parent methods. Harder cases often reveal why the original design became fragile.
A subclass may read protected fields directly:
loadReport(name):
path = rootDirectory + "/" + nameMoving to delegation forces a design decision. Should path construction belong to the file component? Should the collaborator expose a higher-level read(name) operation? Or does the report loader genuinely own path rules?
Don’t mechanically replace a protected field with a getter. That preserves the same knowledge leak through a different syntax. Instead, identify the behavior the subclass was trying to perform and place the required knowledge with the component that should own it.
Overridden hooks require similar care. A superclass may call an overridable method as part of its internal algorithm. Once inheritance is removed, that implicit callback disappears. You may need an explicit strategy, callback, or collaborator if the variation is still necessary. In some cases, that discovery shows that inheritance was correctly modeling a template-style extension point and should remain.
Delegation has costs too
Delegation usually adds explicit plumbing. The outer object needs a collaborator, constructors may gain a dependency, and some methods may simply forward calls:
readSource(name):
return this.fileReader.read(name)That indirection is worthwhile when it narrows responsibility or removes a misleading subtype relationship. It isn’t automatically an improvement when a subclass is already a natural subtype with a stable contract.
Delegation can also become noisy if an object forwards a large interface without adding a meaningful boundary. If nearly every method is passed through unchanged, ask whether the wrapper owns a real responsibility or merely duplicates another abstraction.
There is also a runtime consideration in some systems: composition creates an additional object relationship and method forwarding may have a cost. For most application design decisions, that cost is less significant than clarity and changeability, but performance-sensitive code should be measured in its actual environment rather than decided by a general rule.
Keep inheritance when substitutability is the point
Inheritance remains appropriate when the subtype relationship is genuine and useful to callers.
Consider an abstract Shape contract with implementations such as Circle and Rectangle, where callers operate on shapes through operations that every subtype meaningfully supports. If the parent abstraction exists specifically to define that common contract and the implementations honor it, replacing the relationship with delegation may add indirection without improving the model.
Framework extension points are another case. Some frameworks intentionally require subclassing and define lifecycle methods that subclasses implement. You can sometimes isolate framework inheritance behind your own boundary, but removing it inside the integration may not be possible or useful.
The decision is therefore not “composition good, inheritance bad.” Ask what the relationship means to callers. If callers benefit from treating the child as the parent, inheritance may be expressing real substitutability. If the child only wants implementation help, delegation usually states the dependency more accurately.
Common mistakes during the change
One mistake is removing inheritance before finding all places that depend on the subtype relationship. Compilation failures may catch some uses in statically typed code, but runtime type checks, serialization rules, reflection, framework registration, or external consumers can make the dependency less obvious.
Another mistake is exposing the collaborator just to recreate the old superclass API:
reportLoader.fileStore.delete(name)That moves the broad dependency outward instead of narrowing it. Keep the collaborator private unless callers genuinely need that separate capability.
A third mistake is turning every inherited method into a forwarding method even when the outer class doesn’t need to promise it. Delegation gives you a chance to reduce the public surface. Forward only operations that belong to the outer abstraction.
Finally, don’t mix behavior changes into a structural refactoring unless necessary. First preserve the current contract, then make separate changes to semantics. Smaller steps make failures easier to attribute.
Use the relationship that tells the truth
When a subclass feels awkward, inspect why it inherits. If the answer is “to reuse these few methods,” write down the relationship in plain language. If “uses” describes it better than “is,” delegation gives the code a structure that matches that statement.
Start by making inherited dependencies explicit, move them behind a collaborator, and remove the parent relationship only after callers no longer rely on it. The result isn’t valuable because delegation is a fashionable alternative. It’s valuable because the class promises less, depends on a more precise capability, and can change without pretending to be something it isn’t.