In the previous post, we talked about how folder structure evolved from Simple MVC to Microservices — and why each approach emerged to solve the problems of the one before it.
This time we go somewhere more concrete: what does the code actually look like?
The approach: we build the same feature using all five architectures, then examine how each one is tested. That way, the differences can be compared directly — apples-to-apples.
---
The Scenario: Orders Feature
The feature we build across all architectures:
POST /orders — create a new orderGET /orders/{id} — fetch order detailsThis feature was chosen because it involves more than one layer — HTTP, business logic, database, and an external service. Real enough to show architectural differences, but not so complex it obscures the point.
Tech stack used across all examples:
| Need | Library |
|---|---|
| HTTP framework | FastAPI |
| ORM | SQLAlchemy (async) |
| Unit test | pytest + anyio |
| Integration test | httpx AsyncClient |
| Mocking | unittest.mock.AsyncMock |
| Contract testing | pact-python |
Architecture 1: Simple MVC
Folder Structure
/controllers
order_controller.py
/models
order.py
Implementation
In Simple MVC, all logic lives inside a single route function. The controller directly validates stock, saves the order via SQLAlchemy, and sends an email — all in one block.
# controllers/order_controller.py
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import get_session
from app.email import send_email
from app.models.order import Order, Product
from pydantic import BaseModel
router = APIRouter()
class CreateOrderRequest(BaseModel):
product_id: str
quantity: int
customer_email: str
@router.post("/orders", status_code=201)
async def create_order(
request: CreateOrderRequest,
session: AsyncSession = Depends(get_session),
):
product = await session.get(Product, request.product_id)
if product is None or product.stock < request.quantity:
raise HTTPException(status_code=400, detail="Insufficient stock")
order = Order(
product_id=request.product_id,
quantity=request.quantity,
)
session.add(order)
await session.commit()
await session.refresh(order)
await send_email(
to=request.customer_email,
subject=f"Order {order.id} confirmed",
)
return order
The code is readable and fast to write. But every time new logic is needed — discounts, fraud checks, audit logs — it all goes into the same function. Within a few sprints, this handler can grow to hundreds of lines.
Testing Simple MVC
Because logic and infrastructure are mixed in one function, pure unit testing is not possible. The only option is integration testing: spin up the entire application and hit the endpoint.
In FastAPI, this is done with httpx.AsyncClient and ASGITransport:
# test_order_controller.py
import pytest
from httpx import AsyncClient, ASGITransport
from app.main import app
@pytest.mark.anyio
async def test_create_order_returns_201():
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.post("/orders", json={
"product_id": "prod-1",
"quantity": 2,
"customer_email": "user@test.com",
})
assert response.status_code == 201
assert "id" in response.json()
@pytest.mark.anyio
async def test_create_order_returns_400_when_stock_empty():
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.post("/orders", json={
"product_id": "prod-no-stock",
"quantity": 2,
"customer_email": "user@test.com",
})
assert response.status_code == 400
Every test requires a pre-seeded database. Tests are slow because they go through the full HTTP stack. Testing edge cases requires elaborate data setup in every test.
Best fit for: prototypes, small internal tools, validating ideas in 1–2 weeks.
---
Architecture 2: Layered Architecture
Folder Structure
/controllers
order_controller.py
/services
order_service.py
/repositories
order_repository.py
/models
order.py
Implementation
Business logic moves to OrderService. The controller's only job is to receive the request and delegate.
# services/order_service.py
from app.repositories.order_repository import OrderRepository
from app.email import EmailSender
class OrderService:
def __init__(self, repo: OrderRepository, email: EmailSender):
self._repo = repo
self._email = email
async def create_order(
self, product_id: str, quantity: int, customer_email: str
) -> dict:
product = await self._repo.find_product_by_id(product_id)
if product is None or product["stock"] < quantity:
raise ValueError("Insufficient stock")
order = await self._repo.save({"product_id": product_id, "quantity": quantity})
await self._email.send(
to=customer_email,
subject=f"Order {order['id']} confirmed",
)
return order
# controllers/order_controller.py
@router.post("/orders", status_code=201)
async def create_order(
request: CreateOrderRequest,
service: OrderService = Depends(get_order_service),
):
return await service.create_order(
product_id=request.product_id,
quantity=request.quantity,
customer_email=request.customer_email,
)
Testing Layered Architecture
Now OrderService can be tested in isolation by mocking OrderRepository with AsyncMock — no database needed:
# test_order_service.py
import pytest
from unittest.mock import AsyncMock
from app.services.order_service import OrderService
@pytest.fixture
def mock_repo():
return AsyncMock()
@pytest.fixture
def mock_email():
return AsyncMock()
@pytest.fixture
def service(mock_repo, mock_email):
return OrderService(repo=mock_repo, email=mock_email)
@pytest.mark.anyio
async def test_create_order_raises_when_stock_insufficient(service, mock_repo):
mock_repo.find_product_by_id.return_value = {"stock": 0}
with pytest.raises(ValueError, match="Insufficient stock"):
await service.create_order("prod-1", quantity=2, customer_email="u@test.com")
@pytest.mark.anyio
async def test_create_order_sends_email_after_success(service, mock_repo, mock_email):
mock_repo.find_product_by_id.return_value = {"stock": 10}
mock_repo.save.return_value = {"id": "order-1", "product_id": "prod-1"}
await service.create_order("prod-1", quantity=2, customer_email="u@test.com")
mock_email.send.assert_called_once_with(
to="u@test.com", subject="Order order-1 confirmed"
)
Tests run fast because they never touch the database or HTTP stack. Different scenarios can be tested simply by changing the mock return value.
Best fit for: REST APIs with 5–15 endpoints, teams of 2–5 developers.
---
Architecture 3: Feature-Based Structure
Folder Structure
/features
/orders
router.py
service.py
repository.py
schemas.py
models.py
test_service.py ← test lives inside the feature folder
/notifications
service.py
schemas.py
Implementation
Same structure as Layered, but organized around business features. Everything related to Orders lives in one folder — including the tests.
service.py uses NotificationService as a separate abstraction instead of directly calling send_email:
# features/orders/service.py
from features.notifications.service import NotificationService
from .repository import OrderRepository
class OrderService:
def __init__(self, repo: OrderRepository, notifier: NotificationService):
self._repo = repo
self._notifier = notifier
async def create_order(self, product_id: str, quantity: int) -> dict:
product = await self._repo.find_product_by_id(product_id)
if product is None or product["stock"] < quantity:
raise ValueError("Insufficient stock")
order = await self._repo.save({"product_id": product_id, "quantity": quantity})
await self._notifier.send_order_confirmation(order["id"])
return order
# features/orders/router.py
from fastapi import APIRouter, Depends
from .service import OrderService
from .schemas import CreateOrderRequest
router = APIRouter(prefix="/orders")
@router.post("", status_code=201)
async def create_order(
request: CreateOrderRequest,
service: OrderService = Depends(),
):
return await service.create_order(request.product_id, request.quantity)
Testing Feature-Based Structure
The test file lives inside features/orders/test_service.py — not in a separate tests/ directory. pytest fixtures are defined in the same file, so all context is readable in one place.
# features/orders/test_service.py
import pytest
from unittest.mock import AsyncMock
from .service import OrderService
@pytest.fixture
def mock_repo():
return AsyncMock()
@pytest.fixture
def mock_notifier():
return AsyncMock()
@pytest.fixture
def service(mock_repo, mock_notifier):
return OrderService(repo=mock_repo, notifier=mock_notifier)
@pytest.mark.anyio
async def test_create_order_saves_and_notifies(service, mock_repo, mock_notifier):
mock_repo.find_product_by_id.return_value = {"stock": 10}
mock_repo.save.return_value = {"id": "order-1", "product_id": "prod-1"}
await service.create_order(product_id="prod-1", quantity=2)
mock_repo.save.assert_called_once()
mock_notifier.send_order_confirmation.assert_called_once_with("order-1")
@pytest.mark.anyio
async def test_create_order_raises_when_stock_zero(service, mock_repo):
mock_repo.find_product_by_id.return_value = {"stock": 0}
with pytest.raises(ValueError, match="Insufficient stock"):
await service.create_order(product_id="prod-1", quantity=1)
A new developer joining the Orders feature finds code and tests in the same folder. No need to switch between src/ and tests/.
Best fit for: SaaS products, teams of 5–20 developers, applications with a growing number of features.
---
Architecture 4: Clean Architecture
Folder Structure
/src
/domain
/orders
order.py ← pure entity (dataclass)
order_repository.py ← ABC as port
exceptions.py
/application
/orders
create_order_use_case.py
test_create_order_use_case.py
/infrastructure
/database
sqlalchemy_order_repository.py
test_sqlalchemy_order_repository.py
/email
sendgrid_notification_adapter.py
/presentation
/api
order_router.py
Implementation
In Clean Architecture, the domain layer must not know about SQLAlchemy, FastAPI, or SendGrid. Order is a pure Python dataclass. OrderRepository is an Abstract Base Class that defines the contract — not the implementation.
# domain/orders/order.py
from dataclasses import dataclass, field
from uuid import uuid4
from .exceptions import DomainException
@dataclass
class Order:
product_id: str
quantity: int
id: str = field(default_factory=lambda: str(uuid4()))
def __post_init__(self):
if self.quantity <= 0:
raise DomainException("Quantity must be greater than zero")
# domain/orders/order_repository.py
from abc import ABC, abstractmethod
from .order import Order
class OrderRepository(ABC):
@abstractmethod
async def find_product_by_id(self, product_id: str) -> dict | None:
...
@abstractmethod
async def save(self, order: Order) -> Order:
...
# application/orders/create_order_use_case.py
from domain.orders.order import Order
from domain.orders.order_repository import OrderRepository
from domain.orders.exceptions import DomainException
class NotificationPort(ABC):
@abstractmethod
async def notify(self, order_id: str) -> None:
...
class CreateOrderUseCase:
def __init__(self, repo: OrderRepository, notifier: NotificationPort):
self._repo = repo
self._notifier = notifier
async def execute(self, product_id: str, quantity: int) -> Order:
product = await self._repo.find_product_by_id(product_id)
if product is None:
raise DomainException("Product not found")
if product["stock"] < quantity:
raise DomainException("Insufficient stock")
order = Order(product_id=product_id, quantity=quantity)
saved = await self._repo.save(order)
await self._notifier.notify(saved.id)
return saved
CreateOrderUseCase has no idea whether we're using SQLAlchemy, Tortoise ORM, or any other database. It only knows OrderRepository — an abstract contract.
Testing Clean Architecture
There are three distinct test layers, each with a different purpose and speed.
Domain Entity — pure unit test, zero dependencies
# domain/orders/test_order.py
import pytest
from domain.orders.order import Order
from domain.orders.exceptions import DomainException
def test_order_created_successfully():
order = Order(product_id="prod-1", quantity=3)
assert order.product_id == "prod-1"
assert order.quantity == 3
assert order.id is not None
def test_order_raises_when_quantity_zero():
with pytest.raises(DomainException, match="Quantity must be greater than zero"):
Order(product_id="prod-1", quantity=0)
No AsyncMock, no database fixtures. This is pure Python — runs in milliseconds and is never flaky.
Application Use Case — mock port via AsyncMock
# application/orders/test_create_order_use_case.py
import pytest
from unittest.mock import AsyncMock
from domain.orders.order import Order
from application.orders.create_order_use_case import CreateOrderUseCase
@pytest.fixture
def mock_repo():
repo = AsyncMock()
repo.save.side_effect = lambda order: order
return repo
@pytest.fixture
def mock_notifier():
return AsyncMock()
@pytest.fixture
def use_case(mock_repo, mock_notifier):
return CreateOrderUseCase(repo=mock_repo, notifier=mock_notifier)
@pytest.mark.anyio
async def test_execute_saves_order_and_notifies(use_case, mock_repo, mock_notifier):
mock_repo.find_product_by_id.return_value = {"stock": 10}
order = await use_case.execute(product_id="prod-1", quantity=2)
mock_repo.save.assert_called_once()
mock_notifier.notify.assert_called_once_with(order.id)
@pytest.mark.anyio
async def test_execute_raises_when_stock_insufficient(use_case, mock_repo):
mock_repo.find_product_by_id.return_value = {"stock": 1}
with pytest.raises(Exception, match="Insufficient stock"):
await use_case.execute(product_id="prod-1", quantity=5)
The use case is tested without a database or HTTP stack. AsyncMock only needs to mock the ABC — not the concrete implementation.
Infrastructure — integration test via SQLite in-memory
# infrastructure/database/test_sqlalchemy_order_repository.py
import pytest
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from infrastructure.database.sqlalchemy_order_repository import SqlAlchemyOrderRepository
from domain.orders.order import Order
@pytest.fixture
async def db_session():
engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session:
yield session
@pytest.mark.anyio
async def test_save_persists_order_to_database(db_session):
repo = SqlAlchemyOrderRepository(session=db_session)
order = Order(product_id="prod-1", quantity=2)
saved = await repo.save(order)
assert saved.id is not None
assert saved.product_id == "prod-1"
This is the only test that touches a database — and only SQLite in-memory at that. In the CI pipeline, it can be separated from unit tests using pytest -m integration.
Key advantage: domain entity and use case can be tested 100% without a database, without HTTP, without any external library. Switch SQLAlchemy for Tortoise ORM? Only sqlalchemy_order_repository.py changes.
Best fit for: fintech, healthcare, enterprise, systems expected to evolve over 5–10 years.
---
Architecture 5: Microservices
System Structure
In microservices, the Orders feature is no longer a single folder — it becomes several independent services running in separate processes and containers:
/order-service ← FastAPI app
/notification-service ← FastAPI + Celery worker
/product-service ← FastAPI app
/api-gateway ← nginx / Traefik
Flow between services:
Client
│
▼
[api-gateway]
│
▼
[order-service] ──── HTTP GET ────► [product-service] (check stock)
│
│ publish task / event
▼
[RabbitMQ / Redis] ◄── subscribe ── [notification-service]
order-service never directly calls notification-service. It only publishes a task to Celery or an event to RabbitMQ. notification-service subscribes and reacts asynchronously.
# order-service: after the order is saved
from celery_app import celery
celery.send_task(
"notification.send_order_confirmation",
kwargs={"order_id": order.id, "customer_email": dto.customer_email},
)
Testing Microservices
Unit test per service
Identical to Layered or Clean Architecture internally. Each service is tested on its own using AsyncMock.
Contract testing with pact-python
A new problem emerges: how do we ensure the payload published by order-service matches what notification-service expects? They run in separate processes.
The answer is contract testing:
# order-service/test_notification_contract.py
import pytest
from pact import Consumer, Provider
pact = Consumer("order-service").has_pact_with(
Provider("notification-service"),
pact_dir="./pacts",
host_name="localhost",
port=1234,
)
def test_order_service_sends_correct_payload():
expected_body = {"order_id": "order-123", "customer_email": "user@test.com"}
(pact
.given("notification service is running")
.upon_receiving("an order confirmation request")
.with_request(method="POST", path="/notify", body=expected_body)
.will_respond_with(status=200))
with pact:
import requests
requests.post(
f"http://localhost:1234/notify",
json=expected_body,
)
This contract is generated as a JSON file and published to a Pact Broker. notification-service then verifies it can fulfill the contract — without order-service needing to run alongside it.
E2E test via Docker Compose
For end-to-end verification, run all services with docker-compose up and hit the endpoint from outside:
# e2e/test_create_order_flow.py
@pytest.mark.anyio
async def test_full_order_flow():
async with AsyncClient(base_url="http://localhost:8000") as client:
response = await client.post("/orders", json={
"product_id": "prod-1",
"quantity": 2,
"customer_email": "user@test.com",
})
assert response.status_code == 201
Without contract testing, any change to the event schema in order-service can silently break notification-service — and no one finds out until production.
Best fit for: teams of 50+ developers, platforms with uneven traffic, systems that must scale independently per feature.
---
Side-by-Side Comparison
| Aspect | Simple MVC | Layered | Feature-Based | Clean Arch | Modular Monolith | Microservices |
|---|---|---|---|---|---|---|
| Files for Orders feature | 2 | 4 | 6 | 8+ | 6–8 per module | per-service |
| Unit test business logic | Not possible | Yes | Yes | Very easy | Easy (per module) | Yes per service |
| Tests require a database? | Yes (always) | No | No | No | No | No per unit |
| Contract testing needed? | No | No | No | No | No | Yes (pact-python) |
| Test suite speed | Slow | Medium | Medium | Fast | Medium | Medium |
| Swap database | Change everything | Change repository | Change repository | Change 1 file | Change 1 file per module | Per service |
| Onboarding a new developer | Fast | Fast | Medium | Slow | Medium | Slow |
Conclusion
Every architecture can be tested. But how easy — and how meaningful — that testing is varies enormously.
Simple MVC works when speed is the priority and the system won't grow much. httpx.AsyncClient is sufficient for testing at that scale.
Layered and Feature-Based are the sweet spot for most Python applications. pytest fixtures make mocking clean and composable. Feature-Based adds the benefit of tests living right next to the code they verify, with full context in one file.
Clean Architecture is an investment — it requires more files and more discipline. But the domain entity and use case can be tested as pure Python, without SQLAlchemy, without FastAPI, without anything. That value compounds over time as business rules grow more complex.
Modular Monolith sits exactly between Feature-Based and Microservices — a single deployment unit, but with module boundaries enforced by tooling rather than convention. In Python, the combination of __init__.py and import-linter provides effective enforcement. Read more in the dedicated Modular Monolith post.
Microservices is no longer about folders — it's about systems. It introduces a new need — contract testing — that doesn't exist in any monolithic architecture. pact-python is the tooling to learn when teams start splitting the system.
Choose the architecture whose testing can be kept consistent by the team you have right now. Not the most sophisticated one — the most maintainable one.
---
Closing Thoughts
The right question isn't:
> "Which is the best architecture?"
It's:
> "Which architecture is easiest to test and maintain by my team, given the complexity we're facing right now?"
Code that can be tested is code that can be trusted. And code that can be trusted is the foundation of a system that can grow.