Module 1: Dependency Injection
Project: Refactoring the To-Do API with Dependency Injection
Project overview
In this project you take your To-Do API from FastAPI Fundamentals — with its CRUD endpoints, Pydantic models, and error handling — and refactor it by applying Dependency Injection. You aren't changing what the API does. You're changing how it does it. The repeated logic gets extracted into reusable dependencies. Data access gets centralized. Pagination gets encapsulated. The task lookup with its 404 gets written exactly once.
The result is an API with exactly the same endpoints and responses, but with significantly cleaner code. Each endpoint focuses on its business logic — the preconditions (the task exists, pagination is parsed, the data is reachable) are handled by the dependencies.
You'll pull together everything from capsules 02–04: basic Depends() for pagination and filters, sub-dependencies for composition, class-based dependencies for configuration, yield dependencies for the data store, and dependency_overrides to confirm that testing works.
Project objectives
By the end of this project:
- ✅ You extract pagination (
skip,limit) into a reusable dependency function - ✅ You extract the task lookup + 404 into
get_task_or_404 - ✅ You centralize data access with a
get_task_storeyield dependency - ✅ You create a
Paginatorclass-based dependency with a configurable limit - ✅ You use sub-dependencies:
get_task_or_404depends onget_task_store - ✅ You implement common filters as a dependency
- ✅ You use
dependency_overridesto confirm you can replace the data store in tests - ✅ Your endpoints are shorter and focused on business logic
- ✅ The API behaves exactly as it did before the refactor
Why this project?
Look at these three endpoints from an API without DI:
@app.get("/tasks/{task_id}")
def get_task(task_id: int):
for task in tasks: # ← repeated
if task["id"] == task_id: # ← repeated
return task # ← repeated
raise HTTPException(404, ...) # ← repeated
@app.put("/tasks/{task_id}")
def update_task(task_id: int, data: TaskUpdate):
for task in tasks: # ← repeated
if task["id"] == task_id: # ← repeated
task.update(...)
return task
raise HTTPException(404, ...) # ← repeated
@app.delete("/tasks/{task_id}")
def delete_task(task_id: int):
for i, task in enumerate(tasks): # ← repeated
if task["id"] == task_id: # ← repeated
return tasks.pop(i)
raise HTTPException(404, ...) # ← repeated
That's 4 lines of lookup repeated 3 times. With DI, they get written once:
Without DI: With DI (this project):
────────── ──────────────────────
3x inline lookup + 404 → 1x get_task_or_404 + Depends()
3x skip/limit in the signature → 1x pagination_params + Depends()
direct access to a global list → 1x get_task_store (yield dep)
business logic + plumbing → endpoints with business logic only
Technical specifications
Stack
- Framework: FastAPI
- Validation: Pydantic v2
- Server: uvicorn with hot reload
- Storage: In-memory list (accessed through a yield dependency)
Data model
| Field | Type | Description |
|---|---|---|
id | int | Unique identifier (auto-generated) |
title | str | The task's title (1-200 chars) |
description | str | None | Optional description |
completed | bool | Completion state (default False) |
priority | str | Priority: high, medium, low (default "medium") |
created_at | str | Creation date (ISO format) |
Endpoints
| Method | Path | Description | Dependencies |
|---|---|---|---|
| GET | / | Service info | — |
| GET | /tasks | List with filters and pagination | pagination_params, task_filters, get_task_store |
| GET | /tasks/stats | Statistics | task_filters, get_task_store |
| GET | /tasks/{task_id} | Get by ID | get_task_or_404 |
| POST | /tasks | Create a task | get_task_store |
| PATCH | /tasks/{task_id} | Partial update | get_task_or_404 |
| DELETE | /tasks/{task_id} | Delete | get_task_or_404 |
Dependencies to create
| Dependency | Type | Purpose |
|---|---|---|
get_task_store | Yield | Returns the task list (centralizes data access) |
pagination_params | Regular | Encapsulates skip and limit with their constraints |
task_filters | Regular | Encapsulates the filters: completed, priority, search |
get_task_or_404 | Regular (sub-dep) | Looks up a task by ID, raises 404 if it doesn't exist |
Paginator | Class-based | A configurable paginator with max_limit |
Step-by-step guide
Step 1: Create the project structure
fastapi-advanced/
├── app/
│ ├── __init__.py
│ └── main.py ← all the code (for now)
├── venv/
└── requirements.txt
mkdir -p fastapi-advanced/app
cd fastapi-advanced
python -m venv venv
source venv/bin/activate
pip install "fastapi[standard]"
touch app/__init__.py
Step 2: Define the Pydantic models
Create the models in app/main.py:
from typing import Optional
from datetime import datetime
from pydantic import BaseModel, Field
class TaskCreate(BaseModel):
title: str = Field(min_length=1, max_length=200, description="The task's title")
description: Optional[str] = Field(default=None, max_length=1000)
priority: str = Field(default="medium", pattern="^(high|medium|low)$")
model_config = {
"json_schema_extra": {
"examples": [
{
"title": "Study advanced FastAPI",
"description": "Finish the Dependency Injection module",
"priority": "high",
}
]
}
}
class TaskUpdate(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=200)
description: Optional[str] = Field(default=None, max_length=1000)
completed: Optional[bool] = None
priority: Optional[str] = Field(default=None, pattern="^(high|medium|low)$")
Step 3: Create the initial data and the yield dependency for the data store
from fastapi import FastAPI, Depends, HTTPException, Query
initial_tasks = [
{
"id": 1,
"title": "Buy groceries",
"description": "Milk, bread, eggs, fruit",
"completed": False,
"priority": "medium",
"created_at": "2026-03-10T09:00:00",
},
{
"id": 2,
"title": "Study Dependency Injection",
"description": "Finish capsules 02 through 04 of module 1",
"completed": False,
"priority": "high",
"created_at": "2026-03-10T10:30:00",
},
{
"id": 3,
"title": "Work out",
"description": None,
"completed": True,
"priority": "low",
"created_at": "2026-03-10T07:00:00",
},
{
"id": 4,
"title": "Review pull requests",
"description": "PR #42 and PR #43 pending review",
"completed": False,
"priority": "high",
"created_at": "2026-03-11T14:00:00",
},
{
"id": 5,
"title": "Prepare presentation",
"description": "Slides for Friday's demo",
"completed": True,
"priority": "medium",
"created_at": "2026-03-09T16:00:00",
},
]
task_store = initial_tasks[:]
def get_task_store():
yield task_store
The get_task_store yield dependency centralizes data access. Today it returns an in-memory list. In the future, it could return a database session — and the endpoints wouldn't change.
Step 4: Create the pagination dependency
class PaginationResult(BaseModel):
skip: int
limit: int
def pagination_params(
skip: int = Query(default=0, ge=0, description="Records to skip"),
limit: int = Query(default=10, ge=1, le=100, description="Maximum records"),
) -> PaginationResult:
return PaginationResult(skip=skip, limit=limit)
Use a Pydantic model for the result — you get autocompletion with pagination.skip and pagination.limit.
Step 5: Create the filters dependency
class TaskFilters(BaseModel):
completed: Optional[bool]
priority: Optional[str]
search: Optional[str]
def task_filters(
completed: Optional[bool] = Query(default=None, description="Filter by state"),
priority: Optional[str] = Query(
default=None, pattern="^(high|medium|low)$", description="Filter by priority"
),
search: Optional[str] = Query(
default=None, min_length=1, description="Search in title and description"
),
) -> TaskFilters:
return TaskFilters(completed=completed, priority=priority, search=search)
Step 6: Create the lookup dependency with a sub-dependency
def get_task_or_404(
task_id: int,
store: list = Depends(get_task_store),
) -> dict:
for task in store:
if task["id"] == task_id:
return task
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
get_task_or_404 uses Depends(get_task_store) — it's a sub-dependency. The store comes from the yield dependency, not from a global variable.
Step 7: Create a class-based dependency (Paginator)
class Paginator:
def __init__(self, max_limit: int = 100):
self.max_limit = max_limit
def __call__(
self,
page: int = Query(default=1, ge=1, description="Page number"),
size: int = Query(default=10, ge=1, description="Page size"),
) -> PaginationResult:
effective_size = min(size, self.max_limit)
skip = (page - 1) * effective_size
return PaginationResult(skip=skip, limit=effective_size)
paginator = Paginator(max_limit=50)
Step 8: A helper function to apply the filters
def apply_filters(tasks_list: list, filters: TaskFilters) -> list:
result = tasks_list[:]
if filters.completed is not None:
result = [t for t in result if t["completed"] == filters.completed]
if filters.priority:
result = [t for t in result if t["priority"] == filters.priority]
if filters.search:
term = filters.search.lower()
result = [
t
for t in result
if term in t["title"].lower()
or (t["description"] and term in t["description"].lower())
]
return result
This is a helper, not a dependency — it doesn't pull parameters out of the request, it just operates on data.
Step 9: Implement the endpoints
app = FastAPI(
title="To-Do API with Dependency Injection",
description="A task API refactored with DI. Module 1 — FastAPI Advanced Features.",
version="2.0.0",
)
@app.get("/", tags=["General"])
def root():
return {
"service": "To-Do API",
"version": "2.0.0",
"module": "Dependency Injection",
"docs": "/docs",
}
@app.get("/tasks", tags=["Tasks"])
def list_tasks(
store: list = Depends(get_task_store),
filters: TaskFilters = Depends(task_filters),
pagination: PaginationResult = Depends(pagination_params),
):
filtered = apply_filters(store, filters)
start = pagination.skip
end = start + pagination.limit
return {
"total": len(filtered),
"skip": pagination.skip,
"limit": pagination.limit,
"tasks": filtered[start:end],
}
@app.get("/tasks/stats", tags=["Tasks"])
def task_stats(
store: list = Depends(get_task_store),
filters: TaskFilters = Depends(task_filters),
):
filtered = apply_filters(store, filters)
completed = sum(1 for t in filtered if t["completed"])
by_priority = {}
for task in filtered:
p = task["priority"]
by_priority[p] = by_priority.get(p, 0) + 1
return {
"total": len(filtered),
"completed": completed,
"pending": len(filtered) - completed,
"by_priority": by_priority,
}
@app.get("/tasks/{task_id}", tags=["Tasks"])
def get_task(task: dict = Depends(get_task_or_404)):
return task
@app.post("/tasks", status_code=201, tags=["Tasks"])
def create_task(
task_data: TaskCreate,
store: list = Depends(get_task_store),
):
new_id = max((t["id"] for t in store), default=0) + 1
new_task = task_data.model_dump()
new_task["id"] = new_id
new_task["completed"] = False
new_task["created_at"] = datetime.now().isoformat()
store.append(new_task)
return new_task
@app.patch("/tasks/{task_id}", tags=["Tasks"])
def update_task(
task: dict = Depends(get_task_or_404),
task_data: TaskUpdate = ...,
):
update = task_data.model_dump(exclude_unset=True)
task.update(update)
return task
@app.delete("/tasks/{task_id}", tags=["Tasks"])
def delete_task(
task: dict = Depends(get_task_or_404),
store: list = Depends(get_task_store),
):
store.remove(task)
return {"message": f"Task '{task['title']}' deleted", "task": task}
Step 10: Check that everything works
uvicorn app.main:app --reload
Try it in /docs or with curl:
# List them all
curl http://127.0.0.1:8000/tasks
# Filter by priority
curl "http://127.0.0.1:8000/tasks?priority=high"
# Search
curl "http://127.0.0.1:8000/tasks?search=study"
# Pagination
curl "http://127.0.0.1:8000/tasks?skip=2&limit=2"
# Statistics
curl http://127.0.0.1:8000/tasks/stats
# Filtered statistics
curl "http://127.0.0.1:8000/tasks/stats?priority=high"
# Get by ID
curl http://127.0.0.1:8000/tasks/1
# Get one that doesn't exist
curl http://127.0.0.1:8000/tasks/999
# Create
curl -X POST http://127.0.0.1:8000/tasks \
-H "Content-Type: application/json" \
-d '{"title": "New task with DI", "priority": "high"}'
# Update
curl -X PATCH http://127.0.0.1:8000/tasks/1 \
-H "Content-Type: application/json" \
-d '{"completed": true}'
# Delete
curl -X DELETE http://127.0.0.1:8000/tasks/3
The complete project
Structure
fastapi-advanced/
├── app/
│ ├── __init__.py
│ └── main.py ← all the code
├── venv/
└── requirements.txt
Full code — app/main.py
from typing import Optional
from datetime import datetime
from fastapi import FastAPI, Depends, HTTPException, Query
from pydantic import BaseModel, Field
# --- Models ---
class TaskCreate(BaseModel):
title: str = Field(min_length=1, max_length=200, description="The task's title")
description: Optional[str] = Field(default=None, max_length=1000)
priority: str = Field(default="medium", pattern="^(high|medium|low)$")
model_config = {
"json_schema_extra": {
"examples": [
{
"title": "Study advanced FastAPI",
"description": "Finish the Dependency Injection module",
"priority": "high",
}
]
}
}
class TaskUpdate(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=200)
description: Optional[str] = Field(default=None, max_length=1000)
completed: Optional[bool] = None
priority: Optional[str] = Field(default=None, pattern="^(high|medium|low)$")
class PaginationResult(BaseModel):
skip: int
limit: int
class TaskFilters(BaseModel):
completed: Optional[bool]
priority: Optional[str]
search: Optional[str]
# --- Data Store ---
initial_tasks = [
{
"id": 1,
"title": "Buy groceries",
"description": "Milk, bread, eggs, fruit",
"completed": False,
"priority": "medium",
"created_at": "2026-03-10T09:00:00",
},
{
"id": 2,
"title": "Study Dependency Injection",
"description": "Finish capsules 02 through 04 of module 1",
"completed": False,
"priority": "high",
"created_at": "2026-03-10T10:30:00",
},
{
"id": 3,
"title": "Work out",
"description": None,
"completed": True,
"priority": "low",
"created_at": "2026-03-10T07:00:00",
},
{
"id": 4,
"title": "Review pull requests",
"description": "PR #42 and PR #43 pending review",
"completed": False,
"priority": "high",
"created_at": "2026-03-11T14:00:00",
},
{
"id": 5,
"title": "Prepare presentation",
"description": "Slides for Friday's demo",
"completed": True,
"priority": "medium",
"created_at": "2026-03-09T16:00:00",
},
]
task_store = initial_tasks[:]
# --- Dependencies ---
def get_task_store():
yield task_store
def pagination_params(
skip: int = Query(default=0, ge=0, description="Records to skip"),
limit: int = Query(default=10, ge=1, le=100, description="Maximum records"),
) -> PaginationResult:
return PaginationResult(skip=skip, limit=limit)
def task_filters(
completed: Optional[bool] = Query(default=None, description="Filter by state"),
priority: Optional[str] = Query(
default=None, pattern="^(high|medium|low)$", description="Filter by priority"
),
search: Optional[str] = Query(
default=None, min_length=1, description="Search in title and description"
),
) -> TaskFilters:
return TaskFilters(completed=completed, priority=priority, search=search)
def get_task_or_404(
task_id: int,
store: list = Depends(get_task_store),
) -> dict:
for task in store:
if task["id"] == task_id:
return task
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
class Paginator:
def __init__(self, max_limit: int = 100):
self.max_limit = max_limit
def __call__(
self,
page: int = Query(default=1, ge=1, description="Page number"),
size: int = Query(default=10, ge=1, description="Page size"),
) -> PaginationResult:
effective_size = min(size, self.max_limit)
skip = (page - 1) * effective_size
return PaginationResult(skip=skip, limit=effective_size)
paginator = Paginator(max_limit=50)
# --- Helpers ---
def apply_filters(tasks_list: list, filters: TaskFilters) -> list:
result = tasks_list[:]
if filters.completed is not None:
result = [t for t in result if t["completed"] == filters.completed]
if filters.priority:
result = [t for t in result if t["priority"] == filters.priority]
if filters.search:
term = filters.search.lower()
result = [
t
for t in result
if term in t["title"].lower()
or (t["description"] and term in t["description"].lower())
]
return result
# --- App ---
app = FastAPI(
title="To-Do API with Dependency Injection",
description="A task API refactored with DI. Module 1 — FastAPI Advanced Features.",
version="2.0.0",
)
# --- Endpoints ---
@app.get("/", tags=["General"])
def root():
return {
"service": "To-Do API",
"version": "2.0.0",
"module": "Dependency Injection",
"docs": "/docs",
}
@app.get("/tasks", tags=["Tasks"])
def list_tasks(
store: list = Depends(get_task_store),
filters: TaskFilters = Depends(task_filters),
pagination: PaginationResult = Depends(pagination_params),
):
filtered = apply_filters(store, filters)
start = pagination.skip
end = start + pagination.limit
return {
"total": len(filtered),
"skip": pagination.skip,
"limit": pagination.limit,
"tasks": filtered[start:end],
}
@app.get("/tasks/stats", tags=["Tasks"])
def task_stats(
store: list = Depends(get_task_store),
filters: TaskFilters = Depends(task_filters),
):
filtered = apply_filters(store, filters)
completed_count = sum(1 for t in filtered if t["completed"])
by_priority = {}
for task in filtered:
p = task["priority"]
by_priority[p] = by_priority.get(p, 0) + 1
return {
"total": len(filtered),
"completed": completed_count,
"pending": len(filtered) - completed_count,
"by_priority": by_priority,
}
@app.get("/tasks/{task_id}", tags=["Tasks"])
def get_task(task: dict = Depends(get_task_or_404)):
return task
@app.post("/tasks", status_code=201, tags=["Tasks"])
def create_task(
task_data: TaskCreate,
store: list = Depends(get_task_store),
):
new_id = max((t["id"] for t in store), default=0) + 1
new_task = task_data.model_dump()
new_task["id"] = new_id
new_task["completed"] = False
new_task["created_at"] = datetime.now().isoformat()
store.append(new_task)
return new_task
@app.patch("/tasks/{task_id}", tags=["Tasks"])
def update_task(
task: dict = Depends(get_task_or_404),
task_data: TaskUpdate = ...,
):
update = task_data.model_dump(exclude_unset=True)
task.update(update)
return task
@app.delete("/tasks/{task_id}", tags=["Tasks"])
def delete_task(
task: dict = Depends(get_task_or_404),
store: list = Depends(get_task_store),
):
store.remove(task)
return {"message": f"Task '{task['title']}' deleted", "task": task}
Step-by-step verification
1. Check that the server starts
cd fastapi-advanced
source venv/bin/activate
uvicorn app.main:app --reload
Open http://localhost:8000/docs. You should see the documentation titled "To-Do API with Dependency Injection", with every endpoint organized by tags.
2. Check the listing with pagination
curl -s "http://127.0.0.1:8000/tasks?skip=0&limit=2" | python -m json.tool
{
"total": 5,
"skip": 0,
"limit": 2,
"tasks": [
{"id": 1, "title": "Buy groceries", ...},
{"id": 2, "title": "Study Dependency Injection", ...}
]
}
3. Check the filters
# Completed only
curl -s "http://127.0.0.1:8000/tasks?completed=true" | python -m json.tool
# High priority only
curl -s "http://127.0.0.1:8000/tasks?priority=high" | python -m json.tool
# Text search
curl -s "http://127.0.0.1:8000/tasks?search=study" | python -m json.tool
# Combining filters
curl -s "http://127.0.0.1:8000/tasks?completed=false&priority=high" | python -m json.tool
4. Check the statistics
curl -s http://127.0.0.1:8000/tasks/stats | python -m json.tool
{
"total": 5,
"completed": 2,
"pending": 3,
"by_priority": {"medium": 2, "high": 2, "low": 1}
}
5. Check the lookup + 404
# It exists
curl -s http://127.0.0.1:8000/tasks/1 | python -m json.tool
# It doesn't exist
curl -s http://127.0.0.1:8000/tasks/999 | python -m json.tool
# {"detail": "Task 999 not found"}
6. Check the CRUD
# Create
curl -s -X POST http://127.0.0.1:8000/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Task created with DI", "priority": "high"}' | python -m json.tool
# Partial update
curl -s -X PATCH http://127.0.0.1:8000/tasks/1 \
-H "Content-Type: application/json" \
-d '{"completed": true}' | python -m json.tool
# Delete
curl -s -X DELETE http://127.0.0.1:8000/tasks/3 | python -m json.tool
7. Check the Pydantic validation
# Empty title
curl -s -X POST http://127.0.0.1:8000/tasks \
-H "Content-Type: application/json" \
-d '{"title": ""}' | python -m json.tool
# Invalid priority
curl -s -X POST http://127.0.0.1:8000/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Test", "priority": "urgent"}' | python -m json.tool
8. Check dependency_overrides (optional)
Create a test_di.py file at the project root:
from fastapi.testclient import TestClient
from app.main import app, get_task_store
test_tasks = [
{"id": 100, "title": "Test Task", "description": None,
"completed": False, "priority": "high", "created_at": "2026-01-01T00:00:00"},
]
def fake_store():
yield test_tasks
app.dependency_overrides[get_task_store] = fake_store
client = TestClient(app)
response = client.get("/tasks")
data = response.json()
assert data["total"] == 1
assert data["tasks"][0]["id"] == 100
print(f"✅ Override works: {data['total']} test task(s)")
response = client.get("/tasks/100")
assert response.status_code == 200
print(f"✅ Lookup works with the test data")
response = client.get("/tasks/1")
assert response.status_code == 404
print(f"✅ 404 works with the test data (task 1 doesn't exist in test_tasks)")
app.dependency_overrides.clear()
print("\n✅ All the DI tests passed!")
python test_di.py
# ✅ Override works: 1 test task(s)
# ✅ Lookup works with the test data
# ✅ 404 works with the test data (task 1 doesn't exist in test_tasks)
# ✅ All the DI tests passed!
Comparison: before vs after
The GET /tasks/{task_id} endpoint
Before (without DI):
@app.get("/tasks/{task_id}")
def get_task(task_id: int):
for task in tasks:
if task["id"] == task_id:
return task
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
After (with DI):
@app.get("/tasks/{task_id}")
def get_task(task: dict = Depends(get_task_or_404)):
return task
Reduction: From 5 lines to 1 line of logic. The lookup and the 404 are handled in the dependency.
The DELETE /tasks/{task_id} endpoint
Before:
@app.delete("/tasks/{task_id}")
def delete_task(task_id: int):
for i, task in enumerate(tasks):
if task["id"] == task_id:
return tasks.pop(i)
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
After:
@app.delete("/tasks/{task_id}")
def delete_task(task: dict = Depends(get_task_or_404), store: list = Depends(get_task_store)):
store.remove(task)
return {"message": f"Task '{task['title']}' deleted", "task": task}
Benefit: The endpoint only does its job (delete). The lookup and the existence check are the dependency's responsibility.
The GET /tasks endpoint with filters
Before:
@app.get("/tasks")
def list_tasks(
skip: int = Query(default=0, ge=0),
limit: int = Query(default=10, ge=1, le=100),
completed: Optional[bool] = None,
priority: Optional[str] = Query(default=None, pattern="^(high|medium|low)$"),
search: Optional[str] = Query(default=None, min_length=1),
):
result = tasks[:]
# ... 15 lines of filters and pagination
After:
@app.get("/tasks")
def list_tasks(
store: list = Depends(get_task_store),
filters: TaskFilters = Depends(task_filters),
pagination: PaginationResult = Depends(pagination_params),
):
filtered = apply_filters(store, filters)
start = pagination.skip
end = start + pagination.limit
return {"total": len(filtered), "skip": pagination.skip, "limit": pagination.limit, "tasks": filtered[start:end]}
Benefit: The pagination and filter parameters get defined once. If you add a /tasks/export endpoint that needs the same filters, you just add Depends(task_filters).
Troubleshooting
Problem 1: "Task already removed" or an error when deleting
Cause: get_task_or_404 returns a reference to the dict inside the list. If you try store.remove(task) and the dict was already modified or isn't in the list, it fails.
Fix: Make sure get_task_or_404 searches the same store you use to delete. Both have to use Depends(get_task_store).
Problem 2: The filters don't apply on /tasks/stats
Cause: You forgot to add Depends(task_filters) to the stats endpoint.
# ❌ Without filters
@app.get("/tasks/stats")
def task_stats(store: list = Depends(get_task_store)):
...
# ✅ With filters
@app.get("/tasks/stats")
def task_stats(store: list = Depends(get_task_store), filters: TaskFilters = Depends(task_filters)):
filtered = apply_filters(store, filters)
...
Problem 3: /tasks/stats returns 404 instead of statistics
Cause: FastAPI matches /tasks/stats against /tasks/{task_id} and treats "stats" as a task_id. The order of the endpoints matters.
Fix: Define /tasks/stats before /tasks/{task_id}:
@app.get("/tasks/stats") # ← fixed paths first
def task_stats(...): ...
@app.get("/tasks/{task_id}") # ← parameterized paths after
def get_task(...): ...
Problem 4: dependency_overrides doesn't affect the endpoint
Cause: The override's key doesn't match the original function. Make sure you import exactly the same function.
# ❌ You import a copy or a different function
from app.main import get_task_store as store_fn
app.dependency_overrides[store_fn] = fake # might not match
# ✅ Import the exact reference
from app.main import get_task_store
app.dependency_overrides[get_task_store] = fake
Problem 5: Data persisting between test requests
Cause: task_store is a mutable list that gets modified in place. If one test creates or deletes tasks, the changes persist into the next test.
Fix: Each test should use its own copy of the data via an override:
def fake_store():
test_data = [{"id": 1, "title": "Test"}] # fresh data every time
yield test_data
Problem 6: search looks in description but fails when it's None
Cause: If description is None, term in None.lower() raises an error.
Fix: Check that description isn't None before searching:
if filters.search:
term = filters.search.lower()
result = [
t for t in result
if term in t["title"].lower()
or (t["description"] and term in t["description"].lower())
]
Problem 7: Creating a task with max() fails on an empty list
Cause: max(t["id"] for t in store) fails if store is empty.
Fix: Use default=0:
new_id = max((t["id"] for t in store), default=0) + 1
Problem 8: The update endpoint doesn't preserve the fields that weren't sent
Cause: You aren't using exclude_unset=True in model_dump().
# ❌ Overwrites everything
update = task_data.model_dump()
# ✅ Only the fields the client sent
update = task_data.model_dump(exclude_unset=True)
Completion checklist
Before you consider the project finished, check that:
- The server starts without errors using
uvicorn app.main:app --reload -
/docsshows every endpoint with the "General" and "Tasks" tags -
GET /tasksreturns a list withtotal,skip,limit,tasks -
GET /tasks?completed=truefilters to completed tasks only -
GET /tasks?priority=highfilters to high priority only -
GET /tasks?search=studysearches in the title and description -
GET /tasks?skip=2&limit=2paginates correctly -
GET /tasks/statsreturnstotal,completed,pending,by_priority -
GET /tasks/stats?priority=highapplies the filters to the statistics -
GET /tasks/1returns the task -
GET /tasks/999returns 404 with a descriptive message -
POST /taskscreates a task with Pydantic validation -
POST /taskswith an empty title returns 422 -
POST /taskswith an invalid priority returns 422 -
PATCH /tasks/1updates only the fields that were sent -
DELETE /tasks/1deletes and returns a confirmation message -
pagination_paramsis used withDepends()(not inline) -
task_filtersis used withDepends()(not inline) -
get_task_or_404is used in GET, PATCH, and DELETE by ID -
get_task_storeis a yield dependency -
get_task_or_404usesDepends(get_task_store)(a sub-dependency) - The
Paginatorclass-based dependency exists and works -
dependency_overridesworks in the test script
Connection with Module 2 (APIRouter and Middleware)
Your API now has reusable dependencies, but everything still lives in a single main.py file. In Module 2 (APIRouter and Middleware) you'll learn to:
Module 1 (now): Module 2 (next):
────────────────── ─────────────────────
Everything in main.py → app/routers/tasks.py, app/routers/root.py
Inline dependencies → app/dependencies/tasks.py, app/dependencies/pagination.py
No middleware → Logging middleware, timing middleware
No lifecycle events → Lifespan events (startup/shutdown)
The dependencies you created here will move into dedicated files. The endpoints will get organized into routers by domain. And middleware will intercept every request for logging and timing.
Additional resources
- FastAPI - Dependencies — The complete Dependency Injection tutorial
- FastAPI - Dependencies with yield — Yield dependencies for setup/cleanup
- FastAPI - Testing Dependencies — dependency_overrides for testing
- FastAPI - Sub-dependencies — Dependency chains
- FastAPI - Classes as Dependencies — Class-based dependencies
- Pydantic v2 - Models — Models for dependency results
What's next?
Module 1 complete. Your To-Do API now has DI applied: pagination encapsulated, lookup centralized, data access through a yield dependency, and testing with overrides. Each endpoint focuses on its business logic — the preconditions are handled by the dependencies.
In Module 2 (APIRouter and Middleware) you're going to modularize this app: split the endpoints into routers, organize the dependencies into dedicated files, add middleware for logging/timing, and configure lifecycle events. The transition: "Your code is DRY → now your app is modular."