Home / Blog / Engineering
Engineering

Folder Structure in Practice: One Feature, Five Architectures (Python)

Building the same Orders feature using Simple MVC, Layered, Feature-Based, Clean Architecture, and Microservices — with testing strategies for each, using real code examples in Python and FastAPI.

Yudi Nugraha
May 3, 2026
13 min read

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 order
  • GET /orders/{id} — fetch order details
  • Validation: product stock must be available before the order is created
  • Notification: send a confirmation email after the order succeeds
  • This 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:

    NeedLibrary
    HTTP frameworkFastAPI
    ORMSQLAlchemy (async)
    Unit testpytest + anyio
    Integration testhttpx AsyncClient
    Mockingunittest.mock.AsyncMock
    Contract testingpact-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

    AspectSimple MVCLayeredFeature-BasedClean ArchModular MonolithMicroservices
    Files for Orders feature2468+6–8 per moduleper-service
    Unit test business logicNot possibleYesYesVery easyEasy (per module)Yes per service
    Tests require a database?Yes (always)NoNoNoNoNo per unit
    Contract testing needed?NoNoNoNoNoYes (pact-python)
    Test suite speedSlowMediumMediumFastMediumMedium
    Swap databaseChange everythingChange repositoryChange repositoryChange 1 fileChange 1 file per modulePer service
    Onboarding a new developerFastFastMediumSlowMediumSlow
    ---

    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.

    Tags

    Software EngineeringArchitecturePythonFastAPITesting
    Y

    Yudi Nugraha

    Software Engineer | Builder

    More Articles

    Explore more articles on similar topics

    View All Articles