Why Architecture Matters: The Cost of Ignoring It
Let me start with a confession. Early in my career, I was proud of how fast I could ship features. No ceremony, no diagrams, no hand-wringing about abstractions — just code. And for about six months on any given project, that approach felt like a superpower.
Then month seven would arrive. A simple change — swap the email provider, add a new payment method, move from PostgreSQL to a different store — would take three days instead of three hours. The tests, when they existed, were too slow to run locally and too brittle to trust. New teammates would spend their first two weeks reading code, asking questions like "wait, why is the billing logic in the view?" and receiving answers that boiled down to "historical reasons." The feature velocity that had felt so good in the early days had quietly inverted itself into a tax on every subsequent decision.
That inversion — fast early, expensive forever — is what this course is actually about.
Architecture is not a luxury for large teams or complex systems. It is the set of decisions that determine how expensive the next decision will be. Done well, it is practically invisible: code that reads as if the problem was always simple, tests that run in seconds, features that slot in without disturbing anything they shouldn't touch. Done poorly, it is everywhere: in the 3 AM pages, the release freezes, the "we'll refactor that someday" comments that are now three years old.
The Real Metric: Change Velocity, Not Initial Delivery Speed
Here is the uncomfortable truth about software economics that nobody puts in the project proposal. The cost of writing code is small. The cost of changing code over a system's lifetime dwarfs it.
The majority of a software system's cost is incurred after initial release — in bug fixes, feature additions, and adaptations to new requirements. The ratio varies by domain, but "75% of total cost is maintenance" is a number that comes up repeatedly in the literature, and most practitioners I know think that's conservative.
What this means in practice: the codebase you ship on day one is a financial instrument. Its architecture determines the interest rate you'll pay on every future change. High coupling and low cohesion are not just aesthetic problems — they are a compounding liability.
Let me make this concrete. Consider a Flask application that started as a weekend prototype and is now running in production.
# The kind of code that happens when you're moving fast
@app.route('/orders/<order_id>/ship', methods=['POST'])
def ship_order(order_id):
order = db.session.query(Order).filter_by(id=order_id).first()
if not order:
return jsonify({'error': 'Not found'}), 404
if order.status != 'paid':
return jsonify({'error': 'Order not paid'}), 400
# Business rule buried in the view
if order.total > 1000:
requires_signature = True
else:
requires_signature = False
# Direct third-party call — no abstraction, no seam
response = requests.post(
'https://api.shippo.com/shipments',
json={
'address_to': order.shipping_address,
'parcels': [{'weight': order.total_weight}],
'requires_signature': requires_signature,
},
headers={'Authorization': f'ShippoToken {SHIPPO_API_KEY}'}
)
tracking_number = response.json()['tracking_number']
order.status = 'shipped'
order.tracking_number = tracking_number
db.session.commit()
# Sending email directly — another hard dependency
send_grid_client.send(
to=order.customer_email,
subject='Your order has shipped',
body=f'Tracking: {tracking_number}'
)
return jsonify({'tracking_number': tracking_number})
This function works. On the first day it works great. But count the dependencies it has formed: the Flask request context, the SQLAlchemy session, the Shippo API specifically, the SendGrid client specifically, the Order model's internal fields, and the SHIPPO_API_KEY global. Now ask yourself: how do you test this in isolation? You can't, without either mocking seven things or standing up the entire infrastructure stack.
More painfully: what happens when the business decides to support a second shipping carrier? Or when marketing wants to send shipping notifications via SMS instead of (or in addition to) email? Every one of those changes requires touching this function, understanding all its dependencies, and hoping you don't break the parts you weren't trying to change.
This is what bad architecture costs. Not in the moment of writing it. In the dozens of moments that follow.
Coupling and Cohesion: The Two Forces That Explain Everything
Every architectural decision you will ever make is, at its core, an attempt to manage two forces: coupling and cohesion. Everything else — SOLID principles, design patterns, hexagonal architecture, microservices — is just a more specific vocabulary for talking about these two things.
Coupling is the degree to which one module depends on another. When module A knows the internal details of module B, they are tightly coupled. Change B, and A breaks. The ship_order function above is tightly coupled to Shippo's API response format — if Shippo changes tracking_number to trackingNumber, you have a bug in production.
Cohesion is the degree to which the elements inside a module belong together. High cohesion means a module has one clear job and does it well. Low cohesion — sometimes called "coincidental cohesion" — means a module does many unrelated things, usually because someone was in a hurry and it was the nearest available place.
The relationship between them is not quite a trade-off; it is more of a design target. You are always trying to maximize cohesion within modules while minimizing coupling between them. The goal is code where each piece has a clear identity and talks to the outside world through well-defined seams.
graph TD
A[High Coupling + Low Cohesion] --> E[Fragile: Changes ripple everywhere]
B[Low Coupling + Low Cohesion] --> F[Spaghetti: Hard to understand]
C[High Coupling + High Cohesion] --> G[Brittle: Correct but impossible to swap]
D[Low Coupling + High Cohesion] --> H[Clean: Easy to change and test]
style D fill:#2d5a27,color:#fff
style H fill:#2d5a27,color:#fff
style E fill:#8b1a1a,color:#fff
The classic symptom of too much coupling is the shotgun surgery smell: you need to make one logical change, but you have to edit seven different files to do it. You changed the email notification format and now you need to update the view, the service, the model, the test fixture, the integration test, and the admin panel. Each of those files knew too much about the others.
The classic symptom of low cohesion is the God class or the catch-all utils.py: one module that does unrelated things, grows without bound, and becomes the junk drawer of the codebase. Everyone adds to it. Nobody owns it. It has no architectural identity.
Seams: Where Change Can Happen
There is one more vocabulary word worth introducing here because we will use it throughout the course: the seam.
Michael Feathers, in Working Effectively with Legacy Code, defines a seam as a place in the code where you can alter behavior without editing the code at that place. Seams are where you plug in test doubles. Seams are where you swap implementations. Seams are, architecturally speaking, the places where your system can flex.
In the ship_order example above, there are no seams. The Shippo call is hardcoded. The SendGrid call is hardcoded. If you want different behavior — in a test, in a different environment, for a different customer segment — you have to edit the function itself. The architecture has trapped you.
A seam might be as simple as an argument you pass in instead of a global you reach for, or as formal as a Protocol that defines the interface a shipping provider must implement. The form varies. The purpose is always the same: to create a point of flexibility where the system can be changed or tested without surgery.
What "Clean" Actually Means
The word "clean" gets thrown around a lot in software, usually as an aesthetic judgment dressed up as an engineering principle. I want to be more precise. For the purposes of this course, clean code has three specific properties:
It communicates intent. A reader who understands the domain (not necessarily the codebase) should be able to read a function and understand what it is trying to accomplish. Clean code is expressive: it provides instructions to a computer while remaining readable and clearly communicating intent to humans. Note that this is about the reader's experience, not the writer's self-expression. Clever code that requires you to hold five abstractions in your head simultaneously is not clean; it is showing off.
It resists rot. Rot — also called entropy or technical debt — is the natural tendency of a codebase to become harder to change over time. Clean architecture resists rot not by being perfect but by making decay visible and localized. When something goes wrong, it goes wrong in one place, not everywhere simultaneously.
It enables testing. This is perhaps the most operationally useful definition. If you cannot write a fast, isolated test for a piece of behavior, that is a strong signal that the behavior is too coupled to its context. Testability is not just a quality metric — it is an architectural sensor. Code that is hard to test is code that is hard to change, for the same underlying reason: too many implicit dependencies.
Writing clean code is about keeping it simple, expressive, and free from excessive duplication. These are not abstract ideals. Simplicity means the code contains only what is necessary for the problem at hand. Expressiveness means names and structures that reveal purpose. Freedom from duplication means business rules live in one place, so you change them once.
Incidental vs. Essential Complexity
Fred Brooks, in his 1986 essay "No Silver Bullet," drew a distinction that remains one of the most useful diagnostic tools in software engineering. Essential complexity is the irreducible complexity of the problem itself. If you are building a payroll system, tax law is complex. That complexity does not go away; you have to model it somehow. Accidental (or incidental) complexity is complexity that arises from the tools, processes, and choices you made — complexity that is not inherent to the problem.
The job of architecture is to contain essential complexity (model it well, isolate it, make it navigable) and eliminate incidental complexity wherever possible.
In Python specifically, incidental complexity often looks like:
- Importing a database session in a function that is doing pure calculation
- Having a function that returns different types depending on a flag (
returns User or None or a dict, depending) - Threading business logic through eight layers of indirection because the original codebase confused "separation of concerns" with "more files"
- Using a full class hierarchy with six levels of inheritance to represent something that is genuinely a simple data container
The irony is that incidental complexity often comes from trying to be architectural. Someone read about patterns, applied them mechanically without understanding the forces they address, and now you have a AbstractBaseUserFactoryStrategyBuilder that wraps a function that fetches a row from a database.
Writing Pythonic code means thinking in Python from the start, not idiomatically translating patterns from another language. This is the central tension this course holds: we want the discipline of architecture without the cargo-culting of patterns that were designed for different languages and different problems.
Python's Flexibility: Superpower and Liability
Python occupies a peculiar position among mainstream languages. It is dynamically typed, multiparadigm, and aggressively readable. It gives you enormous latitude: you can write procedural scripts, functional pipelines, OOP hierarchies, or whatever hybrid suits the problem. There is rarely a Python feature that prevents you from doing something.
That freedom is genuinely wonderful. It is also genuinely dangerous, and I say this as someone who has spent the better part of fifteen years writing Python professionally.
In a language like Java, the type system and the access modifiers create a certain amount of forced structure. You cannot accidentally reach into a private field. The compiler enforces interface contracts. These constraints are annoying but they also function as guardrails — they make certain kinds of coupling literally impossible.
Python has none of those guardrails by default. You can import anything from anywhere. You can monkey-patch classes at runtime. You can add attributes to objects that were not declared in __init__. The interpreter will not stop you. Your colleagues will, eventually, but by then the pattern is established and the test suite is already fragile.
The result is that Python projects have a particular failure mode: they start beautifully readable and flexible, then gradually accumulate coupling in invisible ways. Nobody made a big wrong decision. Hundreds of small convenient decisions compounded into an architecture that nobody would have chosen intentionally.
This is why Python, more than most languages, rewards deliberate architectural thinking. The language will not enforce the discipline for you. You have to build it in consciously, using the tools Python provides — and Python does provide excellent tools once you know what you are looking for. Protocols for structural subtyping. Dataclasses for value objects. Type hints that document (and optionally enforce) boundaries. Decorators that wrap behavior cleanly. These are architectural tools, and we will use them as such.
A Map of What Follows
This course moves through three levels of concern, roughly from smaller to larger:
Code-level hygiene (Sections 2–4): What does a well-designed function look like? When is a class the right unit, and when is a plain function better? How does Python's type system — including Protocol, dataclass, and duck typing — give you design leverage without Java-style ceremony?
Principle-level reasoning (Sections 5–6): The SOLID principles, applied in Python with Python idioms. Not "here is what the Liskov Substitution Principle says in a textbook" but "here is the actual failure mode it prevents, here is how to spot it in a Python codebase, and here is the Pythonic remedy."
Application-level structure (Sections 7–13): Classic patterns in Pythonic form. Dependency injection. Layered architecture. Hexagonal architecture. The domain layer as the heart of a system. Testing as an architectural concern, not an afterthought. And finally, a worked example of refactoring a messy real-world codebase toward something you would actually be proud of.
Throughout all of it, the thesis stays constant: the goal is not to faithfully implement patterns from other languages. The goal is to understand what forces those patterns were designed to address — coupling, cohesion, changeability, testability — and then reach for the most Pythonic tool available for each job.
Sometimes that tool is a Protocol. Sometimes it is a dataclass. Sometimes it is a plain function with a well-chosen signature. Sometimes, honestly, it is a comment that explains why the code is the way it is. The appropriate level of architecture is always relative to the problem, the team, and the expected rate of change.
The Vocabulary We Will Use
Before moving on, let me pin down the terms we have introduced here, because we will use them precisely throughout the course:
Coupling — the degree to which a module depends on the internal details of another. Lower is generally better. Coupling through interfaces (rather than implementations) is acceptable; coupling through concrete details is a liability.
Cohesion — the degree to which the elements of a module belong together and serve a single, clear purpose. Higher is generally better.
Seam — a point in the code where behavior can be changed or substituted without editing the code at that location. Seams enable testing, extension, and swapping of implementations.
Essential complexity — complexity inherent to the problem domain, which cannot be designed away. It must be modeled and managed.
Incidental complexity — complexity arising from choices, tools, and patterns, not from the problem itself. The primary enemy of clean architecture.
Dependency direction — the direction in which knowledge flows. Module A "depends on" module B if A needs to know things about B to function. In clean architecture, dependency direction is deliberate, not accidental, and high-level modules (business rules) do not depend on low-level modules (databases, APIs).
These concepts are not Python-specific. But every section that follows will show you what they look like in Python — in real code, with real trade-offs, and with the kind of judgment that only comes from having shipped a few systems that hurt you later.
Let us get into it.
Only visible to you
Sign in to take notes.