29.2 Project Structure
Table of Contents
Why Project Structure Matters
A clear project structure makes your backend:
- Easier to understand.
- Easier to extend.
- Easier to test.
- Easier to debug.
For this Task Management API we will use a simple, realistic layout that you can reuse in many future projects.
Rule: Decide and document your project structure early. Changing it later is painful, especially when the project grows.
We will assume:
- Language: Python
- Framework: FastAPI
- Database: PostgreSQL with SQLAlchemy and Alembic
You do not need to understand all of these tools yet. In this chapter you only learn where things will live in the project tree.
High-Level Layout
Here is a typical project layout for the Task Management API:
task_manager_api/
ββ app/
β ββ __init__.py
β ββ main.py
β ββ config.py
β ββ db.py
β ββ models/
β β ββ __init__.py
β β ββ task.py
β ββ schemas/
β β ββ __init__.py
β β ββ task.py
β ββ api/
β β ββ __init__.py
β β ββ v1/
β β ββ __init__.py
β β ββ tasks.py
β ββ services/
β β ββ __init__.py
β β ββ task_service.py
β ββ repositories/
β β ββ __init__.py
β β ββ task_repository.py
β ββ core/
β ββ __init__.py
β ββ security.py # used later when we add auth
ββ migrations/
β ββ ... # Alembic migration files
ββ tests/
β ββ __init__.py
β ββ test_tasks.py
ββ alembic.ini
ββ requirements.txt
ββ .env.example
ββ .gitignore
ββ README.mdYou will not need all folders at once. Some will start small and grow with the project.
The `app/` Package
app/ contains all application code. Think of it as the heart of the backend.
`app/main.py`
main.py is the entry point of the FastAPI app.
Minimal example:
# app/main.py
from fastapi import FastAPI
from app.api.v1 import tasks
app = FastAPI(title="Task Management API")
app.include_router(tasks.router, prefix="/api/v1")Here:
- We create the main
FastAPIinstance. - We plug in routers from
app.api.v1.tasks.
When you run Uvicorn you will use something like:
uvicorn app.main:app --reload`app/config.py`
config.py holds configuration values, usually loaded from environment variables.
Example:
# app/config.py
from pydantic import BaseSettings
class Settings(BaseSettings):
DATABASE_URL: str = "postgresql://user:password@localhost:5432/task_manager"
ENVIRONMENT: str = "development"
class Config:
env_file = ".env"
settings = Settings()
Other parts of the app import settings instead of hard-coding values.
Rule: Never hard-code secrets such as passwords or API keys in code. Use configuration and environment variables instead.
`app/db.py`
db.py contains database setup. For example, the SQLAlchemy engine and session.
Simple example:
# app/db.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.config import settings
engine = create_engine(settings.DATABASE_URL, future=True)
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
Later, your repositories and services will use SessionLocal to talk to the database.
Models, Schemas, and Separation of Concerns
We separate three related but different concepts:
| Layer | Folder | Purpose |
|---|---|---|
| Database | models/ | How data is stored in the database |
| API I/O | schemas/ | How data is sent/received via the API |
| Logic | services/ | What the application actually does |
This separation keeps the code more maintainable.
`app/models/`
models/ has SQLAlchemy models that map to database tables.
Example Task model:
# app/models/task.py
from sqlalchemy import Column, Integer, String, Boolean
from sqlalchemy.orm import declarative_base
Base = declarative_base()
class Task(Base):
__tablename__ = "tasks"
id = Column(Integer, primary_key=True, index=True)
title = Column(String, nullable=False)
description = Column(String, nullable=True)
is_completed = Column(Boolean, default=False, nullable=False)Do not worry about every SQLAlchemy detail now. Just remember:
- Models describe how data looks in the database.
- Each model usually matches one table.
`app/schemas/`
schemas/ has Pydantic models that describe request and response bodies.
Example:
# app/schemas/task.py
from typing import Optional
from pydantic import BaseModel
class TaskBase(BaseModel):
title: str
description: Optional[str] = None
class TaskCreate(TaskBase):
pass
class TaskUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
is_completed: Optional[bool] = None
class TaskRead(TaskBase):
id: int
is_completed: bool
class Config:
orm_mode = TrueHere:
TaskCreaterepresents data required to create a task.TaskUpdaterepresents updatable fields.TaskReadrepresents data returned to the client.
Rule: Never return database models directly in API responses. Use schemas to control what the client sees.
API Layer
The API layer exposes endpoints. We will version the API under app/api/v1/.
`app/api/v1/tasks.py`
Each router groups related endpoints. For this project we have tasks.
Example:
# app/api/v1/tasks.py
from typing import List
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.db import SessionLocal
from app.schemas.task import TaskCreate, TaskRead, TaskUpdate
from app.services.task_service import TaskService
router = APIRouter(tags=["tasks"])
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
@router.get("/tasks", response_model=List[TaskRead])
def list_tasks(db: Session = Depends(get_db)):
service = TaskService(db)
return service.list_tasks()
@router.post("/tasks", response_model=TaskRead, status_code=201)
def create_task(task_in: TaskCreate, db: Session = Depends(get_db)):
service = TaskService(db)
return service.create_task(task_in)
@router.get("/tasks/{task_id}", response_model=TaskRead)
def get_task(task_id: int, db: Session = Depends(get_db)):
service = TaskService(db)
return service.get_task(task_id)
@router.put("/tasks/{task_id}", response_model=TaskRead)
def update_task(task_id: int, task_in: TaskUpdate, db: Session = Depends(get_db)):
service = TaskService(db)
return service.update_task(task_id, task_in)
@router.delete("/tasks/{task_id}", status_code=204)
def delete_task(task_id: int, db: Session = Depends(get_db)):
service = TaskService(db)
service.delete_task(task_id)Here you see:
- Endpoints are thin. They only accept input, call the service, and return output.
- Database session is injected with
Depends(get_db).
Services and Repositories
To keep endpoints clean, we move logic into services and repositories.
`app/repositories/task_repository.py`
Repository talks to the database using models.
# app/repositories/task_repository.py
from typing import List, Optional
from sqlalchemy.orm import Session
from app.models.task import Task
class TaskRepository:
def __init__(self, db: Session):
self.db = db
def list(self) -> List[Task]:
return self.db.query(Task).all()
def get(self, task_id: int) -> Optional[Task]:
return self.db.query(Task).filter(Task.id == task_id).first()
def create(self, task: Task) -> Task:
self.db.add(task)
self.db.commit()
self.db.refresh(task)
return task
def delete(self, task: Task) -> None:
self.db.delete(task)
self.db.commit()`app/services/task_service.py`
Service contains business logic and uses repository and schemas.
# app/services/task_service.py
from typing import List
from sqlalchemy.orm import Session
from fastapi import HTTPException, status
from app.schemas.task import TaskCreate, TaskRead, TaskUpdate
from app.models.task import Task
from app.repositories.task_repository import TaskRepository
class TaskService:
def __init__(self, db: Session):
self.repo = TaskRepository(db)
def list_tasks(self) -> List[TaskRead]:
tasks = self.repo.list()
return [TaskRead.from_orm(task) for task in tasks]
def get_task(self, task_id: int) -> TaskRead:
task = self.repo.get(task_id)
if not task:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found")
return TaskRead.from_orm(task)
def create_task(self, task_in: TaskCreate) -> TaskRead:
task = Task(title=task_in.title, description=task_in.description or "")
task = self.repo.create(task)
return TaskRead.from_orm(task)
def update_task(self, task_id: int, task_in: TaskUpdate) -> TaskRead:
task = self.repo.get(task_id)
if not task:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found")
if task_in.title is not None:
task.title = task_in.title
if task_in.description is not None:
task.description = task_in.description
if task_in.is_completed is not None:
task.is_completed = task_in.is_completed
task = self.repo.create(task) # will commit and refresh
return TaskRead.from_orm(task)
def delete_task(self, task_id: int) -> None:
task = self.repo.get(task_id)
if not task:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found")
self.repo.delete(task)Rule: Endpoints should be thin. Put business logic into services and database operations into repositories.
Core Utilities
app/core/ contains shared utilities and core settings that are not specific to a single feature.
Example files you might have later:
security.pyfor password hashing or JWT logic.exceptions.pyfor custom error types.config.pycould also live here if you preferapp/core/config.py.
A minimal security.py for the future:
# app/core/security.py
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)You will reuse these helpers when the project gets authentication.
Migrations
migrations/ is managed by Alembic. It stores scripts that change the database schema over time.
You usually create migrations with CLI commands, for example:
alembic revision --autogenerate -m "create tasks table"
alembic upgrade head
You almost never edit migrations/ directly by hand in small projects; the structure just needs to exist and be committed.
Tests
All tests live in tests/.
Example:
tests/
ββ __init__.py
ββ test_tasks.pyA very simple test file:
# tests/test_tasks.py
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_create_task():
response = client.post("/api/v1/tasks", json={"title": "Test task"})
assert response.status_code == 201
data = response.json()
assert data["title"] == "Test task"
assert "id" in data
You will expand tests later, but having a tests/ folder from the start encourages good habits.
Rule: Always have a tests/ folder, even if you start with a single test. Testing is part of the structure, not an afterthought.
Project Root Files
At the root of the project you will find some important files.
| File | Purpose |
|---|---|
requirements.txt | Python package dependencies |
.env.example | Example environment variables for configuration |
.gitignore | Files that Git should ignore |
README.md | Project description and usage instructions |
alembic.ini | Alembic configuration file for migrations |
Example requirements.txt for this project:
fastapi
uvicorn[standard]
sqlalchemy
psycopg2-binary
alembic
pydantic
python-dotenv
passlib[bcrypt]
pytest
Example .env.example:
DATABASE_URL=postgresql://user:password@localhost:5432/task_manager
ENVIRONMENT=developmentThis tells other developers what they need to configure to run the project.
Putting It All Together
With this structure in place, the flow for a single request looks like this:
- Client calls
POST /api/v1/taskswith JSON. - FastAPI in
app/main.pyroutes the request to the handler inapp/api/v1/tasks.py. - The handler validates the body using schemas from
app/schemas/task.py. - The handler calls
TaskServiceinapp/services/task_service.py. - The service uses
TaskRepositoryinapp/repositories/task_repository.py. - The repository uses
SessionLocalfromapp/db.pyandTaskmodel fromapp/models/task.py. - The result is converted back to
TaskReadschema and returned to the client.
Once you understand where each piece belongs, building new features becomes much easier. You add:
- New models under
models/. - New schemas under
schemas/. - New endpoints under
api/v1/. - New services and repositories when you add new business logic.
This Task Management API is your first full backend project, and this structure will guide how you implement every requirement in the next chapters.
Views: 7
KAHIBARO