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

Lesson 1: Architecture

7 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 LLMProviderDocumentRepositoryRetriever. 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.

[IMAGE PROMPT M12-1
Purpose: Show the four layers, the direction dependencies are allowed to point, and which imports are forbidden in each layer.
Visual type: Layered architecture diagram with allowed and forbidden import annotations.
Prompt: A clean educational diagram with four horizontal layer bands stacked vertically, each drawn as a wide rounded rectangle. From top to bottom the bands read "API layer" with the sub-label "routes, request and response models, middleware", "Service layer" with "use cases, orchestration, transactions", "AI pipeline" with "retrieval, prompting, validation, agent loop", and "Ports and adapters" with "LLMProvider, DocumentRepository, Retriever". Single downward arrows connect each band to the one below, each labelled "depends on". On the right of each band sits a small box listing forbidden imports: beside API "no SQL, no provider SDK", beside Service "no HTTP, no SQL", beside AI pipeline "no HTTP, no SQL", beside Ports and adapters "the only layer that imports drivers and SDKs". A single upward arrow is drawn on the left crossing all bands with a large cross through it, labelled "no upward imports, ever". Beneath the stack, a separate small box labelled "composition root" has dashed arrows to every layer, annotated "wires everything once, at startup".
Required elements: Four correctly ordered and labelled bands with sub-labels, downward dependency arrows, per-layer forbidden-import boxes, a crossed-out upward arrow, a composition root box with dashed arrows to all layers.
Style: Clean educational illustration, professional, uncluttered, high contrast, flat vector, generous whitespace.
Layout: Vertical stack centred, forbidden-import boxes on the right, crossed upward arrow on the left, composition root beneath.
Text labels: "API layer", "Service layer", "AI pipeline", "Ports and adapters", "routes, request and response models, middleware", "use cases, orchestration, transactions", "retrieval, prompting, validation, agent loop", "LLMProvider, DocumentRepository, Retriever", "depends on", "no SQL, no provider SDK", "no HTTP, no SQL", "the only layer that imports drivers and SDKs", "no upward imports, ever", "composition root", "wires everything once, at startup".
Aspect ratio: 4:3
Accessibility: State every constraint in text rather than relying on arrow colour, and mark the forbidden direction with a cross as well as a label.
Avoid: UML formality, vendor logos, screenshots, tiny text, watermarks.
Alt text: 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.
END IMAGE PROMPT]

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"]

[VOLATILE: rule codes and the configuration key for banned imports may change. Verify against the installed ruff version.]

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

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.