slowlp
← Blog
Method 2026.06.22 · 8 min read

Your Folder Structure Is Your Design

Why I organize by domain instead of function, and what changes when you do

Method

The Folder Tree Is the Design: A Domain-Based Project Structure

Why I split folders by domain instead of by function

When I open a new backend project, the first thing I look at isn’t the code — it’s the folder tree. Which folder sits where, what’s placed next to what. To me, that tree already tells me roughly how the project was thought through. A folder structure isn’t just tidying up; it’s a design decision that’s already been made.

The structure I use is domain-based and modular. Instead of splitting folders by kind of thing (model, schema, service…), I split first by business domain (things like user, order, account). And on top of that, I separate the concerns of the web server (FastAPI) from the background worker (Celery).

Everything for one domain lives together

Under app/domain/modules/ there’s a folder per domain. A single folder like order/, for example, holds everything that belongs to that domain.

order/
  order_model.py       # SQLModel table definition
  order_schema.py      # API request/response schemas
  order_repository.py  # DB access logic
  order_router.py      # FastAPI router
  order_service.py     # business logic
  order_tasks.py       # Celery background tasks

This can look a little odd at first. Most beginner tutorials teach you to gather every model in a models/ folder and every router in a routers/ folder. But do that, and fixing one part of the order feature means hopping back and forth across five folders. Group by domain, and “everything about orders” sits in one place. Adding a feature just means adding one more folder.

I keep the rules loose. A file’s prefix doesn’t have to match the folder name exactly. The user domain folder can be member/ while the files inside start with user_*.

I didn’t insist on a service in every domain. My rule for creating one was simple: I add a service when the same logic is needed in two or more places. Login and logout, for instance, are needed both on the admin side and on the regular app side — so instead of copy-pasting the same code into both, I fold it into a single service that both call. I also reached for a service when a transaction needed to stretch across several steps. Conversely, for a simple lookup that’s used in just one place and done, I didn’t bother — the router or task can hit the repository directly. And when one service’s responsibilities grew too big, I split it back out by purpose, into things like cost_basis_service.py and external_trade_service.py.

Pull the shared stuff out

Some things repeat across every domain: base classes for models, environment-variable settings, exception definitions, common utilities. Copy-paste those into each domain and they drift out of sync fast. So I collect them in a single domain/common/.

common/
  base/       # BaseSqlModel, BaseSchema, BaseRepository
  config/     # environment variables, logging setup
  exception/  # AppException and per-HTTP-status exceptions
  schema/     # shared response and search-parameter schemas
  util/       # query builder, date/time utilities

Every domain model inherits from the base classes laid down in base/. Define the exception format once in common/exception/, and every domain throws errors in the same shape. The domains focus only on their own work, and common sets the shared grain.

Split the web from the worker

The same domain code is used by two kinds of runtime environment: the web server that responds to user requests instantly, and the worker that grinds through heavy jobs in the background. They look similar, but they have different things to worry about.

So I keep app/web/ and app/worker/ separate. Each has its own entry point (web_app.py / celery_app.py) and its own settings folder. Where the difference really shows is the DB session. The worker runs by forking processes, so simply sharing a connection pool causes trouble. That’s why the worker’s database.py uses NullPool for fork safety. On the web side, the session is injected into endpoints via Depends; on the worker side, a separate class handles using the session safely inside a Task.

The domain logic (service) is written once, and the web’s routers and the worker’s tasks each call into it. Business rules live in one place, and the difference in runtime environment gets absorbed on the outside.

You can read it from the tree alone

Set it up this way, and test/ can just mirror the app/ structure. Tests split cleanly along domain lines.

In the end this tree is a few decisions made visible: split by domain rather than by function, gather the shared stuff in one place, keep the web and the worker apart. Someone who just joined can read these agreements off the folders alone. That’s why you can’t lay out a folder structure carelessly — it is the design.

From the project
Stock Portfolio App (MyFolio) LAB
A portfolio-first investing app — no need to analyze individual stocks, just connect your KIS brokerage account and let the app handle order planning. Running privately on my home server.
View →
COMMENTS