KAHIBARO
Discord Login Register

29.2 Project Structure

Why Project Structure Matters

A clear project structure makes your backend:

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:

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:

text
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.md

You 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:

python
# 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:

When you run Uvicorn you will use something like:

bash
uvicorn app.main:app --reload

`app/config.py`

config.py holds configuration values, usually loaded from environment variables.

Example:

python
# 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:

python
# 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:

LayerFolderPurpose
Databasemodels/How data is stored in the database
API I/Oschemas/How data is sent/received via the API
Logicservices/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:

python
# 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:

`app/schemas/`

schemas/ has Pydantic models that describe request and response bodies.

Example:

python
# 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 = True

Here:

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:

python
# 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:

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.

python
# 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.

python
# 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:

A minimal security.py for the future:

python
# 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:

bash
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:

text
tests/
β”œβ”€ __init__.py
└─ test_tasks.py

A very simple test file:

python
# 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.

FilePurpose
requirements.txtPython package dependencies
.env.exampleExample environment variables for configuration
.gitignoreFiles that Git should ignore
README.mdProject description and usage instructions
alembic.iniAlembic configuration file for migrations

Example requirements.txt for this project:

text
fastapi
uvicorn[standard]
sqlalchemy
psycopg2-binary
alembic
pydantic
python-dotenv
passlib[bcrypt]
pytest

Example .env.example:

text
DATABASE_URL=postgresql://user:password@localhost:5432/task_manager
ENVIRONMENT=development

This 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:

  1. Client calls POST /api/v1/tasks with JSON.
  2. FastAPI in app/main.py routes the request to the handler in app/api/v1/tasks.py.
  3. The handler validates the body using schemas from app/schemas/task.py.
  4. The handler calls TaskService in app/services/task_service.py.
  5. The service uses TaskRepository in app/repositories/task_repository.py.
  6. The repository uses SessionLocal from app/db.py and Task model from app/models/task.py.
  7. The result is converted back to TaskRead schema and returned to the client.

Once you understand where each piece belongs, building new features becomes much easier. You add:

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

Comments

Please login to add a comment.

Don't have an account? Register now!