What is it? #
Project structure is about making it obvious where things go. When the layout is predictable, new people find their way quickly and code stops piling up in one enormous file.
The core idea is grouping by feature, not by technical type. A users/ folder containing its models, service functions and schemas beats separate models/, services/ and schemas/ folders that each contain a bit of everything.
Configuration comes from the environment, never from hardcoded values. The same code then runs locally, in CI and in production with only the environment differing.
A few files belong in every repository: a readme that says how to run it, a requirements or pyproject.toml, a .gitignore, an .env.example, and a tests folder.
Think of it like this #
A well-organised kitchen has the knives near the chopping board and the spices near the stove — grouped by what you are doing. A badly organised one has all the metal objects in one drawer and all the plastic ones in another, which is technically consistent and completely useless when you are cooking.
Simple example #
An expense tracking API grows beyond one file. You split it by feature, move configuration into a settings module fed by environment variables, and add tests that mirror the source layout.
Code #
expense-tracker/
├── src/
│ └── tracker/
│ ├── __init__.py
│ ├── main.py # app entry point, wires routes together
│ ├── config.py # settings read from the environment
│ ├── db.py # connection setup
│ ├── expenses/
│ │ ├── __init__.py
│ │ ├── models.py # data shapes
│ │ ├── service.py # business rules — no HTTP here
│ │ └── routes.py # thin HTTP layer
│ └── users/
│ ├── __init__.py
│ ├── models.py
│ ├── service.py
│ └── routes.py
├── tests/
│ ├── test_expenses_service.py
│ └── test_users_service.py
├── .env.example
├── .gitignore
├── pyproject.toml
├── requirements.txt
└── README.md
# src/tracker/config.py
import os
from dataclasses import dataclass
@dataclass(frozen=True)
class Settings:
database_url: str
debug: bool
page_size: int
@classmethod
def from_env(cls) -> "Settings":
return cls(
database_url=os.environ["DATABASE_URL"], # required
debug=os.environ.get("DEBUG", "false").lower() == "true",
page_size=int(os.environ.get("PAGE_SIZE", "20")),
)
settings = Settings.from_env()
# .env.example — committed, with no real values
DATABASE_URL=sqlite:///local.db
DEBUG=true
PAGE_SIZE=20
How it works #
The src/ layout puts your package one level down. That prevents a subtle problem: without it, tests can accidentally import the local folder instead of the installed package, and you end up testing something different from what you ship.
Each feature folder holds everything about that feature. Adding a field to expenses means touching files in one directory rather than three.
The split inside a feature matters. service.py holds the rules and knows nothing about HTTP; routes.py translates requests into service calls. That separation is what makes the service testable without starting a web server.
config.py reads the environment once into a frozen dataclass. Using os.environ["DATABASE_URL"] for required values means the app fails loudly at startup if it is missing, rather than mysteriously later. Optional values use .get() with a default.
.env.example is committed and lists every variable with placeholder values, so a new developer knows what to set. The real .env is git-ignored.
tests/ mirrors the source structure, which makes it obvious where a test for a given module belongs.
pyproject.toml is the modern place for project metadata and tool configuration — pytest, mypy and linters all read settings from it, replacing a scatter of config files.
Real-world use #
Most teams converge on something like this because the alternative hurts. A single app.py of 4,000 lines works until two people edit it at once, and then every merge is a conflict.
The service-versus-route split is the layer that pays off most. When the same logic is needed by an API endpoint, a scheduled job and a management command, having it in a plain function means all three can call it.
Configuration via environment variables is what makes twelve-factor deployment work, and it is assumed by every platform — Docker, systemd, Vercel, Kubernetes. The Docker and VPS tracks both build on this.
Structure should still grow with the project. Starting a 200-line script with this layout is overkill; refusing to adopt it at 5,000 lines is worse.
Common mistakes #
- Grouping by technical type instead of feature, so every change touches five folders.
- Hardcoding database URLs or API keys instead of reading the environment.
- Committing a real
.envfile. Commit.env.exampleonly. - Putting business logic in route handlers, making it impossible to test without HTTP.
- Circular imports between feature packages — usually a sign that shared code belongs in its own module.
Practice #
Restructure a single-file script of your own into a package: create a src/ layout, move configuration into a config.py that reads environment variables, split one feature into service.py and routes.py (or cli.py), and add a tests/ folder with one test for the service function.