CoursePython · Application Architecture and Production Readiness · part 71 of 79
Part 71 · Application Architecture and Production Readiness

Lesson 1: Architecture

5 min read·9 Sept 2026

The problem this lesson solves

Everything works. The recipe-extractor system retrieves, extracts, streams, retries, budgets, logs, and evaluates. It is also a single package where a service function imports a database driver, a route handler builds a prompt, and adding a second provider means editing four files that have nothing to do with providers.

The symptoms are recognisable. A new person cannot find where anything happens. Two features cannot be worked on in parallel without conflicts. A change to retrieval breaks the HTTP layer for reasons nobody can explain. And somewhere there is an import that only works because of the order modules happen to load.

None of that is a bug. It is the absence of structure, and this module is about putting it in place and then getting the result into production.

Layered design

Four layers, each depending only on the one beneath it.

text
API layer          routes, request and response models, middleware
      ↓
Service layer      use cases, orchestration, transactions
      ↓
AI pipeline        retrieval, prompting, extraction, agent loops
      ↓
Ports and adapters providers, repositories, caches

The API layer translates HTTP into domain calls and domain results into HTTP. It knows about status codes, headers, and serialisation, and it contains no business rules.

The service layer implements use cases. "Extract a recipe from this document" is a service operation: it decides what happens in what order, handles failure, and returns a domain result. It knows nothing about HTTP and nothing about SQL.

The AI pipeline holds the logic specific to working with models: chunking, retrieval, prompt assembly, output validation, and the agent loop. It receives its dependencies rather than constructing them.

Ports and adapters is Module 7's material. Ports are the Protocols, meaning LLMProvider, DocumentRepository, Retriever. Adapters are the implementations that talk to real systems.

Why four rather than three. The AI pipeline is separated from the service layer because it changes for different reasons. A new prompt strategy or a reranker is a pipeline change. A new use case is a service change. Keeping them apart means a retrieval experiment does not touch the code implementing your product's behaviour.

Four-layer architecture showing API, service, AI pipeline, and ports and adapters layers with dependencies pointing only downward, per-layer forbidden imports listed, upward imports crossed out, and a composition root wiring all layers at startup.

Honest dependency direction

The rule is that dependencies point inward and downward, toward things that change less often.

Business rules are the stable part of a system. HTTP frameworks, database drivers, and provider SDKs are the changeable part. If the stable part depends on the changeable part, every infrastructure change reaches into your rules, and you cannot test the rules without the infrastructure.

The mechanism is Module 7's Protocols. The service layer declares what it needs and the adapter satisfies it, so the dependency arrow points from the adapter toward the interface rather than from the service toward the SDK.

python
# src/recipe_extractor/services/extraction.py
from recipe_extractor.domain import Document, ExtractedRecipe
from recipe_extractor.ports import DocumentRepository, LLMProvider


class ExtractionService:
    def __init__(
        self,
        *,
        provider: LLMProvider,
        repository: DocumentRepository,
        budget: BudgetGuard,
    ) -> None:
        self._provider = provider
        self._repository = repository
        self._budget = budget

    async def extract(self, document_id: str) -> ExtractedRecipe:
        """Use case: extract structured data from a stored document."""
        document = await self._repository.get(document_id)
        if document is None:
            raise RetrievalError(f"document {document_id} not found")

        await self._budget.check(feature="extraction", estimated_cost=estimate(document))
        extraction = await extract_with_repair(document, provider=self._provider)
        await self._repository.save_extraction(extraction)
        return extraction

Nothing in that file knows about FastAPI, SQLAlchemy, or any provider. It can be tested with the in-memory repository and fake provider from Module 11 in microseconds, and the entire infrastructure can be replaced without editing it.

Business logic that imports neither HTTP nor SQL

Stating the rule is easy. Making it hold as a codebase grows needs enforcement.

Make it visible in the package layout, so a violation is obvious in review:

text
src/recipe_extractor/
├── domain/              # records and value types, imports nothing
│   ├── documents.py
│   ├── extraction.py
│   └── errors.py
├── ports/               # Protocols only, imports domain
│   ├── providers.py
│   ├── repositories.py
│   └── retrievers.py
├── pipeline/            # AI logic, imports domain and ports
│   ├── chunking.py
│   ├── retrieval.py
│   ├── prompts/
│   └── extraction.py
├── services/            # use cases, imports domain, ports, pipeline
│   └── extraction.py
├── adapters/            # the only layer importing drivers and SDKs
│   ├── providers/
│   ├── repositories/
│   └── retrievers/
├── api/                 # FastAPI, imports services and domain
│   ├── routes/
│   ├── models.py
│   └── errors.py
├── config.py
└── main.py              # composition root

Enforce it automatically, because a rule that depends on reviewer attention will be broken.

text
# pyproject.toml
[tool.ruff.lint.flake8-tidy-imports.banned-api]
"sqlalchemy".msg = "Import SQLAlchemy only in adapters."
"httpx".msg = "Import httpx only in adapters."

[tool.ruff.lint.per-file-ignores]
"src/recipe_extractor/adapters/*" = ["TID251"]

This turns an architectural principle into a lint failure, which is the difference between a convention and a constraint. Import-linting tools that enforce layer contracts more thoroughly also exist, and are worth adding once more than a few people work on the codebase.

The domain layer imports nothing of yours, which is the test that tells you the layering is real. If domain needs to import adapters, the dependency direction has inverted somewhere.

Package layout, import hygiene, and circular imports

A circular import occurs when two modules import each other, directly or through a chain. Python raises ImportError or produces a partially initialised module, and the error message rarely names the actual cycle.

python
# services/extraction.py
from recipe_extractor.services.reporting import build_report      # needs reporting

# services/reporting.py
from recipe_extractor.services.extraction import ExtractionService  # needs extraction

Three ways out, in order of preference.

Move the shared thing down. Usually the cycle exists because both modules need a type that belongs in a lower layer. Move it to domain and both import downward, which is the fix that also improves the design.

Depend on a Protocol rather than the concrete class. If reporting needs an extraction service, it can accept anything satisfying an interface defined in ports, and the concrete class is supplied at the composition root.

Import inside the function as a last resort. It works by deferring the import to call time, and it hides the cycle rather than removing it, so leave a comment saying why.

Habits that prevent cycles. Import modules rather than names where practical, so from recipe_extractor.pipeline import chunking then chunking.split(). Keep __init__.py files thin, since a package __init__ importing everything creates cycles that are hard to trace. And use from __future__ import annotations or quoted annotations for type-only imports, or better, put them behind if TYPE_CHECKING: so they cost nothing at runtime.

python
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from recipe_extractor.services.reporting import ReportBuilder


def summarize(builder: "ReportBuilder") -> str: ...

A cycle is a design signal. Two modules needing each other usually means they are one module, or that a third thing should be extracted from both. Fix the design rather than the import.