Table of Contents:
- Components Are Not Just Folders
- The Entity Trap — and Why Everyone Falls In
- How to Actually Find the Boundaries
- Coupling Is the Hidden Tax
- Cohesion: Does This Actually Belong Here?
- The Contract: What You Show vs. What You Hide
- Granularity: How Big Should a Component Be?
- Components Change — And That’s Fine
Components Are Not Just Folders
Okay, real talk. When most engineers first hear “component-based architecture,” they picture something like this:
project/
├── models/
│ ├── user.py
│ ├── order.py
│ └── product.py
├── services/
│ ├── user_service.py
│ ├── order_service.py
│ └── product_service.py
└── routes/
├── user_routes.py
├── order_routes.py
└── product_routes.py
Clean. Predictable. Every new team member will know where to find things. Looks like good component design, right?
Not quite. Look at what’s actually being grouped here — models, services, routes. Those are layers, not components. The real business concepts — user, order, product — have been chopped up horizontally and scattered across three folders. If you want to understand anything about how an order works, you have to look in three different places.
This is what architects call layer thinking wearing a component costume. It’s not wrong exactly, but it’s not component thinking either.
So what is a component?
A component is a self-contained unit of functionality with a clear boundary and a public interface. Other parts of the system talk to it through that interface — they don’t reach inside and grab things directly.
The shift in mindset is going from “where do files go?” to “what are this system’s moving parts, and how do they talk to each other?” Once you start seeing systems that way, a lot of architectural decisions become much less ambiguous.
The Entity Trap — and Why Everyone Falls In
Here’s the other classic mistake, and I promise you’ve either seen it or written it yourself.
You’re designing a new system. You open your database schema. You see users, orders, products, payments. So you think: “I’ll make a component for each one.” Makes sense, right? One component per table.
# Looks organized.
class UserComponent:
def create_user(self, data): ...
def get_user(self, user_id): ...
def update_user(self, user_id, data): ...
def delete_user(self, user_id): ...
class OrderComponent:
def create_order(self, data): ...
def get_order(self, order_id): ...
def update_order(self, order_id, data): ...
def delete_order(self, order_id): ...
class PaymentComponent:
def create_payment(self, data): ...
def get_payment(self, payment_id): ...
# ... you see where this is going
The problem shows up the moment you try to do anything real. “Place an order” isn’t just one component’s job — it touches all of them. You need UserComponent to validate the user, OrderComponent to create the record, PaymentComponent to charge the card, and probably something for notifications too.
So where does that logic live? It ends up in a checkout_service.py that someone writes to “just orchestrate things.” And three months later it looks like this:
# checkout_service.py — your new Big Ball of Mud, now with extra imports
class CheckoutService:
def __init__(self):
self.users = UserComponent()
self.orders = OrderComponent()
self.payments = PaymentComponent()
self.inventory = InventoryComponent()
self.notifications = NotificationComponent()
def place_order(self, user_id, cart):
user = self.users.get_user(user_id)
if not self.inventory.check_stock(cart):
raise OutOfStockError()
order = self.orders.create_order(user, cart)
self.payments.charge(user.payment_method, ...)
self.notifications.send_confirmation(user, ...)
return order
Your “components” became hollow CRUD wrappers. The actual business logic migrated out of them and into a service that now knows everything about everyone. The decomposition was cosmetic.
This is the Entity Trap: organizing around what your data looks like instead of what your system does.
The fix is simple, but it requires a mindset shift
Stop asking “what data do I have?” Start asking “what does this system need to be able to do?”
# Organized around capabilities — things the system actually does
class OrderManagement:
"""Everything about an order's lifecycle."""
def place_order(self, user_id: str, cart: Cart) -> Order: ...
def cancel_order(self, order_id: str, reason: str) -> Order: ...
def process_return(self, order_id: str, items: list) -> Refund: ...
class PaymentProcessing:
"""Everything about money moving in and out."""
def charge(self, method: PaymentMethod, amount: Money) -> ChargeResult: ...
def refund(self, charge_id: str, amount: Money) -> RefundResult: ...
def dispute(self, charge_id: str, reason: str) -> DisputeCase: ...
class InventoryControl:
"""Everything about stock and availability."""
def reserve(self, sku: str, quantity: int) -> Reservation: ...
def release(self, reservation_id: str) -> None: ...
def restock(self, sku: str, quantity: int) -> None: ...
Now “process a return” has an obvious home: OrderManagement.process_return(). Internally it calls PaymentProcessing.refund() and InventoryControl.release() — but those are just conversations between components. Nobody outside needs to know the details.
The structure now reflects what the business actually does, not what the database schema looks like. That’s a meaningful difference.
How to Actually Find the Boundaries
Okay, so you want to find good component boundaries. Theory is nice, but in practice you’re staring at an existing codebase full of files with overlapping responsibilities and no obvious seams. Where do you even start?
Here are three questions that help:
“What code always changes together?”
When a feature request comes in, which files do you always end up touching at the same time? If order_service.py and notification_service.py move in lockstep — every order change also changes the notification logic — that’s a sign they might be one component in disguise.
The flip side is also true: if payment_service.py only ever changes when the payment provider updates their API, that’s a clean boundary. Payment logic has its own reason to exist and its own reason to change.
# If these always change together, they probably belong together.
def cancel_order(order_id: str) -> Order:
order = repo.get(order_id)
order.status = OrderStatus.CANCELLED
repo.save(order)
notify_customer_of_cancellation(order) # always here, every time
return order
”Where does the data change shape?”
Trace a request through your system. Where does raw input become a typed object? Where does a business decision produce an event that other parts react to? Those handoffs — where something transforms or responsibility shifts — are often where component boundaries belong.
HTTP Request
│
▼
[Request Validation] ← raw input becomes a typed command
│
▼
[Order Management] ← business decision is made, order state changes
│
▼
[Payment Processing] ← money moves, external system is called
│
▼
[Event Publication] ← downstream systems are notified
Each arrow here is a candidate boundary. You’re not drawing components yet — you’re just noticing where “who’s responsible” changes.
”What could have its own version number?”
PaymentProcessing v2.3.1 is a meaningful statement — it has its own release cadence, its own changelog, its own reason to bump a version. The-CRUD-wrapper-around-the-orders-table v1.0.0 doesn’t mean anything, because it has no independent identity.
If something couldn’t meaningfully have its own version, it’s probably not really a component yet.
Coupling Is the Hidden Tax
Every time you reach across a component boundary without going through the proper interface, you’re taking on a little debt. Just a small import here, a quick access to an internal method there. No big deal.
Until day 400, when you have 60 modules all tangled together in ways that no single person fully understands, and changing anything feels like defusing a bomb.
That’s coupling. It’s not dramatic when it happens. It accumulates quietly.
Not all coupling is the same
Some coupling is fine — actually, it’s unavoidable. Here’s a rough ladder from “this is totally okay” to “you will regret this”:
Data coupling — you pass data in, you get data back. Clean function arguments and return types. This is how components should talk to each other.
# Totally fine
def process_return(order_id: str, items: list[ReturnItem]) -> Refund: ...
Stamp coupling — you pass in a big object but only use two fields from it. Not terrible, but worth noticing — it means the interface is carrying more weight than it needs to.
# Yellow flag — why does send_confirmation need the whole Order object?
def send_confirmation(order: Order) -> None:
send_email(order.user_email, f"Your order {order.id} is confirmed.")
# only used order.user_email and order.id
Control coupling — you pass in a flag that tells a function how to behave. This usually means one function is secretly trying to be two.
# Hmm. Why does the caller decide whether to send a receipt?
def process_payment(amount, method, send_receipt=True, log_to_audit=True): ...
Content coupling — one module directly touches the internal state or private implementation of another. The boundary is now fictional.
# order_management.py — this is bad
from payment_processing import _stripe_client # reaching into internals
def refund_directly(charge_id):
_stripe_client.refunds.create(charge=charge_id) # bypassed PaymentProcessing entirely
Once you have content coupling, the two “components” are really just one component awkwardly split across two files.
Point dependencies toward stability
A useful rule from Clean Architecture: business logic shouldn’t depend on infrastructure details. OrderManagement shouldn’t care that you’re using Stripe. Tomorrow you might switch to M-Pesa. Next year you might switch again.
# Bad — OrderManagement is now married to Stripe
class OrderManagement:
def __init__(self):
import stripe
self.stripe = stripe
# Good — OrderManagement depends on a contract, not an implementation
class PaymentGateway(Protocol):
def charge(self, method: PaymentMethod, amount: Money) -> ChargeResult: ...
def refund(self, charge_id: str, amount: Money) -> RefundResult: ...
class OrderManagement:
def __init__(self, payments: PaymentGateway):
self.payments = payments # could be Stripe, M-Pesa, or a test mock
This is dependency injection — and it’s not a Java thing or a framework thing. It’s just the practical way to make sure your business logic doesn’t get glued to your infrastructure.
Cohesion: Does This Actually Belong Here?
Coupling is about how components connect. Cohesion is about what belongs inside one component.
A cohesive component has one clear job. Everything inside it exists for the same reason and changes for the same reason. When you open the file, it tells a single story.
You don’t need a metric to feel low cohesion. Just ask yourself: can I describe what this component does in one sentence, without using the word “and”?
# Low cohesion — this is doing at least four different jobs
class OrderService:
def create_order(self, cart): ...
def send_order_confirmation(self, order): ... # notification job
def generate_invoice_pdf(self, order): ... # document job
def validate_delivery_address(self, address): ... # validation job
def get_estimated_delivery(self, order): ...
vs.
# Each of these tells one story
class OrderLifecycle:
"""Manages how orders move between states."""
def create(self, cart: Cart) -> Order: ...
def confirm(self, order_id: str) -> Order: ...
def cancel(self, order_id: str, reason: str) -> Order: ...
class OrderNotifier:
"""Handles all customer communication around orders."""
def send_confirmation(self, order: Order) -> None: ...
def send_cancellation(self, order: Order) -> None: ...
def send_delivery_update(self, order: Order, status: str) -> None: ...
class InvoiceGenerator:
"""Turns order data into financial documents."""
def generate_pdf(self, order: Order) -> bytes: ...
def generate_receipt(self, order: Order) -> Receipt: ...
Now when your PDF library releases a new version, only InvoiceGenerator cares. When your email provider changes, only OrderNotifier moves. That’s the practical value of cohesion — it makes changes local.
There’s also a human angle here. Low-cohesion components tend to be owned by nobody in particular. When a file does five things, five engineers all touch it, nobody feels responsible for it, and it grows in all directions at once. High-cohesion components have a natural owner. “Who owns the payment component?” is an answerable question. “Who owns the order service?” — when it does everything — is not.
The Contract: What You Show vs. What You Hide
Here’s a thing Python makes easy to ignore: access control.
In Java or C++, private is enforced by the compiler. In Python, it’s a convention — an underscore prefix means “this is internal, please don’t touch it.” Lots of engineers skip this entirely and end up with components where everything is technically public, so other parts of the system gradually start depending on internals that were never meant to be stable.
The fix is to think of your __init__.py as your component’s front door. Everything you put in there is the public contract. Everything else is an implementation detail.
# payment_processing/gateway.py — this is the contract
from typing import Protocol
class PaymentGateway(Protocol):
def charge(self, method: PaymentMethod, amount: Money) -> ChargeResult: ...
def refund(self, charge_id: str, amount: Money) -> RefundResult: ...
# payment_processing/_stripe.py — this is the implementation
# The leading underscore says "don't import this from outside"
class _StripeGateway:
def __init__(self, api_key: str):
self._client = stripe.Client(api_key)
def charge(self, method: PaymentMethod, amount: Money) -> ChargeResult:
...
def refund(self, charge_id: str, amount: Money) -> RefundResult:
...
# payment_processing/__init__.py — the front door
from .gateway import PaymentGateway
from ._stripe import _StripeGateway
def create_gateway(config: Config) -> PaymentGateway:
return _StripeGateway(config.stripe_api_key)
Now OrderManagement imports PaymentGateway and create_gateway from payment_processing. That’s all it ever sees. When you swap in an M-Pesa implementation, you add _mpesa.py, update create_gateway, and OrderManagement doesn’t even know anything changed.
That’s a real component boundary.
A small thing that matters a lot: breaking vs. non-breaking changes
Once you’re thinking about components as contracts, you start caring about whether changes break the callers.
# Non-breaking — adding an optional parameter is safe
def charge(
self,
method: PaymentMethod,
amount: Money,
idempotency_key: str | None = None, # callers can ignore this
) -> ChargeResult: ...
# Breaking — changing the return type will silently break callers
# Before:
def refund(self, charge_id: str, amount: Money) -> RefundResult: ...
# After:
def refund(self, charge_id: str, amount: Money) -> dict: ... # ← callers expecting .success will break
This isn’t pedantic. On a real team, knowing the difference between “I can ship this without coordinating” and “I need to talk to three other teams first” is the difference between moving fast and causing incidents.
Granularity: How Big Should a Component Be?
No clean answer here. But there are signs you’ve gone too far in either direction.
Too big: Your component has 30 public methods covering five different concerns. You can’t describe it without the word “and.” It has multiple teams contributing to it, and nobody fully owns it.
Too small: Your component is a three-line wrapper around one function. Deploying it requires deploying two other components at the same time. It has no independent reason to exist.
A useful gut check — the “Goldilocks test”:
- Can you change and ship this component without touching others?
- Can you write tests for it without standing up the whole system?
- Is there one person (or team) who clearly owns it?
If all three are yes, the size is probably right. If any is no, something’s off.
A rough mental spectrum:
Too small ←————————————————————————————→ Too large
3-line single one one domain "everything"
wrapper function capability aggregate service
↑
sweet spot
A “capability” — something like OrderManagement or PaymentProcessing — is usually the right unit. It maps to something a business person can name, and a single team can own.
Components Change — And That’s Fine
Here’s something that trips people up: they think component design is a one-time decision. You draw the boundaries on day one, and then you enforce them forever.
But systems grow. What’s the right decomposition for a three-person startup is not the right decomposition for a thirty-person team. A single OrderManagement component might eventually need to split into OrderCreation, OrderFulfillment, and OrderReturns because each has grown complex enough to deserve its own home.
That’s not a sign the original design was wrong. That’s just how software works.
Signs a component wants to split
It’s doing too many separate things. If you can find two groups of methods inside a component that never call each other, you basically already have two components — they just share a file.
class OrderService:
# Group 1: order state management
def create_order(self): ...
def _validate_cart(self): ... # only called by create_order
def _check_inventory(self): ... # only called by create_order
# Group 2: reporting
def get_daily_summary(self): ...
def export_to_csv(self): ... # only called by get_daily_summary
def _format_row(self): ... # only called by export_to_csv
# Group 1 and Group 2 never interact. These are already two components.
Multiple teams keep bumping into each other. Consistent merge conflicts in the same file are a signal. It’s a team ownership problem that looks like a technical problem.
Signs two components should merge
The opposite also happens. Sometimes a boundary looks clean on paper but in practice the two components always move together — always deployed together, always changed together, always tested together. At that point, the boundary is just overhead.
# If you always do these two things in the same breath...
order = order_management.create(cart)
search_index.index(order)
# ...maybe search is just a feature of order management, not a separate component.
The goal isn’t maximum decomposition. It’s appropriate decomposition — for your current team size, system complexity, and operational needs. That target moves as your system grows.
Putting It Together
When you’re looking at a system and trying to make sense of how it should be structured, here’s a thinking process that actually helps:
- Write down what the system does. Not what data it stores — what it does. Verbs.
- Group those verbs by “what would change for the same reason?” Those clusters are your candidate components.
- Name each component like a business person would. If you can’t explain it to a non-technical stakeholder, the boundary might not be real.
- Define the interface — what does the outside world see? What’s hidden?
- Check the dependencies — does anything reach into internals? Replace those with abstractions.
- Ask the one-sentence question for each: if you can’t describe it without “and,” split it.
- Revisit in six months. Seriously, put it in your calendar.
The goal isn’t a perfect architecture diagram. It’s building the habit of making intentional choices — about boundaries, about what’s public, about who owns what — and revisiting those choices as the system evolves.
A system where every boundary has a reason and every component has an owner is a system you can actually work in without dreading every change.
Next up: Architecture Styles — how these same ideas play out at the whole-system level. Layered monoliths, event-driven systems, microservices — when each one makes sense, and what you’re trading away when you pick one.