Architecture Patterns

Reviewed & published by Brayan K

Master architectural patterns that separate concerns, enforce dependency rules, and build maintainable Python applications that scale from prototype to production

Part of the free Python course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

What You'll Learn

What Is Architecture?

Architecture is how you organize code and responsibilities in an application. It determines where business rules live, how modules depend on each other, and how easy it is to change, test, and extend your system.

❌ Bad Architecture Symptoms✅ Good Architecture Signs
"God files" with 1000+ linesSmall, focused modules
Business logic mixed with SQL/HTTPClear layer boundaries
Can't test without starting serversEach component testable in isolation
Changing DB breaks half the appCan swap DB without major rewrites
New features cause ripple effectsFeatures added without breaking others

MVC Pattern Basics

Model-View-Controller separates an application into three interconnected components:

Model

Represents data and business rules:

View

Responsible for presenting data:

Views should be thin: format and present data, don't implement business rules

Controller

Coordinates between Model and View:

Clean Architecture Core Principle

"Dependencies point inward toward business rules, never outward."

This single rule prevents most long-term maintainability problems. Business logic should never depend on frameworks, databases, or UI code.

1. Domain / Entities (Core)

Pure business objects and rules. Zero framework or database imports. Contains entities, value objects, and domain services.

2. Application / Use Cases

Orchestrates domain actions. Implements "what happens when..." logic. Defines interfaces (ports) for external dependencies.

3. Infrastructure / Adapters

Implements interfaces defined by inner layers. Contains DB, HTTP clients, file storage, external API wrappers, caching, queues.

4. Interface / Presentation

Entry points: web routes, CLI commands, event handlers, cron jobs. Thin layer that delegates to use cases.

Dependency Inversion Principle

Key Principle: High-level modules should not depend on low-level modules. Both should depend on abstractions (interfaces).

✗ Bad: Direct Dependency

# use_case.py
from infrastructure.postgres import PostgresUserRepo

def register_user(email: str):
    repo = PostgresUserRepo()  # Tightly coupled!
    repo.save(user)

Problem: Use case knows about PostgreSQL. Can't test without DB. Can't switch to MongoDB.

✓ Good: Depend on Abstraction

# interfaces.py
class UserRepository(Protocol):
    def save(self, user: User) -> None: ...

# use_case.py
def register_user(email: str, repo: UserRepository):
    repo.save(user)  # Works with any implementation!

Benefit: Use case depends on interface. Can test with fakes. Infrastructure is swappable.

Ports and Adapters (Hexagonal)

Hexagonal Architecture formalizes Clean Architecture with two key concepts:

Ports

Abstract interfaces defining what the application needs:

Adapters

Concrete implementations of ports:

Key Benefits

📖 Worked Example: Dependency Inversion You Can Run

The diagrams above describe the idea. This program is the idea, small enough to hold in your head. One use case, one port, two adapters — and the payoff at the end: the exact same business logic tested with no email server, no network and no database.

Protocol is Python's way of writing an interface. Any class with a matching method satisfies it — no inheritance and no registration needed. That is why ConsoleNotifier and FakeNotifier are interchangeable without either of them mentioning Notifier.

from typing import Protocol

# ---------- PORT: what the use case NEEDS, written as an interface ----------
class Notifier(Protocol):
    """A 'port'. It states WHAT is needed and never HOW it is done."""
    def send(self, to: str, message: str) -> None: ...


# ---------- DOMAIN: pure business rules, no imports from the outside world ----------
class Order:
    def __init__(self, order_id: str, total: float) -> None:
        self.id = order_id
        self.total = total
        self.status = "pending"

    def confirm(self) -> None:
        """Business rule: an order worth nothing can never be confirmed."""
        if self.total <= 0:
            raise ValueError("Cannot confirm an order worth nothing")
        self.status = "confirmed"


# ---------- USE CASE: orchestrates. Depends on the PORT, not on any adapter ----------
class ConfirmOrder:
    def __init__(self, notifier: Notifier) -> None:
        self.notifier = notifier          # injected from outside, never built in here

    def execute(self, order: Order, customer_email: str) -> Order:
        order.confirm()                   # domain rule runs first
        self.notifier.send(customer_email, f"Order {order.id} confirmed: £{order.total:.2f}")
        return order


# ---------- ADAPTERS: two different HOWs that satisfy the same port ----------
class ConsoleNotifier:
    """Stands in for the real email/SMS adapter."""
    def send(self, to: str, message: str) -> None:
        print(f"[email to {to}] {message}")


class FakeNotifier:
    """Test adapter — records messages instead of sending them."""
    def __init__(self) -> None:
        self.sent: list[tuple[str, str]] = []

    def send(self, to: str, message: str) -> None:
        self.sent.append((to, message))


# ---------- WIRING: the only place that knows which adapter is used ----------
ConfirmOrder(ConsoleNotifier()).execute(Order("A-1", 45.00), "[email protected]")

# The same use case, tested with no network, no server, no database.
fake = FakeNotifier()
order = ConfirmOrder(fake).execute(Order("A-2", 12.50), "[email protected]")

print("Status:", order.status)
print("Messages captured:", len(fake.sent))
print("Last message:", fake.sent[-1][1])

# The domain rule still guards the use case, whichever adapter is plugged in.
try:
    ConfirmOrder(FakeNotifier()).execute(Order("A-3", 0.0), "[email protected]")
except ValueError as e:
    print("Rejected:", e)

# ✅ Expected output:
# [email to [email protected]] Order A-1 confirmed: £45.00
# Status: confirmed
# Messages captured: 1
# Last message: Order A-2 confirmed: £12.50
# Rejected: Cannot confirm an order worth nothing

Look at what ConfirmOrder imports: nothing. No database driver, no HTTP client, no email library. Every dependency arrives through its constructor. That single habit is what makes the bottom half of this program — the test — possible at all.

🎯 Your Turn: Inject a Clock

Time is the classic hidden dependency. A use case that calls datetime.now() directly cannot be tested reliably, because the answer changes every run. The fix is the same as for the database: make the clock a port and inject it. Fill in the three ___ blanks.

from typing import Protocol

# 🎯 YOUR TURN — replace the three ___ blanks

class Clock(Protocol):
    """PORT: the use case needs the time, but must not care where time comes from."""
    def now(self) -> str: ...


class Booking:
    def __init__(self, room: str) -> None:
        self.room = room
        self.confirmed_at: str | None = None


class ConfirmBooking:
    # 1) Accept the port as a constructor argument instead of building one inside.
    def __init__(self, clock: ___) -> None:      # 👉 which type should this promise?
        self.clock = clock

    def execute(self, booking: Booking) -> Booking:
        # 2) Ask the injected clock for the time — never call a global time function here.
        booking.confirmed_at = self.___.now()    # 👉 the attribute you stored above
        return booking


class FrozenClock:
    """Test adapter: always the same instant, so tests never flap."""
    def now(self) -> str:
        return "2024-05-01 09:00"


# 3) Wire the FAKE clock into the use case — this is dependency injection.
booking = ConfirmBooking(___()).execute(Booking("Blue Room"))   # 👉 which adapter?

print("Room:", booking.room)
print("Confirmed at:", booking.confirmed_at)
print("Deterministic?", booking.confirmed_at == "2024-05-01 09:00")

# ✅ Expected output:
# Room: Blue Room
# Confirmed at: 2024-05-01 09:00
# Deterministic? True

1) Clock — the port, never FrozenClock. Naming the concrete adapter here would defeat the whole exercise.

3) FrozenClock — ConfirmBooking(FrozenClock()).

In production you would pass a real clock adapter whose now() calls datetime.now(). The use case never changes — only the wiring line does.

🏆 Mini-Challenge: Add a Storage Port

Outline only — the code is yours. Grow the booking use case so it also stores what it confirms, without letting it learn anything about how storage works. Two ports in, still zero infrastructure imports.

from typing import Protocol

class Clock(Protocol):
    def now(self) -> str: ...

class Booking:
    def __init__(self, room: str) -> None:
        self.room = room
        self.confirmed_at: str | None = None

class FrozenClock:
    def now(self) -> str:
        return "2024-05-01 09:00"

# 🎯 MINI-CHALLENGE: add a second port
# The booking use case confirms a booking but never stores it anywhere.
#
# 1. Define a Protocol called BookingStore with two methods:
#       def save(self, booking: "Booking") -> None: ...
#       def count(self) -> int: ...
# 2. Write InMemoryBookingStore, an adapter that satisfies it:
#       keeps a dict of room -> booking, save() adds one, count() returns len()
# 3. Write ConfirmBooking whose __init__ takes BOTH a clock and a store,
#       and whose execute() sets confirmed_at from the clock, then saves.
# 4. Wire it up with FrozenClock() and InMemoryBookingStore(),
#       confirm "Blue Room" and then "Red Room", and print:
#           "Bookings stored:" and the count
#           f"{blue.room} confirmed at {blue.confirmed_at}"
#
# The point: ConfirmBooking still imports no database, no framework and no clock
# library. It only knows two Protocols. That is Clean Architecture in 30 lines.
#
# ✅ Expected output:
# Bookings stored: 2
# Blue Room confirmed at 2024-05-01 09:00

# your code here
from typing import Protocol

class Clock(Protocol):
    def now(self) -> str: ...

class BookingStore(Protocol):
    def save(self, booking: "Booking") -> None: ...
    def count(self) -> int: ...

class Booking:
    def __init__(self, room: str) -> None:
        self.room = room
        self.confirmed_at: str | None = None

class FrozenClock:
    def now(self) -> str:
        return "2024-05-01 09:00"

class InMemoryBookingStore:
    def __init__(self) -> None:
        self._rows: dict[str, Booking] = {}
    def save(self, booking: Booking) -> None:
        self._rows[booking.room] = booking
    def count(self) -> int:
        return len(self._rows)

class ConfirmBooking:
    def __init__(self, clock: Clock, store: BookingStore) -> None:
        self.clock = clock
        self.store = store
    def execute(self, booking: Booking) -> Booking:
        booking.confirmed_at = self.clock.now()
        self.store.save(booking)
        return booking

store = InMemoryBookingStore()
use_case = ConfirmBooking(FrozenClock(), store)

blue = use_case.execute(Booking("Blue Room"))
use_case.execute(Booking("Red Room"))

print("Bookings stored:", store.count())
print(f"{blue.room} confirmed at {blue.confirmed_at}")

Swapping InMemoryBookingStore for a real PostgreSQL adapter later means writing one new class and changing one wiring line. ConfirmBooking is untouched — which is the entire promise of this lesson, demonstrated rather than asserted.

Production Project Structure

A professional Python application typically follows this structure:

project/
│
├── domain/                   # Core business logic
│   ├── entities/            # Business objects
│   │   ├── user.py
│   │   ├── order.py
│   │   └── product.py
│   ├── value_objects/       # Immutable values
│   │   ├── email.py
│   │   └── money.py
│   └── services/            # Domain services
│       └── pricing.py
│
├── application/             # Use cases / workflows
│   ├── use_cases/
│   │   ├── create_order.py
│   │   ├── pay_order.py
│   │   └── cancel_order.py
│   ├── dto/                 # Data transfer objects
│   │   └── order_dto.py
│   └── interfaces/          # Ports (abstractions)
│       ├── repositories.py
│       └── gateways.py
│
├── infrastructure/          # Technical implementations
│   ├── database/
│   │   ├── sql_repository.py
│   │   └── models.py       # ORM models
│   ├── cache/
│   │   └── redis_cache.py
│   ├── email/
│   │   └── smtp_sender.py
│   └── external/
│       └── payment_api.py
│
├── interface/               # Entry points
│   ├── api/                 # REST API
│   │   ├── routes.py
│   │   └── schemas.py
│   ├── cli/                 # Command line
│   │   └── commands.py
│   └── events/              # Event handlers
│       └── consumers.py
│
├── tests/
│   ├── unit/                # Domain & use case tests
│   ├── integration/         # Infrastructure tests
│   └── e2e/                 # Full system tests
│
└── config/                  # Configuration
    ├── settings.py
    └── container.py         # DI container

Testing Different Layers

Test TypeWhat It TestsSpeed
UnitDomain entities, value objects⚡ Milliseconds
IntegrationUse cases with fake repositories⚡ Milliseconds
InfrastructureAdapters with real DB/APIs🐢 Seconds
E2EFull system, HTTP → DB🐌 Minutes

Common Anti-Patterns

Anti-PatternThe ProblemThe Fix
Fat ControllerRoute handler does EVERYTHINGExtract logic to use cases
Anemic DomainEntities are just data bagsPut behavior where data is
DB-Driven DesignModels mirror DB tablesDesign domain first, map later
Framework-FirstLogic coupled to Django/FlaskCore logic is framework-agnostic
God ObjectOne class knows everythingBreak into focused modules

Refactoring Toward Clean Architecture

Follow these steps in order:

When to Use Clean Architecture

✓ Use Clean Architecture When:

⚠ Simple Structure Is Better For:

Choose architecture proportional to your project's expected lifespan and complexity.

Validating Your Architecture

Ask yourself these questions to evaluate your architecture:

Can I change the database without rewriting core logic?

✓ Good architecture isolates DB behind interfaces

Can I test workflows without starting servers or databases?

✓ Use cases should work with fake implementations

Does business logic import framework modules?

✗ Red flag - domain should be framework-agnostic

Is complexity growing linearly or exponentially?

✓ Good architecture scales linearly as features are added

Can I add a CLI interface without touching existing code?

✓ Multiple interfaces should share the same core logic

# Python Architecture Patterns Examples

# ============================================
# 1. DOMAIN ENTITY WITH BUSINESS RULES
# ============================================

from dataclasses import dataclass, field
from decimal import Decimal
from typing import List
from datetime import datetime

@dataclass
class OrderItem:
    """Pure domain entity - no dependencies"""
    product_id: str
    product_name: str
    quantity: int
    unit_price: Decimal
    
    def subtotal(self) -> Decimal:
        return self.unit_price * self.quantity


@dataclass
class Order:
    """Core business entity with rules"""
    id: str
    customer_id: str
    items: List[OrderItem] = field(default_factory=list)
    status: str = "pending"
    is_paid: bool = False
    
    def add_item(self, item: OrderItem):
        """Business rule: add item"""
        if self.status != "pending":
            raise ValueError("Cannot modify order after submission")
        self.items.append(item)
    
    def total_amount(self) -> Decimal:
        """Business rule: calculate total"""
        return sum(item.subtotal() for item in self.items)
    
    def submit(self):
        """Business rule: submit order"""
        if not self.items:
            raise ValueError("Cannot submit empty order")
        if self.status != "pending":
            raise ValueError("Order already submitted")
        self.status = "submitted"


# ============================================
# 2. REPOSITORY INTERFACE (PORT)
# ============================================

from typing import Protocol, Optional

class OrderRepository(Protocol):
    """Port - abstract interface"""
    def get(self, order_id: str) -> Optional[Order]:
        ...
    
    def save(self, order: Order) -> None:
        ...


# ============================================
# 3. USE CASE (APPLICATION LAYER)
# ============================================

class CreateOrderUseCase:
    """Use case - orchestrates domain logic"""
    
    def __init__(self, order_repository: OrderRepository):
        self.order_repository = order_repository
    
    def execute(self, customer_id: str, items_data: list) -> Order:
        order_id = f"ORD-{customer_id}-001"
        order = Order(id=order_id, customer_id=customer_id)
        
        for item_data in items_data:
            item = OrderItem(
                product_id=item_data["product_id"],
                product_name=item_data["product_name"],
                quantity=item_data["quantity"],
                unit_price=Decimal(str(item_data["price"]))
            )
            order.add_item(item)
        
        order.submit()
        self.order_repository.save(order)
        return order


# ============================================
# 4. FAKE REPOSITORY FOR TESTING
# ============================================

class FakeOrderRepository:
    """Adapter for testing"""
    def __init__(self):
        self.orders = {}
    
    def get(self, order_id: str):
        return self.orders.get(order_id)
    
    def save(self, order: Order):
        self.orders[order.id] = order
        print(f"Saved order: {order.id}")


# ============================================
# DEMO: RUN THE ARCHITECTURE
# ============================================

# Create fake repository (in production, use real DB)
repo = FakeOrderRepository()

# Create use case with injected dependency
use_case = CreateOrderUseCase(repo)

# Execute use case
items = [
    {"product_id": "P1", "product_name": "Widget", "quantity": 2, "price": "10.00"},
    {"product_id": "P2", "product_name": "Gadget", "quantity": 1, "price": "25.00"}
]

order = use_case.execute("CUST-001", items)

print(f"Order ID: {order.id}")
print(f"Status: {order.status}")
print(f"Total: $" + str(order.total_amount()))
print(f"Items: {len(order.items)}")

# Verify it was saved
saved_order = repo.get(order.id)
print(f"Order retrieved: {saved_order is not None}")

# ✅ Expected output:
# Saved order: ORD-CUST-001-001
# Order ID: ORD-CUST-001-001
# Status: submitted
# Total: $45.00
# Items: 2
# Order retrieved: True

Key Takeaways

📋 Quick Reference — Architecture Patterns

PatternWhat it achieves
MVCSeparates data, display, and logic
Clean ArchitectureDomain core independent of frameworks
Repository PatternAbstract data access behind an interface
Dependency InjectionDecouple components for testability
Event-Driven ArchitectureDecouple services via events/messages

🎉 Great work! You've completed this lesson.

You can now apply Clean Architecture, MVC, and the Repository Pattern to structure Python apps that scale across teams and years.

Practice quiz

What is the single core rule of Clean Architecture?

  • Dependencies point outward toward the database
  • Every layer can import every other layer
  • Dependencies point inward toward business rules, never outward
  • The UI controls the domain

Answer: Dependencies point inward toward business rules, never outward. Clean Architecture's central rule is that dependencies point inward; business logic never depends on frameworks, DBs, or UI.

In MVC, which component holds the business rules and domain entities?

  • Model
  • View
  • Controller
  • Router

Answer: Model. The Model represents data, validation, and business operations; Views present data and Controllers coordinate.

What should a View do in MVC?

  • Implement business rules
  • Talk directly to the database
  • Validate domain invariants
  • Stay thin — just format and present data

Answer: Stay thin — just format and present data. Views should be thin: they format and present data and leave business rules to the Model.

What does the Dependency Inversion Principle state?

  • High-level modules should import low-level modules directly
  • Both high- and low-level modules should depend on abstractions (interfaces)
  • Interfaces should depend on implementations
  • Avoid all abstractions

Answer: Both high- and low-level modules should depend on abstractions (interfaces). High-level and low-level modules both depend on abstractions, so concrete details become swappable.

In Hexagonal (Ports and Adapters) architecture, what is a 'port'?

  • An abstract interface defining what the application needs
  • A concrete database class
  • A network socket
  • A UI component

Answer: An abstract interface defining what the application needs. Ports are abstract interfaces (e.g. UserRepository, EmailSender) describing required capabilities; adapters implement them.

Which is an example of an 'adapter' for the EmailSender port?

  • EmailSender itself
  • The use case
  • SMTPEmailSender
  • The domain entity

Answer: SMTPEmailSender. Adapters are concrete implementations of ports, such as SMTPEmailSender implementing the EmailSender interface.

What is the recommended order of the four Clean Architecture layers from core outward?

  • Interface → Infrastructure → Application → Domain
  • Domain → Application → Infrastructure → Interface
  • Infrastructure → Domain → Interface → Application
  • Application → Domain → Interface → Infrastructure

Answer: Domain → Application → Infrastructure → Interface. The lesson orders them Domain (core) → Application (use cases) → Infrastructure (adapters) → Interface (entry points).

Why are fake repositories useful when testing use cases?

  • They run faster than real code but break easily
  • They are required for production
  • They replace the domain layer
  • They let you test workflows in milliseconds without a real database or network

Answer: They let you test workflows in milliseconds without a real database or network. Because use cases depend on interfaces, you can inject in-memory fakes and test logic without servers or DBs.

Which is described as an anti-pattern in the lesson?

  • Thin controllers that delegate to use cases
  • An 'anemic domain' where entities are just data bags with no behavior
  • Framework-agnostic core logic
  • Small, focused modules

Answer: An 'anemic domain' where entities are just data bags with no behavior. An anemic domain (entities as mere data bags) is an anti-pattern; behavior should live with the data.

When is a simple structure preferred over full Clean Architecture?

  • Large production apps with many developers
  • Apps needing multiple interfaces
  • Small personal projects, one-off scripts, and short-lived prototypes
  • Compliance-heavy systems

Answer: Small personal projects, one-off scripts, and short-lived prototypes. Clean Architecture adds complexity; for small scripts, MVPs, and prototypes a simple structure is the better fit.

Continue this course