KAHIBARO
Discord Login Register

5.5 Python Project Structure

Why Project Structure Matters

When you build small scripts, you can put everything in a single file. For real backend services, that quickly becomes painful.

A clear project structure helps you:

In this chapter you will see:

We will keep examples framework neutral, but everything here works very well with FastAPI and other backend frameworks.


Minimal Python Project Layout

Start with the smallest layout that is still “real”:

text
my_project/
    my_project/
        __init__.py
        main.py
    tests/
        test_example.py
    README.md
    pyproject.toml  # or requirements.txt + setup.cfg, etc.

This is called a “src layout” or “package layout”. The important parts:

This is better than putting code directly in the root folder, because it avoids import problems and makes packaging easier.

Example: A Tiny App

my_project/main.py:

python
def greet(name: str) -> str:
    return f"Hello, {name}!"
def main() -> None:
    message = greet("Backend Developer")
    print(message)
if __name__ == "__main__":
    main()

tests/test_example.py:

python
from my_project.main import greet
def test_greet():
    assert greet("World") == "Hello, World!"

You can run tests with:

bash
pytest

This simple structure already gives you:

Package vs Script: Why You Want a Package

A single script file can be useful:

bash
python my_script.py

But for backends you need multiple modules, and you need imports to work reliably.

Script Layout

text
project/
    app.py

app.py:

python
from utils import helper_function  # This will often fail

Now if you move utils.py around or run the script from a different directory, imports break.

Package Layout

text
project/
    my_app/
        __init__.py
        app.py
        utils.py

app.py:

python
from my_app.utils import helper_function

You can now run things using the module syntax:

bash
python -m my_app.app

Here Python knows where the package is and resolves imports correctly.

bash
python -m package_name.module_name

Important rule
Always structure non-trivial backends as a package, not as a single loose script. Put code inside a named directory with an __init__.py file, and run it with:

This avoids many hard-to-debug import issues as your project grows.


Typical Backend Project Structure

Let us grow the minimal layout into something closer to a real backend:

text
my_backend/
    my_backend/
        __init__.py
        main.py           # Application entry point
        config.py         # App configuration helpers
        db.py             # Database connection setup
        api/
            __init__.py
            routes.py      # Group of API routes
            schemas.py     # Pydantic or data models for requests/responses
        core/
            __init__.py
            settings.py    # Central settings
            security.py    # Security related helpers
        services/
            __init__.py
            user_service.py
            email_service.py
        models/
            __init__.py
            user.py        # Database models
            order.py
    tests/
        __init__.py
        test_routes.py
        test_services.py
    requirements.txt or pyproject.toml
    README.md

High Level Purpose of Folders

FolderPurpose
my_backend/ (inner)All application code as a package.
api/Web layer, request handlers, route definitions.
core/Core infrastructure, settings, security utilities.
services/Business logic, independent of HTTP and database details.
models/Database models or domain entities.
tests/Automated tests for all components.

You do not have to use these exact names, but separating “layers” is very helpful.


Grouping Code by Responsibility

There are two common styles:

  1. Layered by technical responsibility
    Example folders: api, services, repositories, models.
  2. Grouped by feature or module
    Example folders: users, orders, payments, each containing routes, services, models.

For a beginner, layered structure is easier to start with. Later you can move to feature modules if needed.

Example: Layered Structure

text
my_backend/
    my_backend/
        api/
            users.py
            auth.py
        services/
            user_service.py
            auth_service.py
        models/
            user.py
        db.py
        main.py

Here:

Example Files

models/user.py:

python
from dataclasses import dataclass
@dataclass
class User:
    id: int
    email: str
    is_active: bool

services/user_service.py:

python
from my_backend.models.user import User
class UserService:
    def __init__(self, user_repository):
        self.user_repository = user_repository
    def get_user_by_id(self, user_id: int) -> User | None:
        return self.user_repository.find_by_id(user_id)
    def deactivate_user(self, user_id: int) -> None:
        user = self.user_repository.find_by_id(user_id)
        if not user:
            return
        user.is_active = False
        self.user_repository.save(user)

api/users.py (using a fake “framework-like” interface):

python
from my_backend.services.user_service import UserService
from my_backend.repositories.user_repository import UserRepository
user_service = UserService(user_repository=UserRepository())
def get_user_handler(request):
    user_id = int(request.path_params["user_id"])
    user = user_service.get_user_by_id(user_id)
    if not user:
        return {"status": 404, "body": {"detail": "User not found"}}
    return {"status": 200, "body": {"id": user.id, "email": user.email}}

Even without a specific framework, you can see:

This separation will matter a lot once your app grows.


Where to Put Configuration

Backends need configuration:

You should:

A simple pattern is to have a config.py or core/settings.py file.

Example: Settings Module

my_backend/core/settings.py:

python
import os
from dataclasses import dataclass
@dataclass
class Settings:
    debug: bool = False
    database_url: str = "sqlite:///./dev.db"
    secret_key: str = "CHANGE_ME"
    @classmethod
    def from_env(cls) -> "Settings":
        return cls(
            debug=os.getenv("APP_DEBUG", "false").lower() == "true",
            database_url=os.getenv("DATABASE_URL", "sqlite:///./dev.db"),
            secret_key=os.getenv("SECRET_KEY", "CHANGE_ME"),
        )
settings = Settings.from_env()

Use it anywhere:

python
from my_backend.core.settings import settings
def connect_db():
    print(f"Connecting to {settings.database_url}")

Organizational rules:

Entry Points: `main.py` and `__main__`

Your project needs a clear “start here” file.

Common patterns:

Simple CLI style entry

my_backend/main.py:

python
def main() -> None:
    print("Starting backend...")
if __name__ == "__main__":
    main()

Run:

bash
python -m my_backend.main

Framework entry

For a web backend, you usually have an “app” object.

Example with a fake framework:

python
# my_backend/main.py
from my_backend.api.routes import app
def run():
    # Imagine `app.run()` starts a server
    app.run()

If your server process (for example, uvicorn) needs the app, you point it to my_backend.main:app.

bash
uvicorn my_backend.main:app --reload

Key idea:

Important rule
Have a single, small entry point file, such as main.py, where you create the app, load configuration, and start the server. Do not scatter “startup” code all over the project.


Tests and Their Structure

Always keep tests in a separate tests/ folder at the project root:

text
my_backend/
    my_backend/
        ...
    tests/
        __init__.py
        test_users.py
        test_auth.py
        test_services/
            test_user_service.py

You can mirror the application structure:

Simple Service Test Example

tests/test_user_service.py:

python
from my_backend.services.user_service import UserService
from my_backend.models.user import User
class FakeUserRepository:
    def __init__(self):
        self.users = {
            1: User(id=1, email="user@example.com", is_active=True)
        }
    def find_by_id(self, user_id: int) -> User | None:
        return self.users.get(user_id)
    def save(self, user: User) -> None:
        self.users[user.id] = user
def test_deactivate_user():
    repo = FakeUserRepository()
    service = UserService(user_repository=repo)
    service.deactivate_user(1)
    assert repo.users[1].is_active is False

Keeping tests organized:

Handling Dependencies: `requirements.txt` and `pyproject.toml`

Your project should declare which packages it needs. Two common approaches:

1. Using `requirements.txt`

text
my_backend/
    requirements.txt

Example content:

text
fastapi==0.115.0
uvicorn[standard]==0.30.0
sqlalchemy==2.0.35
psycopg2-binary==2.9.9

Install with:

bash
pip install -r requirements.txt

2. Using `pyproject.toml` (modern way)

pyproject.toml:

toml
[project]
name = "my-backend"
version = "0.1.0"
description = "Example backend project"
requires-python = ">=3.11"
dependencies = [
    "fastapi==0.115.0",
    "uvicorn[standard]==0.30.0",
    "sqlalchemy==2.0.35",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]

Then you can use tools such as pip, pip-tools, or uv to install from pyproject.toml.

Organizational rules:

Example: Simple Backend Structure from Scratch

Let us outline a tiny but realistic project that could later become a FastAPI app.

text
todo_backend/
    todo_backend/
        __init__.py
        main.py
        core/
            __init__.py
            settings.py
        api/
            __init__.py
            todos.py
        models/
            __init__.py
            todo.py
        services/
            __init__.py
            todo_service.py
    tests/
        test_todo_service.py
    pyproject.toml
    README.md

Core Domain Model

todo_backend/models/todo.py:

python
from dataclasses import dataclass
from datetime import datetime
@dataclass
class Todo:
    id: int
    title: str
    completed: bool = False
    created_at: datetime = datetime.utcnow()

Business Logic Service

todo_backend/services/todo_service.py:

python
from typing import List, Dict
from todo_backend.models.todo import Todo
class InMemoryTodoRepository:
    def __init__(self) -> None:
        self._todos: Dict[int, Todo] = {}
        self._next_id = 1
    def list_todos(self) -> List[Todo]:
        return list(self._todos.values())
    def create_todo(self, title: str) -> Todo:
        todo = Todo(id=self._next_id, title=title)
        self._todos[self._next_id] = todo
        self._next_id += 1
        return todo
    def mark_done(self, todo_id: int) -> None:
        todo = self._todos.get(todo_id)
        if todo:
            todo.completed = True
class TodoService:
    def __init__(self, repository: InMemoryTodoRepository) -> None:
        self.repository = repository
    def get_all(self) -> List[Todo]:
        return self.repository.list_todos()
    def add(self, title: str) -> Todo:
        return self.repository.create_todo(title)
    def complete(self, todo_id: int) -> None:
        self.repository.mark_done(todo_id)

API Layer (Pseudocode)

todo_backend/api/todos.py:

python
from todo_backend.services.todo_service import TodoService, InMemoryTodoRepository
repo = InMemoryTodoRepository()
service = TodoService(repository=repo)
def list_todos_handler(request):
    todos = service.get_all()
    return {
        "status": 200,
        "body": [{"id": t.id, "title": t.title, "completed": t.completed} for t in todos],
    }
def create_todo_handler(request):
    data = request.json()
    new_todo = service.add(title=data["title"])
    return {
        "status": 201,
        "body": {"id": new_todo.id, "title": new_todo.title, "completed": new_todo.completed},
    }

You could plug these handlers into any HTTP framework, but the structure of your project would stay almost the same.

Entry Point

todo_backend/main.py:

python
# Here you would create a real app with a framework.
# For illustration we just show where that would live.
def main() -> None:
    print("Todo backend starting...")
if __name__ == "__main__":
    main()

You can see how each part has a clear place:

Practical Guidelines for Structuring Python Backend Projects

To summarize the most important points:

Key rules for Python project structure

  1. Use a package layout
    Put your code inside project_name/ with an __init__.py file.
  2. Separate application code and tests
    Application package (for example my_backend/) and tests/ at the root.
  3. Group by responsibility
    For example:
    • api/ for routes and controllers.
    • services/ for business logic.
    • models/ for data models.
    • core/ or config.py for settings and core utilities.
  4. Have a clear entry point
    Create a main.py or similar, and run it with python -m package.module.
  5. Centralize configuration
    Read environment variables in a single settings module, and import that everywhere.
  6. Declare dependencies
    Use requirements.txt or pyproject.toml so others can install your project easily.

If you follow these principles from the beginning, your backend projects will be much easier to grow, test, and maintain as you become a more advanced backend developer.

Views: 8

Comments

Please login to add a comment.

Don't have an account? Register now!