In a FastAPI or Flask app it is easy for route handlers to fill up with database queries, SDK calls, and business rules. That coupling makes tests slow and framework changes painful. A lightweight ports-and-adapters layout, sometimes called hexagonal architecture, keeps the core of the service independent of FastAPI, SQLAlchemy, and third-party APIs.
You do not need a large ceremony to get the benefit. The idea is practical: business rules live in plain Python, they talk to the outside world through small interfaces, and the web framework only translates HTTP into those calls.
The four parts
- Domain. Dataclasses, functions, and services that express the rules. They should not import FastAPI, SQLAlchemy, or a vendor SDK.
- Ports. Narrow interfaces for what the domain needs: a payment gateway, a repository, a mail sender. In modern Python,
typing.Protocolis usually enough. - Adapters. The concrete implementations: a Stripe client, a SQLAlchemy repository, an OpenAI client, a fake used in tests.
- API layer. FastAPI (or another framework) parses the request, calls the domain service, and returns an HTTP response.
The direction of dependence matters. The domain should not know about FastAPI. FastAPI may know about the domain. Adapters implement ports; the domain depends on the port, not the adapter.
A small checkout example
Suppose checkout must refuse non-positive amounts and then charge a payment provider. The domain should say that in plain language. The provider details stay in an adapter.
Define the port
from typing import Protocol
class PaymentGatewayPort(Protocol):
async def process_payment(self, amount_cents: int, token: str) -> bool:
"""Charge the customer. Return True when the payment is accepted."""
...
Keep the domain free of framework imports
class InvalidAmountError(ValueError):
pass
class PaymentFailedError(RuntimeError):
pass
class CheckoutService:
def __init__(self, payment_gateway: PaymentGatewayPort):
self.payment_gateway = payment_gateway
async def checkout(self, amount_cents: int, token: str) -> dict:
if amount_cents <= 0:
raise InvalidAmountError("Amount must be greater than zero")
success = await self.payment_gateway.process_payment(amount_cents, token)
if not success:
raise PaymentFailedError("Payment was declined")
return {"status": "success", "amount_cents": amount_cents}
Put vendor code in an adapter
class StripePaymentAdapter:
def __init__(self, api_key: str):
self.api_key = api_key
async def process_payment(self, amount_cents: int, token: str) -> bool:
# Talk to the Stripe SDK here. Keep Stripe types out of CheckoutService.
raise NotImplementedError("Wire the Stripe SDK in the adapter, not the domain")
The NotImplementedError is intentional in this sketch: the point is the boundary, not a full Stripe integration. In a real adapter you would call the SDK, translate its result into True or False (or a small domain result object), and map vendor exceptions at this edge. Tests can use a fake that implements the same port and returns known values.
Call it from FastAPI, not the other way around
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
app = FastAPI()
checkout_service = CheckoutService(payment_gateway=StripePaymentAdapter(api_key="..."))
class CheckoutRequest(BaseModel):
amount_cents: int = Field(gt=0)
token: str
@app.post("/checkout")
async def checkout(payload: CheckoutRequest):
try:
return await checkout_service.checkout(payload.amount_cents, payload.token)
except InvalidAmountError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except PaymentFailedError as exc:
raise HTTPException(status_code=402, detail=str(exc)) from exc
The routeโs job is HTTP: validation, status codes, and wiring. It should not contain the payment rule itself. In a larger app, build the service in a lifespan hook or a dependency, and read the API key from the environment rather than a string in the module.
Why this is worth it
You can unit-test CheckoutService with a fake gateway and no network, no database, and no FastAPI test client. You can replace Stripe later without rewriting checkout rules. You can also keep FastAPI-specific concerns โ status codes, headers, dependency injection โ at the edge, where they belong.
The same pattern works for repositories. A OrderRepositoryPort can expose get and save. SQLAlchemy lives in the adapter. The domain never writes a query. That is usually the first port worth adding, because database details spread faster than almost anything else.
How much architecture is enough
Do not invent a port for every function. Start where change or testing is expensive: payments, email, object storage, and persistence. A five-route service can use this layout in a few modules and stay readable. A folder named โdomainโ is not the goal. The goal is that business rules can be tested and moved without dragging a framework with them.
Avoid passing framework objects โ a FastAPI Request, a SQLAlchemy session, a Stripe client โ into the domain service. If the domain needs data, pass plain values or domain objects. If it needs a side effect, pass a port.
A repository port is usually next
Once checkout is isolated, persistence is the next leak to close. If the service also needs to store an order, give it a repository port instead of importing a model and a session into the domain:
from dataclasses import dataclass
from typing import Protocol
@dataclass
class Order:
id: str
amount_cents: int
status: str
class OrderRepositoryPort(Protocol):
async def save(self, order: Order) -> None:
...
async def get(self, order_id: str) -> Order | None:
...
The SQLAlchemy adapter would convert the dataclass to a model, commit, and convert back. The domain continues to work with Order. That keeps migrations, session scope, and query details at the edge. It also makes it obvious where transactions belong: in the adapter or in an application-level unit of work, not scattered through route handlers.
Testing without a framework
A fake adapter is often ten lines:
class FakePaymentGateway:
def __init__(self, accepted: bool = True):
self.accepted = accepted
self.calls: list[tuple[int, str]] = []
async def process_payment(self, amount_cents: int, token: str) -> bool:
self.calls.append((amount_cents, token))
return self.accepted
With that fake you can assert that a negative amount never reaches the gateway, and that a declined charge raises PaymentFailedError. Those tests run quickly and do not need a database container. Save slower tests for the adapters themselves, where you really do want to see SQLAlchemy or an HTTP client behave.
This is the practical payoff. The structure is not for its own sake. It exists so the rules you care about can be checked without standing up FastAPI, Postgres, and a vendor sandbox every time.
Common mistakes
- Ports that mirror the vendor API. If your port has Stripe-shaped arguments, you have not decoupled anything. Name the port in product language: amount, currency, payment token, result.
- Anemic services that only pass data through. The domain should hold rules. If the service is a thin forwarder, the rules are still hiding in the adapter or the route.
- One โunit of workโ object that everything depends on. Start with explicit ports. You can add a unit of work later if several repositories must share a transaction.
- Framework types in the domain. A
Request, an ORM model, or a Pydantic model in the core will drag the framework back in. Convert at the edge.
Summary
A light ports-and-adapters layout keeps Python business rules independent of FastAPI and third-party APIs. Define a port, implement an adapter, and let the API layer only translate HTTP. That is enough to make the core testable and to change vendors or frameworks later without rewriting the productโs rules.
If you want to untangle a Python service whose routes, queries, and SDK calls have grown together, get in touch. I can help you introduce a clean service layer without turning the project into an architecture exercise.