the easiest part of Python backend architecture is drawing the folder tree.
api/, services/, repositories/, schemas/ and core/ look clean on a diagram. they give everything a name and make a new project feel organised before it has done any real work.
but a folder tree cannot show the part that decides whether the project will survive change.
you can create services/, repositories/ and schemas/ and still end up with a mess. a service function can accept a FastAPI request, query SQLAlchemy directly, read environment variables and return an HTTP response. the files may be in the "correct" folders while the boundaries are not real.
the folder tree is a map.
the architecture is the rule that decides which direction the roads are allowed to run.
what i want from a backend structure
when i call a Python backend maintainable, i mean a few practical things:
- i can test a business rule without starting the web server;
- an HTTP route does not decide business policy;
- changing a database query does not change the API contract by accident;
- a background worker can run the same use case as an endpoint;
- external services fail at a boundary instead of leaking their weirdness everywhere;
- the code makes invalid state difficult to create;
- i can find where a decision belongs without searching the entire repository.
that is more useful to me than whether the project has seven top-level packages or twelve.
start with the dependency direction
the simplest model i keep returning to is this:
textHTTP / CLI / worker | v application use cases | v domain rules and ports ^ | database / email / queues / external APIs
the outer parts know how the world works. they understand HTTP headers, SQL rows, provider payloads and queue messages.
the inner parts know what the application means. they understand things like creating an account, reserving inventory, approving a payout or recording a webhook exactly once.
dependencies should point inward. the use case can say it needs a user repository. it should not need to know that the repository is backed by PostgreSQL through SQLAlchemy.
Python's Protocol is useful here because the boundary can stay small:
pythonfrom typing import Protocol class UserRepository(Protocol): async def find_by_email(self, email: str) -> class="syntax-string">"User | None": ... async def add(self, user: class="syntax-string">"User") -> None: ... class PasswordHasher(Protocol): def hash(self, value: str) -> str: ...
the application service depends on those capabilities, not their implementations:
pythonclass RegisterUser: def __init__(self, users: UserRepository, passwords: PasswordHasher): self.users = users self.passwords = passwords async def execute(self, email: str, password: str) -> class="syntax-string">"User": if await self.users.find_by_email(email): raise EmailAlreadyRegistered(email) user = User.register( email=email, password_hash=self.passwords.hash(password), ) await self.users.add(user) return user
now the route has very little to invent:
python@router.post(class="syntax-string">"/users", status_code=class="syntax-number">201) async def register_user( payload: RegisterUserRequest, use_case: RegisterUser = Depends(get_register_user), ) -> UserResponse: user = await use_case.execute(payload.email, payload.password) return UserResponse.from_domain(user)
the route translates HTTP input, calls one use case and translates the result. that is enough work for a route.
organise by feature once the project starts growing
a purely layered structure is easy to understand at first:
textapp/ api/ models/ schemas/ services/ repositories/
the problem appears when each directory contains forty unrelated features. changing account recovery means jumping through five folders and trying to remember which user_service.py owns the rule.
for a backend with several real domains, i prefer a feature-first shape with layers inside the feature:
textapp/ main.py core/ config.py errors.py logging.py users/ api.py schemas.py domain.py service.py repository.py billing/ api.py schemas.py domain.py service.py repository.py infrastructure/ database.py email.py shared/ clock.py ids.py tests/ unit/ integration/ journeys/
this is not a law. a small application with four endpoints may be clearer with four modules and no package maze.
the point is to let the structure reveal the things the product does. users and billing tell me more than a global services directory with thirty files in it.
keep three models separate in your head
one of the easiest ways to couple a backend is to use one class for everything.
the database model becomes the request body. then it becomes the response body. soon a migration changes a public API, or an internal field appears in JSON because it happened to exist on the ORM object.
i find it safer to think in three shapes:
- transport models describe what crosses the API boundary;
- domain models describe valid business state and behaviour;
- persistence models describe how data is stored.
sometimes two of these shapes can be the same without causing trouble. that is fine. separation does not mean duplicating every field ceremonially.
it means the application should not be forced to keep them identical when their responsibilities diverge.
an API may accept password, the domain may immediately turn it into an operation rather than stored state, and the database may only know password_hash. pretending those are one model does not make the system simpler. it only hides the translation.
repositories are useful, but not magical
i like repositories when they express domain questions:
pythonawait users.find_by_email(email) await invoices.list_overdue(as_of=clock.now()) await webhook_inbox.claim_batch(limit=class="syntax-number">100)
i do not get much value from wrapping every ORM call with methods like get_all, save, update and delete only because a diagram said repositories are required.
a repository should protect the application from persistence details and give important queries a home. it should not become a second, less capable ORM.
and no, i would not design around the fantasy that PostgreSQL might casually become MongoDB next Tuesday. database replacement is rarely cheap. repositories are still valuable because they isolate query behaviour, transaction boundaries and test seams—not because storage engines are interchangeable batteries.
transactions belong to the use case
the blueprint gets more interesting when one action performs several writes.
imagine creating an order, reserving inventory and recording an event for a worker. if each repository commits independently, the system can save the order and fail before the reservation. every individual function worked. the use case did not.
this is why i like an explicit unit-of-work boundary:
pythonasync with unit_of_work: order = await create_order.execute(command) await unit_of_work.events.add(OrderCreated.from_order(order)) await unit_of_work.commit()
the transaction follows the business operation, not whichever repository happened to run first.
for messages that must leave the process, a transactional outbox can persist the event in the same database transaction. a worker publishes it later. this is less exciting than calling a queue directly, but it avoids the lovely situation where the database committed and the process died one line before publishing the message.
workers are delivery mechanisms too
HTTP is not the application.
a command may arrive from a FastAPI route today, a scheduled job tomorrow and a queue consumer next month. if the use case lives inside the route, every new entrypoint either imports web code or reimplements the rules.
the worker should do what the route does:
textdecode input -> validate it -> call a use case -> translate the outcome
this also makes idempotency easier to see. a webhook consumer or queue worker should assume delivery can happen more than once. the protection belongs near the use case and persistence boundary, not in the hope that the provider behaves perfectly.
configuration is a boundary, not a global variable
configuration deserves one typed loading path.
read environment variables at startup. validate them. fail with a useful message if something required is missing. pass settings or configured adapters into the parts that need them.
what i try to avoid is calling os.getenv() from random business functions. that turns process state into a hidden dependency and makes tests quietly inherit whatever happens to be on the machine.
secrets should also remain secrets. they belong in the deployment environment or a secret manager, not in a settings file committed because it was convenient during development.
tests should follow the boundaries
a useful architecture makes several kinds of tests natural.
unit tests exercise domain rules and application services with small fakes. they should be fast and should not need FastAPI or PostgreSQL to prove that a duplicate email is rejected.
integration tests exercise the adapters we do not own: repository queries, migrations, serializers, provider clients and queue behaviour.
journey tests prove that the assembled system can perform the actions users actually depend on.
i do not think every method deserves a mock and a test. i care more about failure boundaries:
- can the same command safely run twice?
- what happens after a partial failure?
- does a transaction roll back correctly?
- does an expired credential become the right application error?
- does the response accidentally expose internal state?
- can the migration run against a realistic database?
the architecture is earning its keep when these questions are easy to ask in code.
utils.py is usually a warning
every backend eventually wants a utils.py.
some utilities are genuinely shared: parsing an ISO timestamp, generating an identifier or normalising an email address.
but utils.py often becomes a waiting room for decisions nobody has classified yet. password hashing, invoice calculations, provider retries and authentication helpers end up beside each other because they are all "reusable."
when a helper starts owning policy, state or an external dependency, i move it closer to the feature or promote it into a named service. names create pressure to explain responsibility. utils removes that pressure.
do not install the architecture before the problem exists
there is another failure mode here: building the perfect enterprise skeleton for an application that has two endpoints.
you can create interfaces for every class, factories for every interface and adapters for services that will never change. the project becomes technically organised and emotionally exhausting.
my rule is simple: start with clear modules, then extract a boundary when the code gives you a reason.
good reasons include:
- business rules are trapped inside a framework handler;
- the same operation needs a second entrypoint;
- tests require too much infrastructure;
- an external provider's types are spreading through the codebase;
- a transaction crosses several repositories;
- one feature changes for reasons unrelated to another.
"clean architecture says so" is not enough by itself.
the blueprint i would actually keep
if i had to reduce all of this to a short checklist, it would be this:
- keep delivery code thin;
- name application use cases explicitly;
- keep domain rules independent of frameworks and providers;
- translate at boundaries instead of leaking foreign models inward;
- let transactions follow business operations;
- make duplicate delivery and partial failure normal design cases;
- test rules, adapters and real journeys at different levels;
- organise around features when global layers stop being easy to navigate;
- add abstractions because they remove a real coupling, not because the folder looks professional;
- let the project become more structured as its responsibility grows.
build with clarity.
the goal is clarity, not ceremony.
i would only add this: architecture is not proven by opening the repository and seeing the expected directory names.
it is proven when a requirement changes and the change has somewhere honest to go.