The first time I refactored a medium-sized Python service, I tried to clean up the codebase by “standardizing” everything with inheritance. It felt tidy: a neat hierarchy, shared methods, and fewer files. A month later, changes to the base class kept rippling into unrelated features, and I spent more time debugging than shipping. That was the moment I stopped treating inheritance as the default and started making a deliberate choice between is‑a and has‑a relationships. If you’re building Python systems in 2026—mixing API layers, data pipelines, and ML services—you need that choice to be intentional.
In this post, I’ll show you how I reason about inheritance (is‑a) versus composition (has‑a), how the tradeoffs show up in real code, and how to decide quickly and confidently. I’ll use concrete examples, walk through common mistakes, and share a few modern patterns I keep coming back to. By the end, you’ll know when to reach for a base class, when to wrap with a collaborator, and how to keep your Python designs flexible without losing clarity.
Inheritance: When a Child Truly Is a Parent
Inheritance models an is‑a relationship. When I use inheritance, I’m saying, “This new type is a specialized form of that existing type.” That’s a strong statement. It means every place the parent is expected, the child can stand in without surprising behavior. That’s the heart of substitutability and why misuse causes bugs.
A good inheritance example is a domain where specialization adds features without changing fundamental meaning. Consider a logging system with a base handler and a specific handler for HTTP requests.
class LogHandler:
def init(self, name: str):
self.name = name
def handle(self, message: str) -> None:
raise NotImplementedError("Subclasses must implement handle()")
class HttpLogHandler(LogHandler):
def init(self, name: str, endpoint: str):
super().init(name)
self.endpoint = endpoint
def handle(self, message: str) -> None:
# In a real system this would send a POST request
print(f"POST {self.endpoint}
{self.name} {message}")
def emit(handler: LogHandler, message: str) -> None:
handler.handle(message)
handler = HttpLogHandler("audit", "https://logs.example.com")
emit(handler, "user_login")
This works because an HttpLogHandler behaves like a LogHandler with extra details, not a different kind of thing. If you accept a LogHandler, you can accept the child without any changes.
I recommend inheritance when:
- The child is a strict specialization of the parent, not a mere re‑use of code.
- The base class defines a clear contract that children must honor.
- You expect polymorphism: code that works against the parent should accept all children cleanly.
If any of those are shaky, I step back and consider composition instead.
Composition: Building Behavior by Assembling Parts
Composition models a has‑a relationship. A composite object contains another object and delegates part of its behavior. The composite is not the same kind of thing as the contained object; it just uses it.
In my experience, composition is the safer default for evolving systems. It lets you swap pieces, upgrade behavior, or test in isolation without reshaping a type hierarchy.
Here’s a straightforward example: a ReportGenerator that has a DataSource and a Formatter.
class DataSource:
def fetch(self) -> list[dict]:
return [{"name": "Nova", "score": 91}, {"name": "Kai", "score": 84}]
class Formatter:
def format(self, rows: list[dict]) -> str:
lines = [f"{r[‘name‘]}: {r[‘score‘]}" for r in rows]
return "\n".join(lines)
class ReportGenerator:
def init(self, source: DataSource, formatter: Formatter) -> None:
self.source = source
self.formatter = formatter
def generate(self) -> str:
rows = self.source.fetch()
return self.formatter.format(rows)
report = ReportGenerator(DataSource(), Formatter())
print(report.generate())
ReportGenerator is not a DataSource or a Formatter. It simply has them and coordinates them. You can swap in a mock DataSource for testing, or a new formatter for a different output without changing the generator’s identity.
I recommend composition when:
- You want flexible assembly of behavior.
- The relationship is about collaboration, not identity.
- You expect change in the internal parts over time.
Is‑A vs Has‑A: A Decision Framework I Use
When I’m not sure which relationship fits, I ask a few direct questions and decide quickly. I’ve found this checklist reliable across web apps, data services, and internal tooling.
1) Can I say “X is a Y” without wincing?
If I hesitate or need a footnote, composition is safer.
2) Would I ever replace X with a different Y at runtime?
If yes, I lean toward composition and dependency injection.
3) Will the child override behavior in ways that violate expectations?
If yes, inheritance will create bugs down the line.
4) Do I need shared interface or shared implementation?
If I only want shared implementation, composition or mixins are usually better.
5) Do I expect the base class to change frequently?
If yes, inheritance can become brittle—use composition and explicit interfaces.
In practice, these questions help me avoid a lot of architectural regret. The key is that inheritance couples your child to the parent’s future; composition isolates change.
A Practical Example: Payment Processing
Let’s ground this in a scenario you’ll recognize: payment processing with different providers. Many people start with inheritance, but composition usually gives you a better result.
Inheritance version (riskier)
class PaymentProcessor:
def charge(self, amount: int, currency: str) -> str:
raise NotImplementedError
class StripeProcessor(PaymentProcessor):
def charge(self, amount: int, currency: str) -> str:
return f"stripe:{amount}:{currency}"
class PayPalProcessor(PaymentProcessor):
def charge(self, amount: int, currency: str) -> str:
return f"paypal:{amount}:{currency}"
This can work, but it forces a hierarchy and invites someone to add “just one more” method to the base class that breaks the contract for a provider.
Composition version (my default)
class StripeGateway:
def send_charge(self, amount: int, currency: str) -> str:
return f"stripe:{amount}:{currency}"
class PayPalGateway:
def send_charge(self, amount: int, currency: str) -> str:
return f"paypal:{amount}:{currency}"
class PaymentService:
def init(self, gateway) -> None:
self.gateway = gateway
def charge(self, amount: int, currency: str) -> str:
return self.gateway.send_charge(amount, currency)
service = PaymentService(StripeGateway())
print(service.charge(1500, "USD"))
Now the “service” is stable, and the gateway is a replaceable part. You can swap gateway implementations without touching the service’s identity. This also makes testing trivial: drop in a stub gateway and assert behavior.
My recommendation here is clear: prefer composition. It keeps your payment system resilient when vendors change APIs, which they will.
Common Mistakes and How I Avoid Them
I see the same pitfalls over and over. Here’s what I watch for and how I correct course.
Mistake 1: Inheriting just to reuse code
This is the most common. If you’re inheriting from a class purely for access to helper methods or internal state, it’s a sign you want composition or a helper module.
Fix: Extract shared behavior into a collaborator class or a standalone function.
Mistake 2: Base class with too many responsibilities
If the base class is bloated, the child either inherits unwanted behavior or overrides half of it. That’s a code smell.
Fix: Split the base class into smaller, focused interfaces or use composition for the extra behavior.
Mistake 3: Child that breaks substitutability
If a child narrows input types, changes side effects, or throws new exceptions, you’ve violated the contract.
Fix: Reevaluate the hierarchy. If the child isn’t fully compatible, redesign with composition.
Mistake 4: Deep inheritance chains
More than two levels deep is usually a warning sign in Python. It becomes hard to follow method resolution and easy to introduce subtle bugs.
Fix: Flatten with composition or mixins, or introduce explicit interfaces with small, clear contracts.
Mistake 5: “God” objects with too many collaborators
Composition can go wrong too. When a class has six or seven internal objects, the relationships get fuzzy.
Fix: Group collaborators into cohesive sub‑objects or introduce a façade to hide complexity.
Composition in Modern Python: Protocols and Dependency Injection
In 2026, Python’s type system and tooling make composition even more attractive. I’ve found Protocol and dependency injection patterns to be the cleanest way to define expectations without inheritance.
from typing import Protocol
class Cache(Protocol):
def get(self, key: str) -> str | None:
...
def set(self, key: str, value: str, ttl_seconds: int) -> None:
...
class InMemoryCache:
def init(self):
self.store = {}
def get(self, key: str) -> str | None:
return self.store.get(key)
def set(self, key: str, value: str, ttl_seconds: int) -> None:
self.store[key] = value
class UserProfileService:
def init(self, cache: Cache) -> None:
self.cache = cache
def getprofile(self, userid: str) -> str:
cached = self.cache.get(user_id)
if cached:
return cached
profile = f"profile:{user_id}"
self.cache.set(user_id, profile, 300)
return profile
No inheritance needed. The service only requires that the injected object behaves like a cache. This is easier to test, easier to swap, and easier to extend.
I recommend this approach whenever you can express a contract as a small behavior surface. Tools like Pyright or MyPy will give you strong feedback without binding you to a hierarchy.
Performance and Maintainability Considerations
Performance isn’t usually the deciding factor between inheritance and composition, but it’s worth understanding the tradeoffs. In Python, dynamic dispatch costs are similar whether you’re calling self.method() from a base class or delegating to a composed object. In most real systems, the difference is small—typically in the microsecond range per call and dwarfed by I/O or database access.
Where performance does matter is in maintainability. I care more about the cost of change than the cost of a call. Here’s how I think about it:
- Inheritance can be fast to implement initially but expensive to modify later if base classes are shared widely.
- Composition adds a small amount of wiring overhead but keeps changes local and reduces regression risk.
If you’re building a hot path that executes thousands of times per second, measure it. But in most business systems, you’ll get more value by choosing the model that keeps design flexible.
Real-World Scenarios and Edge Cases
Here are a few real scenarios I’ve seen where the choice matters a lot.
API client libraries
If you’re building a client for multiple APIs, composition works best. Create a Client that has a Transport, AuthProvider, and RetryPolicy. You can swap each piece independently without rewriting the client. Inheritance here often leads to an unwieldy base class with too many responsibilities.
GUI or CLI tools
In UI frameworks or CLI commands, inheritance is sometimes the simplest way to extend behavior. But I still use composition to handle the data model and domain logic. This keeps the UI layer thin and replaceable.
Data pipelines
In ETL pipelines, I prefer composition for stages: Extractor, Transformer, Loader. Each stage is pluggable. If I need a pipeline runner, it composes those stages rather than inheriting from them.
Testing edge cases
Composition makes testing edge cases much easier. Want to simulate a timeout? Swap in a fake component. Want to simulate a cache miss? Provide a stub that returns None. Inheritance tends to push you into monkey‑patching or heavy subclassing just to test behavior.
Traditional vs Modern Patterns
I’ve seen many teams evolve from classical inheritance to composition‑first designs as systems grow. Here’s a quick comparison I use when mentoring teams.
Modern Pattern
—
Small protocol + composition
Flat classes + collaborators
Strategy objects injected
Helper modules or mixins
I’m not saying inheritance is “wrong.” I’m saying it should be rare, intentional, and justified by identity, not convenience.
When I Still Choose Inheritance
Even with a strong bias for composition, I still use inheritance when it fits perfectly. Here are the cases where I’ll choose it without hesitation:
- Framework extension points: When a framework expects subclassing and provides a solid base contract.
- Mathematical or geometric models: A
Squareis aRectangle(with care), aCircleis aShape. - Template method patterns: When I want a fixed algorithm with a few customizable steps, and all subclasses must follow the same flow.
If you’re in these scenarios, inheritance can be clean and expressive. The key is that the relationship must be true and durable.
Clear Guidance You Can Use Today
Here’s the decision rule I share with teams:
- Default to composition. It protects your future self.
- Use inheritance only when the “is‑a” relationship is obvious and permanent.
- If you’re uncertain, choose composition and build a thin interface that you can test independently.
If you follow that guidance, you’ll avoid most of the brittle hierarchies that slow teams down.
You can also reduce risk by writing explicit tests for shared behavior. When inheritance is necessary, I create a small test suite that verifies every subclass honors the base contract. That makes the hierarchy safer and easier to reason about.
Liskov Substitution in Real Life (Not Just Theory)
People quote the Liskov Substitution Principle, but what it really means in daily Python work is simple: if a function expects the parent, the child must not surprise it. I test that by imagining a call site that uses the base class and asking, “Would this blow up or subtly break if I passed the child?”
Here’s a classic mistake that violates substitutability:
class FileWriter:
def write(self, text: str) -> None:
with open("/tmp/output.txt", "a") as f:
f.write(text)
class ReadOnlyWriter(FileWriter):
def write(self, text: str) -> None:
raise PermissionError("read-only mode")
A caller expecting a FileWriter is suddenly dealing with a PermissionError. That’s not a safe substitution. If you need read‑only mode, it’s better as a collaborator that decides whether to allow the write, or a separate interface altogether. A composed policy object keeps behavior explicit:
class WritePolicy:
def allow_write(self) -> bool:
return True
class ReadOnlyPolicy(WritePolicy):
def allow_write(self) -> bool:
return False
class SafeWriter:
def init(self, policy: WritePolicy) -> None:
self.policy = policy
def write(self, text: str) -> None:
if not self.policy.allow_write():
return
with open("/tmp/output.txt", "a") as f:
f.write(text)
The writer remains a writer, and the policy handles decisions. That’s a clean separation of concerns and avoids surprising callers.
The Mixin Middle Ground (Use Sparingly)
Mixins are a hybrid approach: they use inheritance, but mainly to share implementation across unrelated classes. I use mixins only when the mixed behavior is small, orthogonal, and truly optional. If the mixin needs to know too much about the class it’s mixed into, it becomes a fragile coupling.
A good mixin example is providing JSON serialization for domain models:
import json
class JsonMixin:
def to_json(self) -> str:
return json.dumps(self.dict)
class User(JsonMixin):
def init(self, user_id: str, email: str) -> None:
self.userid = userid
self.email = email
class Project(JsonMixin):
def init(self, projectid: str, ownerid: str) -> None:
self.projectid = projectid
self.ownerid = ownerid
This is small and safe. The moment your mixin needs to call methods that may or may not exist, or requires a specific initialization order, I treat it as a code smell and switch to composition.
Abstract Base Classes vs Protocols
Python gives you two main ways to define interfaces: abstract base classes (ABCs) and structural typing with Protocol. I use each differently.
- ABCs are good when I want a shared partial implementation, or I want to enforce method existence at runtime with
isinstancechecks. - Protocols are better when I want behavior-based compatibility without inheritance and I want static type checking to help me.
Here’s a small example using ABCs for a real template method scenario:
from abc import ABC, abstractmethod
class FileImporter(ABC):
def import_file(self, path: str) -> int:
lines = self.readlines(path)
rows = self._parse(lines)
return self._write(rows)
def readlines(self, path: str) -> list[str]:
with open(path) as f:
return f.readlines()
@abstractmethod
def _parse(self, lines: list[str]) -> list[dict]:
...
@abstractmethod
def _write(self, rows: list[dict]) -> int:
...
The algorithm is fixed, and subclasses only fill in parts. That’s a clean use of inheritance. If I only need behavior compatibility and no shared implementation, I prefer a Protocol and composition.
A Deeper Composition Example: API Client with Pluggable Parts
Here’s a more complete, real‑world example that shows how composition scales without getting messy. I’ve used versions of this in production.
from typing import Protocol
class Transport(Protocol):
def send(self, method: str, url: str, body: dict | None = None) -> dict:
...
class AuthProvider(Protocol):
def headers(self) -> dict:
...
class RetryPolicy(Protocol):
def shouldretry(self, attempt: int, statuscode: int) -> bool:
...
class SimpleTransport:
def send(self, method: str, url: str, body: dict | None = None) -> dict:
# Real version would call httpx or requests
return {"status": 200, "data": {"ok": True}}
class TokenAuth:
def init(self, token: str) -> None:
self.token = token
def headers(self) -> dict:
return {"Authorization": f"Bearer {self.token}"}
class FixedRetry:
def init(self, retries: int) -> None:
self.retries = retries
def shouldretry(self, attempt: int, statuscode: int) -> bool:
return attempt = 500
class ApiClient:
def init(self, base_url: str, transport: Transport, auth: AuthProvider, retry: RetryPolicy) -> None:
self.baseurl = baseurl
self.transport = transport
self.auth = auth
self.retry = retry
def getuser(self, userid: str) -> dict:
url = f"{self.baseurl}/users/{userid}"
attempt = 0
while True:
attempt += 1
response = self.transport.send("GET", url, None)
if not self.retry.should_retry(attempt, response["status"]):
return response
This is simple but powerful: I can swap transport for a mock, or upgrade auth without touching the client’s identity. This is composition at its best: stable core, replaceable edges.
Composition Gone Wrong: How I Keep It Tidy
Composition isn’t automatically clean. A class can become a blob of collaborators if you don’t manage boundaries. The fix is to group related responsibilities into a cohesive collaborator, or create a facade that hides complexity.
A quick rule I use: if a class has more than four collaborators and most methods touch all of them, the class is probably doing too much. Break it into two classes that are composed by a higher‑level coordinator. That keeps each piece focused and easier to test.
Refactoring from Inheritance to Composition (Safely)
I often inherit legacy code that overuses inheritance. The safest way I’ve found to refactor is incremental.
1) Identify the unstable base class. This is often the one that changes most frequently or has the most conditional logic.
2) Extract a collaborator. Pull a cohesive chunk of behavior into a new class and compose it into the base or child.
3) Introduce a thin interface. Use a Protocol or ABC for the collaborator so you can swap it later.
4) Migrate subclasses gradually. Convert one subclass at a time to use the new collaborator.
5) Flatten the hierarchy. Once the shared behavior lives outside the base, delete unnecessary inheritance layers.
I keep this mechanical and test‑driven. Each step is small enough to undo if something goes wrong.
Testing Strategies for Both Models
When you choose inheritance, you’re accepting a contract. I protect it with tests. A simple pattern is a shared test suite that all subclasses must pass.
def asserthandlercontract(handler_cls):
handler = handler_cls("test")
handler.handle("msg") # should not throw
def testhttphandler_contract():
asserthandlercontract(lambda name: HttpLogHandler(name, "https://logs"))
For composition, testing is even easier: I mock collaborators. That lets me test the core behavior in isolation and keep tests fast.
class FakeGateway:
def init(self):
self.calls = []
def send_charge(self, amount: int, currency: str) -> str:
self.calls.append((amount, currency))
return "ok"
def testpaymentserviceusesgateway():
gateway = FakeGateway()
service = PaymentService(gateway)
assert service.charge(100, "USD") == "ok"
assert gateway.calls == [(100, "USD")]
That speed and isolation is a big reason composition scales better for larger systems.
Alternative Approaches Worth Knowing
Sometimes the best answer isn’t inheritance or composition but a different paradigm altogether. I keep these in my toolbox:
- Functional composition: Use pure functions and pass dependencies explicitly rather than bundling them into classes.
- Entity‑component patterns: Useful in simulations, games, and complex domains where entities gain behavior by attaching components.
- Configuration‑driven design: Favor declarative configuration and a small interpreter over class hierarchies for workflow‑heavy systems.
These aren’t replacements, but they’re great options when you want maximum flexibility and minimal coupling.
Production Considerations: Monitoring, Observability, and Change Safety
Design choices show up in production. Composition makes it easier to attach instrumentation and swap behavior without redeploying everything.
I like to add observability at the boundaries: transports, queues, caching layers, and external services. Those are exactly the places composition makes obvious. When I’m forced into inheritance by a framework, I still try to compose my business logic behind that layer to keep monitoring clean and consistent.
Another production concern is rollout safety. If a base class is shared across features, a change to that base is effectively a cross‑service change. That makes rollbacks harder. Composition keeps the blast radius smaller and makes incremental rollouts practical.
Performance, Revisited: When It Actually Matters
If you do hit performance issues, it’s usually about algorithmic cost or I/O, not method dispatch. That said, here are practical ways I measure:
- Use
timeitfor microbenchmarks, but only for functions called in tight loops. - Test both inheritance and composition on the exact workload that matters.
- Use ranges like “low single‑digit microseconds” rather than pretending the numbers are stable across machines.
In most business apps, the performance argument is almost always a red herring. The real win is maintainability.
Modern Tooling and AI‑Assisted Workflows
I keep a few tools in my workflow to make design decisions safer:
- Static type checkers help enforce protocol compatibility and catch mismatched signatures early.
- Linters and formatters keep interfaces consistent, which matters a lot in composed systems.
- AI assistants are most helpful for refactor suggestions and finding hidden inheritance dependencies. I still validate changes with tests, but AI can speed up the “find and assess” phase.
The point is that composition and protocols align well with modern tooling, which makes your codebase easier to maintain as it grows.
Edge Cases: When the “Obvious” Choice Is Wrong
There are scenarios where instinct leads you astray:
- “A Square is a Rectangle” is true mathematically but tricky in code if you expose setters for width and height. If a
Rectanglelets you set width and height independently, aSquaresubclass that overrides setters will surprise callers. In that case, composition or a sharedShapeinterface is safer. - “A Cached Client is a Client” sounds reasonable, but if the cached version changes error semantics or timeout behavior, callers are surprised. This is often better as a composed decorator object that is explicitly named and tested.
- “A Fake Service is a Service” in tests is usually fine, but if the fake isn’t behaviorally accurate, your tests lie. I keep fakes small and close to reality, or use contract tests.
These are subtle but common. When in doubt, I write a small test that expresses the expected behavior and then decide.
A Practical Checklist Before You Commit to Inheritance
I keep this on a sticky note when I’m designing a new class hierarchy:
- Will every method in the base make sense for every child?
- Can I describe the relationship as “X is a Y” without qualifiers?
- Would I be unhappy if a future change to the base forced me to edit all children?
- Is the base class stable, or will it evolve rapidly?
- Can I solve this with a protocol and composition instead?
If I answer “no” to any of those, I default to composition.
Key Takeaways and Next Steps
Here’s what I want you to carry forward the next time you design a class:
- Inheritance expresses identity, not convenience. If the child isn’t truly the parent, don’t inherit.
- Composition gives you flexibility and testability with a small cost in wiring.
- Protocols and injected collaborators let you define behavior without binding to a hierarchy.
- Deep inheritance chains are a maintenance burden; flatten when you can.
- When inheritance is required, guard it with contract tests and a clear, stable base.
If you want to put this into practice today, pick one module in your codebase and identify a class that inherits “just to share code.” Try extracting a collaborator and composing it. The refactor is usually small, and the clarity payoff is immediate. Once you see that difference, the is‑a vs has‑a decision starts to feel obvious—and your designs get more resilient with every iteration.


