Building with AI – A Developer's Diary

Starlette Day 2 — Real CRUD and Project Structure

Break out of the single-file prototype: build full CRUD endpoints, handle URL parameters, and organize your code the way a production backend would.

NoCo Interactive • Python · Starlette · ASGI • 15–20 minute read •

On Day 1 you built a working Starlette API in a single file. That's the right place to start—but a single file stops working the moment your app grows past the tutorial stage. Today you fix that. By the end of this session you'll have a full CRUD API with GET, POST, PUT, and DELETE endpoints, organized the way a real backend would be.

This is also the day the structure starts to feel familiar if you come from PHP. Routes handle HTTP. Services handle logic. The two layers don't know each other's internals. If you've worked with Laravel or any MVC framework, you've seen this separation before—you're just applying it in Python now.


Before you start — activate your virtual environment

Day 1 walked through creating a virtual environment. Every time you open a new terminal session, you need to activate it again before running any Python or Uvicorn commands. Your dependencies—including Starlette itself—only exist inside that environment.

Shell
cd starlette-day1
source venv/bin/activate   # Mac / Linux
# venv\Scripts\activate    # Windows

Your prompt will show (venv) when the environment is active. If you skip this step, commands like uvicorn will either fail entirely or run against a different Python installation than the one with your packages.

Where you're starting

From Day 1 you have a single main.py with a task list, a GET endpoint, and a POST endpoint. The code works, but it has a structural problem: everything lives in one place. Routes, data, and logic are tangled together. That's fine at 30 lines. It becomes a maintenance problem at 300.

Today you'll split that file into a proper structure that scales—separate folders for routes and services, with a clean entry point that ties them together.

Create the project structure

Inside your project folder, create the following directories and empty init files:

Shell
mkdir app
mkdir app/routes
mkdir app/services
touch app/__init__.py
touch app/routes/__init__.py
touch app/services/__init__.py

The __init__.py files tell Python to treat each folder as a package, which is what makes imports like from app.services import task_service work. They can be empty—their presence is what matters.

Your final structure for today will look like this:

Shell
starlette-day1/
├── venv/
├── app/
│   ├── __init__.py
│   ├── main.py
│   ├── routes/
│   │   ├── __init__.py
│   │   └── tasks.py
│   └── services/
│       ├── __init__.py
│       └── task_service.py
└── (your old main.py, now unused)

Move the app entry point

Create app/main.py—this replaces your old root-level main.py as the application entry point:

Python
from starlette.applications import Starlette
from app.routes.tasks import routes as task_routes

app = Starlette(routes=task_routes)

This file has one job: create the Starlette application and wire in the route definitions from other modules. Nothing about tasks, nothing about HTTP logic—just assembly.

Because the entry point moved, the Uvicorn command changes too:

Shell
uvicorn app.main:app --reload
What changed: main:app (root-level file) becomes app.main:app (the main module inside the app package). The colon separates the module path from the variable name inside that module.

Don't run the server yet—app/routes/tasks.py doesn't exist and the import will fail. Finish the next two files first.

Create the service layer

Create app/services/task_service.py. This file owns all of the business logic—finding tasks, creating them, updating them, deleting them. It knows nothing about HTTP:

Python
tasks = [
    {"id": 1, "title": "Learn Starlette"},
    {"id": 2, "title": "Build API"}
]


def get_all_tasks():
    return tasks


def get_task(task_id):
    for task in tasks:
        if task["id"] == task_id:
            return task
    return None


def create_task(title):
    new_task = {
        "id": len(tasks) + 1,
        "title": title
    }
    tasks.append(new_task)
    return new_task


def update_task(task_id, title):
    task = get_task(task_id)
    if task:
        task["title"] = title
        return task
    return None


def delete_task(task_id):
    global tasks
    tasks = [t for t in tasks if t["id"] != task_id]

A few things worth noting here. get_task returns None when no match is found—the route layer will handle converting that into a 404. The service itself doesn't produce HTTP responses; it just operates on data. The global tasks declaration in delete_task is required because the function is reassigning the variable itself (not just mutating the list), so Python needs to know it refers to the module-level tasks.

This is the same separation PHP developers know as Controllers vs. Models—the route handles the HTTP transaction, the service handles what actually happens.

Create the routes layer

Create app/routes/tasks.py. This file handles everything HTTP: reading path parameters, parsing request bodies, choosing status codes, and returning JSON responses. It delegates all real logic to the service:

Python
from starlette.responses import JSONResponse
from starlette.routing import Route
from starlette.requests import Request

from app.services import task_service


# GET /tasks
async def get_tasks(request):
    return JSONResponse(task_service.get_all_tasks())


# GET /tasks/{id}
async def get_task(request):
    task_id = int(request.path_params["id"])
    task = task_service.get_task(task_id)

    if not task:
        return JSONResponse({"error": "Task not found"}, status_code=404)

    return JSONResponse(task)


# POST /tasks
async def create_task(request: Request):
    data = await request.json()
    title = data.get("title")

    if not title:
        return JSONResponse({"error": "Title is required"}, status_code=400)

    new_task = task_service.create_task(title)
    return JSONResponse(new_task, status_code=201)


# PUT /tasks/{id}
async def update_task(request: Request):
    task_id = int(request.path_params["id"])
    data = await request.json()
    title = data.get("title")

    task = task_service.update_task(task_id, title)

    if not task:
        return JSONResponse({"error": "Task not found"}, status_code=404)

    return JSONResponse(task)


# DELETE /tasks/{id}
async def delete_task(request):
    task_id = int(request.path_params["id"])
    task_service.delete_task(task_id)

    return JSONResponse({"message": "Deleted"})


routes = [
    Route("/tasks", get_tasks),
    Route("/tasks", create_task, methods=["POST"]),
    Route("/tasks/{id:int}", get_task),
    Route("/tasks/{id:int}", update_task, methods=["PUT"]),
    Route("/tasks/{id:int}", delete_task, methods=["DELETE"]),
]

The routes list at the bottom is what app/main.py imports. Each Route maps a path pattern to a handler function. Where the path contains {id:int}, Starlette handles the type conversion automatically—it will reject non-integer values before your handler even runs.

Path parameters

The {id:int} syntax in your route definitions is how Starlette captures dynamic URL segments. Inside your handler, you read the value with:

Python
task_id = int(request.path_params["id"])

The :int type hint in the route pattern means Starlette already validated and converted the value by the time it reaches your handler. The explicit int() call is a safe habit—it makes your intent clear and guards against any edge cases where the value might arrive as a string.

For PHP developers: this is equivalent to $_GET['id'], but the routing layer handles the extraction rather than you pulling it from the superglobal manually.

Status codes

Notice that the routes layer explicitly sets status codes in two places:

  • status_code=201 on a successful create — HTTP convention for "resource created"
  • status_code=404 when a task isn't found — "resource does not exist"
  • status_code=400 when required data is missing — "malformed request"

If you omit status_code, JSONResponse defaults to 200. For a GET that returns data, that's correct. For a POST that creates something, returning 201 is more accurate and expected by well-behaved API clients. Mobile apps in particular often check status codes to decide how to handle responses—getting these right matters more than it might seem in a browser-only context.

Start the server and test everything

With all three files in place, start Uvicorn. Make sure your virtual environment is still active first:

Shell
uvicorn app.main:app --reload

You should see Uvicorn running on http://127.0.0.1:8000. Now test each endpoint.

Get all tasks

Open http://127.0.0.1:8000/tasks in your browser. You'll see the two seeded tasks as a JSON array.

Get one task

Open http://127.0.0.1:8000/tasks/1. You'll see the single task object. Try /tasks/99—you should get a 404 with {"error": "Task not found"}.

Create a task

Shell
curl -X POST http://127.0.0.1:8000/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Day 2 Task"}'

You should get back the new task with id: 3 and a 201 status. Hit /tasks again in the browser to confirm it's in the list.

Update a task

Shell
curl -X PUT http://127.0.0.1:8000/tasks/1 \
  -H "Content-Type: application/json" \
  -d '{"title": "Updated Task"}'

You should get back the updated task object. Check /tasks/1 to confirm the change.

Delete a task

Shell
curl -X DELETE http://127.0.0.1:8000/tasks/1

You should get {"message": "Deleted"}. Check /tasks—task 1 will be gone. Try deleting it again and you'll still get {"message": "Deleted"} with a 200 status. If you want strict behavior, you could check whether the task existed before deleting and return a 404—that's a reasonable production choice, though not required here.

One important caveat — data doesn't survive restarts

Any tasks you create or modify exist only in memory while the server process is running. Stop Uvicorn, start it again, and the list resets to the two seeded items. That's intentional for now—it keeps Day 2 focused on structure rather than persistence.

On Day 3 you'll replace the in-memory list with a real SQLite database using SQLAlchemy. The routes layer won't change much; the service layer will swap out plain Python lists for database queries. That's the payoff of the separation you set up today—when the data layer changes, the HTTP layer doesn't have to.

What you actually learned

The code changes are straightforward. The structural insight is worth making explicit:

  • Project layout matters. Separating routes, services, and the entry point isn't ceremony—it's the thing that makes a codebase navigable when it grows. This is the same pattern used in Laravel, Django, and most Node.js API frameworks.
  • Path parameters are captured with {name:type} in the route definition and read with request.path_params["name"] in the handler.
  • HTTP methods map to operations: GET reads, POST creates, PUT updates, DELETE removes. Starlette lets multiple routes share a path as long as their methods are different.
  • Status codes carry meaning for API consumers. 201 for creates, 404 when something isn't found, 400 for bad input. Default 200 when everything is fine.
  • The app.main:app convention is how Python module paths work: package dot module, colon, variable. When your project grows, this pattern extends naturally to any depth.
  • Virtual environments must be activated at the start of every terminal session. If Uvicorn isn't found or imports fail unexpectedly, that's almost always the cause.

Day 3 preview — Database and real persistence

On Day 3, the in-memory task list becomes a real SQLite database. You'll install SQLAlchemy and Alembic, define ORM models for tasks and notes, generate your first migration, and replace the plain Python functions in task_service.py with real database queries. The routes layer stays almost exactly the same—which is exactly why you set it up the way you did today.

If something isn't working: check that your virtual environment is active (source venv/bin/activate), that all three __init__.py files exist, and that you're running uvicorn app.main:app --reload (not uvicorn main:app). Import errors almost always come down to one of those three things.

← Back to Building with AI – A Developer's Diary