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
- Keep endpoints thin: Move business logic into service modules so handlers focus on HTTP concerns only.
- Use the application factory: Centralize app creation to support multiple environments and simplify testing.
- Centralize configuration: Read settings from environment variables and avoid hardcoding secrets.
- Group routes by domain: Organize route modules around business domains rather than technical layers.
- Compose middleware carefully: Order matters; place security middleware early and performance middleware like GZip last.
- Use Mount for versioning: Prefix APIs with version numbers (
/api/v1) to support backward compatibility. - Write tests early: Use
TestClientto validate behavior at the HTTP boundary and catch regressions. - Handle errors consistently: Use exception handlers to return uniform error responses across the application.
- Leverage background tasks: Offload slow operations like sending emails to Starlette's
BackgroundTasks.
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.