← Back to DevBytes

Starlette Architecture: Design Patterns and Project Structure

Introduction to Starlette Architecture

Starlette is a lightweight ASGI framework and toolkit that serves as the foundation for popular frameworks like FastAPI. Understanding its architecture, design patterns, and recommended project structure is essential for building maintainable, scalable web applications. This tutorial explores the core concepts of Starlette and demonstrates how to organize your projects effectively.

What Is Starlette?

Starlette is an asynchronous web framework built on top of the ASGI specification. It provides essential building blocks for web applications, including routing, middleware, request and response handling, WebSocket support, and background tasks. Unlike heavier frameworks, Starlette focuses on simplicity and composability, allowing developers to assemble exactly the components they need.

Why Architecture Matters

As applications grow, a clear architecture becomes critical. A well-structured Starlette project improves testability, simplifies debugging, encourages code reuse, and makes onboarding new developers easier. By following established design patterns, you create predictable conventions that scale with your team and codebase.

Core Design Patterns in Starlette

Starlette embraces several design patterns that shape how you build applications. Understanding these patterns helps you write idiomatic code and extend the framework confidently.

1. The ASGI Application Pattern

At its heart, Starlette follows the ASGI application pattern: a callable that accepts a scope, receive, and send callable. Every Starlette app is ultimately an ASGI app, and middleware wraps other ASGI apps to form a chain.

from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route

async def homepage(request):
    return JSONResponse({"message": "Hello, Starlette!"})

routes = [
    Route("/", homepage),
]

app = Starlette(routes=routes)

This pattern means any ASGI app can wrap or be wrapped by another, enabling powerful composition.

2. Middleware Chain Pattern

Starlette uses a middleware stack where each middleware wraps the next application. Requests flow inward through the stack, and responses flow outward. This is a classic decorator or chain-of-responsibility pattern.

from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.cors import CORSMiddleware
from starlette.middleware.gzip import GZipMiddleware
from starlette.middleware.httpsredirect import HTTPSRedirectMiddleware

middleware = [
    Middleware(HTTPSRedirectMiddleware),
    Middleware(GZipMiddleware, minimum_size=1000),
    Middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"]),
]

app = Starlette(middleware=middleware)

3. Endpoint Pattern

Endpoints in Starlette are async functions that accept a Request and return a Response. This simple contract keeps handlers predictable and easy to test.

from starlette.requests import Request
from starlette.responses import JSONResponse

async def get_user(request: Request):
    user_id = request.path_params["user_id"]
    return JSONResponse({"user_id": user_id})

4. Service Layer Pattern

To keep endpoints thin, move business logic into service modules. Endpoints handle HTTP concerns, while services handle domain logic. This separation improves testability and reuse.

# services/user_service.py
from typing import Dict, Any

_users_db: Dict[int, Dict[str, Any]] = {
    1: {"id": 1, "name": "Alice", "email": "alice@example.com"},
    2: {"id": 2, "name": "Bob", "email": "bob@example.com"},
}

async def get_user_by_id(user_id: int) -> Dict[str, Any] | None:
    return _users_db.get(user_id)

async def create_user(name: str, email: str) -> Dict[str, Any]:
    new_id = max(_users_db.keys()) + 1
    user = {"id": new_id, "name": name, "email": email}
    _users_db[new_id] = user
    return user

5. Dependency Injection Pattern

Starlette does not include a built-in dependency injection system like FastAPI, but you can implement it manually using request state or factory functions. This keeps components decoupled and testable.

from starlette.requests import Request
from starlette.responses import JSONResponse
from services.user_service import get_user_by_id

async def get_user(request: Request):
    user_id = int(request.path_params["user_id"])
    user = await get_user_by_id(user_id)
    if user is None:
        return JSONResponse({"error": "User not found"}, status_code=404)
    return JSONResponse(user)

Recommended Project Structure

A consistent project structure is the backbone of a maintainable codebase. Below is a recommended layout for a medium-to-large Starlette application.

my_starlette_app/
β”œβ”€β”€ app/
β”‚   β”œβ”€β”€ __init__.py
β”‚   β”œβ”€β”€ main.py              # Application factory and entry point
β”‚   β”œβ”€β”€ config.py            # Configuration and environment settings
β”‚   β”œβ”€β”€ routes/
β”‚   β”‚   β”œβ”€β”€ __init__.py
β”‚   β”‚   β”œβ”€β”€ home.py
β”‚   β”‚   β”œβ”€β”€ users.py
β”‚   β”‚   └── items.py
β”‚   β”œβ”€β”€ services/
β”‚   β”‚   β”œβ”€β”€ __init__.py
β”‚   β”‚   β”œβ”€β”€ user_service.py
β”‚   β”‚   └── item_service.py
β”‚   β”œβ”€β”€ models/
β”‚   β”‚   β”œβ”€β”€ __init__.py
β”‚   β”‚   β”œβ”€β”€ user.py
β”‚   β”‚   └── item.py
β”‚   β”œβ”€β”€ middleware/
β”‚   β”‚   β”œβ”€β”€ __init__.py
β”‚   β”‚   └── auth.py
β”‚   β”œβ”€β”€ schemas/
β”‚   β”‚   β”œβ”€β”€ __init__.py
β”‚   β”‚   └── user_schema.py
β”‚   └── utils/
β”‚       β”œβ”€β”€ __init__.py
β”‚       └── pagination.py
β”œβ”€β”€ tests/
β”‚   β”œβ”€β”€ __init__.py
β”‚   β”œβ”€β”€ conftest.py
β”‚   β”œβ”€β”€ test_home.py
β”‚   └── test_users.py
β”œβ”€β”€ requirements.txt
β”œβ”€β”€ .env
└── README.md

Application Factory Pattern

Using an application factory function centralizes app creation, making it easy to configure different environments (development, testing, production) and simplifies testing.

# app/main.py
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.cors import CORSMiddleware
from app.config import settings
from app.routes.home import routes as home_routes
from app.routes.users import routes as user_routes
from app.middleware.auth import AuthMiddleware

def create_app() -> Starlette:
    middleware = [
        Middleware(
            CORSMiddleware,
            allow_origins=settings.cors_origins,
            allow_methods=["*"],
            allow_headers=["*"],
        ),
        Middleware(AuthMiddleware),
    ]

    routes = [*home_routes, *user_routes]

    app = Starlette(
        debug=settings.debug,
        routes=routes,
        middleware=middleware,
    )
    return app

app = create_app()

Configuration Management

Centralize configuration using environment variables. This keeps secrets out of source code and supports multiple deployment environments.

# app/config.py
import os
from dataclasses import dataclass

@dataclass
class Settings:
    debug: bool = os.getenv("DEBUG", "false").lower() == "true"
    database_url: str = os.getenv("DATABASE_URL", "sqlite:///./app.db")
    secret_key: str = os.getenv("SECRET_KEY", "change-me-in-production")
    cors_origins: list = os.getenv("CORS_ORIGINS", "*").split(",")

settings = Settings()

Route Modules

Group related routes into modules. Each module exports a list of Route objects, which the factory aggregates.

# app/routes/users.py
from starlette.routing import Route
from starlette.responses import JSONResponse
from app.services.user_service import get_user_by_id, create_user

async def list_users(request):
    return JSONResponse({"users": []})

async def retrieve_user(request):
    user_id = int(request.path_params["user_id"])
    user = await get_user_by_id(user_id)
    if not user:
        return JSONResponse({"error": "Not found"}, status_code=404)
    return JSONResponse(user)

async def create_user_endpoint(request):
    data = await request.json()
    user = await create_user(data["name"], data["email"])
    return JSONResponse(user, status_code=201)

routes = [
    Route("/users", list_users, methods=["GET"]),
    Route("/users", create_user_endpoint, methods=["POST"]),
    Route("/users/{user_id}", retrieve_user, methods=["GET"]),
]

Custom Middleware

Custom middleware lets you implement cross-cutting concerns like authentication, logging, or request tracing. Subclass BaseHTTPMiddleware for HTTP-specific logic.

# app/middleware/auth.py
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse

PUBLIC_PATHS = {"/", "/health", "/login"}

class AuthMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        if request.url.path in PUBLIC_PATHS:
            return await call_next(request)

        token = request.headers.get("Authorization")
        if not token or not token.startswith("Bearer "):
            return JSONResponse(
                {"error": "Unauthorized"},
                status_code=401,
            )

        # Validate token here and attach user to request state
        request.state.user = {"id": 1, "name": "Alice"}
        return await call_next(request)

Mounting Sub-Applications

For larger applications, use Mount to group routes under a common prefix. This keeps route modules self-contained and composable.

from starlette.applications import Starlette
from starlette.routing import Mount, Route
from app.routes.users import routes as user_routes
from app.routes.items import routes as item_routes

routes = [
    Mount("/api/v1/users", routes=user_routes),
    Mount("/api/v1/items", routes=item_routes),
    Route("/health", lambda req: JSONResponse({"status": "ok"})),
]

app = Starlette(routes=routes)

Testing Your Starlette Application

Starlette includes a TestClient built on top of httpx that lets you test your application without starting a real server. Place tests in the tests/ directory and use fixtures for shared setup.

# tests/conftest.py
import pytest
from starlette.testclient import TestClient
from app.main import create_app

@pytest.fixture
def client():
    app = create_app()
    with TestClient(app) as test_client:
        yield test_client
# tests/test_users.py
def test_retrieve_user_not_found(client):
    response = client.get("/api/v1/users/999")
    assert response.status_code == 404
    assert response.json() == {"error": "Not found"}

def test_create_user(client):
    response = client.post(
        "/api/v1/users",
        json={"name": "Charlie", "email": "charlie@example.com"},
        headers={"Authorization": "Bearer valid-token"},
    )
    assert response.status_code == 201
    assert response.json()["name"] == "Charlie"

Best Practices

Consistent Error Handling

Register exception handlers to ensure errors return a predictable shape, regardless of where they occur.

from starlette.applications import Starlette
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.responses import JSONResponse

async def http_exception_handler(request, exc):
    return JSONResponse(
        {"error": exc.detail, "status_code": exc.status_code},
        status_code=exc.status_code,
    )

async def generic_exception_handler(request, exc):
    return JSONResponse(
        {"error": "Internal server error"},
        status_code=500,
    )

app = Starlette(
    exception_handlers={
        StarletteHTTPException: http_exception_handler,
        Exception: generic_exception_handler,
    },
)

Background Tasks

Use background tasks for operations that should not block the response, such as logging, notifications, or cache invalidation.

from starlette.background import BackgroundTask
from starlette.responses import JSONResponse

async def send_welcome_email(email: str):
    # Simulate sending an email
    print(f"Sending welcome email to {email}")

async def register_user(request):
    data = await request.json()
    task = BackgroundTask(send_welcome_email, data["email"])
    return JSONResponse(
        {"message": "User registered"},
        background=task,
    )

Conclusion

Starlette's lightweight, composable architecture makes it an excellent choice for building asynchronous web applications in Python. By adopting the design patterns covered in this tutorialβ€”ASGI application composition, middleware chains, service layers, dependency injection, and the application factoryβ€”you can create projects that are clean, testable, and scalable. Pair these patterns with a well-organized project structure, consistent error handling, and thorough testing, and you will have a solid foundation that grows gracefully with your application's complexity. Whether you use Starlette directly or as the backbone of a higher-level framework, mastering its architecture gives you the flexibility to build robust web services with confidence.

πŸ›  Tools from DevBytes

Inventory Tracker Pro β€” Excel inventory system, low-stock alerts Β· $19
AI Dev Kit for Mac β€” local AI dev environment templates Β· $9.99
KeyMapper for Mac β€” custom keyboard shortcut toolkit Β· $7.99

← Back to all articles