02 — Clean & Hexagonal Architecture (Ports & Adapters)
At a glance
After completing this lesson you can:
- Structure backend services using Clean/Hexagonal Architecture (Domain Core, Ports, and Adapters).
- Isolate pure business rules from frameworks (FastAPI, Django), databases (SQLAlchemy, psycopg), and third-party APIs.
- Build in-memory unit tests that execute in microseconds without spinning up databases.
Time: ~50 minutes · Prerequisites: 01 — SOLID & Cohesion
Reading Companion
- Book: Architecture Patterns with Python (Cosmic Python) — Harry Percival & Bob Gregory
- Chapter 2: Repository Pattern (Building an abstraction over persistent data storage).
- Chapter 4: Service Layer / Use Cases (Orchestrating domain operations).
Why this matters in production
When domain business logic is intertwined directly inside FastAPI route handlers or Django ORM models, upgrading a database driver, changing web frameworks, or adding a CLI runner requires refactoring everything. Under Hexagonal Architecture, the core domain model has zero external imports. Changing PostgreSQL to DynamoDB or adding a gRPC server merely means writing a new Adapter—the core business logic remains untouched.
Deep dive
1. The Hexagonal / Clean Architecture Onion
[ HTTP / FastAPI ] [ CLI ]
\ /
v v
+-------------------------------+
| Primary Adapters |
| +-------------------------+ |
| | Service Layer | |
| | (Application Ports) | |
| | +-------------------+ | |
| | | Domain Model | | |
| | | (Entities, Rules) | | |
| | +-------------------+ | |
| +-------------------------+ |
| Secondary Adapters |
+-------------------------------+
^ ^
/ \
[ PostgreSQL / SQL ] [ S3 / Email ]
- Domain Model: Pure entities and domain rules. No SQL, no HTTP, no Pydantic, no framework decorators.
- Ports: Abstract interfaces defining what the domain requires (e.g.
UserRepository). - Adapters: Concrete implementations (e.g.
PostgresUserRepository,SendgridEmailAdapter,FastAPIRoutes).
Hands on
python3 phase1-cs-and-architecture/03-software-architecture/02-clean-and-hexagonal-architecture/examples/01_demo.py
python3 phase1-cs-and-architecture/03-software-architecture/02-clean-and-hexagonal-architecture/examples/02_trap.py
AI Trap & Code Review
# BAD AI-GENERATED CODE — DOMAIN LEAKING INTO INFRASTRUCTURE
from fastapi import HTTPException
from models import DBOrder # Direct database model import
def cancel_order(db_order: DBOrder):
if db_order.status == "SHIPPED":
raise HTTPException(status_code=400, detail="Cannot cancel shipped order") # HTTP in domain logic!
db_order.status = "CANCELLED"
Exercises
- Rung 1 (
Domain Entity Validation): Implement a pure domain entitySubscriptionwith invariant methods (cancel(),renew()) raising domain exceptions (never HTTP exceptions). - Rung 2 (
Port Definition— Fix the AI Bug): Define clearSubscriptionRepositoryandPaymentGatewayports astyping.Protocol. - Rung 3 (
Service Layer Use Case): Implement theRenewSubscriptionUseCaseservice coordinating the domain entity, repository, and gateway. - Rung 4 (
Fake Adapters): Build in-memory fake adapters satisfying the ports for rapid unit verification. - Rung 5 (
Primary CLI Adapter): Build a command-line entrypoint adapter that drives the use case and translates domain exceptions into standard POSIX exit codes (0or1).
Wrapping up
Can you explain?
- What is the Dependency Rule in Clean Architecture?
- Why should domain entities never raise
HTTPException? - What is the difference between a Primary (Driving) Adapter and a Secondary (Driven) Adapter?
Further Reading & References
- Martin Fowler: Microservices and the Bounded Context
- Cosmic Python (Architecture Patterns with Python)
- Google Engineering Practices: Code Review Developer Guide
- Alistair Cockburn: Hexagonal Architecture (Ports and Adapters)
Show reference solution notes
Solution & Engineering Notes — 02 Clean & Hexagonal Architecture
- Isolation of Domain State: The
Subscriptionentity enforces its own invariants (credits >= 0, status checks). No external service can put the entity into an illegal state. - Deep Copy in In-Memory Fakes: Always
copy.deepcopy()in repository fakes to prevent caller mutations from silently updating stored state without callingsave().
Self-check
Run python3 -m unittest phase1-cs-and-architecture/03-software-architecture/02-clean-and-hexagonal-architecture/test_exercises.py -v until green, then record it here. Saved to progress/rungs.json; commit it.
Completion edits PROGRESS.md; commit it with the lesson.