Introduction to Sanic Architecture
Sanic is a Python web framework built for speed. Modeled after Flask's familiar API, it leverages Python's asyncio library to handle thousands of concurrent requests with minimal overhead. But raw speed means little if your codebase becomes an unmaintainable tangle as it grows. That's where architecture comes in.
Designing a well-structured Sanic application means choosing the right design patterns, organizing files logically, and separating concerns so your team can iterate quickly without stepping on each other. In this tutorial,'ll we'll explore how to architect a Sanic project from the ground up, covering directory layout, blueprints, service layers, dependency injection, middleware, and testing strategies.
Why Architecture Matters in Sanic
Sanic's asynchronous nature makes it tempting to dump everything into a single app.py file, especially for small services. However, as endpoints multiply, business logic deepens, and integrations with databases and external APIs grow, a flat structure quickly becomes painful. A thoughtful architecture gives you several concrete benefits:
- Testability: Isolated services and repositories are easy to mock and unit test.
- Scalability: Clear boundaries let multiple developers work in parallel.
- Reusability: Shared logic lives in one place rather than being duplicated across handlers.
- Onboarding: New contributors can find their way around the codebase faster.
- Performance tuning: When bottlenecks appear, you know exactly where to look.
Recommended Project Structure
A common and effective pattern is the modular package structure, where each domain or feature has its own folder containing routes, services, models, and schemas. Here's a layout that scales from a small API to a large microservice:
my_sanic_app/
├── app/
│ ├── __init__.py
│ ├── main.py # App factory
│ ├── config.py # Configuration classes
│ ├── extensions.py # DB, cache, etc.
│ ├── blueprints/
│ │ ├── __init__.py
│ │ ├── users/
│ │ │ ├── __init__.py
│ │ │ ├── routes.py
│ │ │ ├── service.py
│ │ │ ├── models.py
│ │ │ └── schemas.py
│ │ ├── products/
│ │ │ ├── __init__.py
│ │ │ ├── routes.py
│ │ │ ├── service.py
│ │ │ ├── models.py
│ │ │ └── schemas.py
│ ├── core/
│ │ ├── __init__.py
│ │ ├── errors.py # Custom exceptions
│ │ ├── responses.py # Standard response helpers
│ │ └── middleware.py # Cross-cutting middleware
│ ├── db/
│ │ ├── __init__.py
│ │ ├── base.py # SQLAlchemy base
│ │ └── session.py # Async session factory
│ └── utils/
│ ├── __init__.py
│ ├── logging.py
│ └── validators.py
├── tests/
│ ├── conftest.py
│ ├── test_users.py
│ └── test_products.py
├── requirements.txt
├── Dockerfile
└── README.md
This structure groups code by feature rather than by technical role. The "users" feature contains everything related to users, making it easy to find and modify. The core and db packages hold shared infrastructure that multiple features depend on.
The Application Factory Pattern
The application factory pattern centralizes app creation in a single function. This is invaluable for testing, where you may want different configurations, and for running multiple instances with different settings. Instead of creating the Sanic instance at module level, you build it inside a function.
# app/main.py
from sanic import Sanic
from sanic.log import logger
from app.config import Config
from app.blueprints.users import users_bp
from app.blueprints.products import products_bp
from app.core.middleware import setup_middleware
from app.core.errors import setup_error_handlers
from app.extensions import init_extensions
def create_app(config_class=Config) -> Sanic:
app = Sanic("my_sanic_app")
# Load configuration
app.config.update(config_class.to_dict())
# Initialize extensions (database, cache, etc.)
init_extensions(app)
# Register blueprints
app.blueprint(users_bp)
app.blueprint(products_bp, url_prefix="/api/v1/products")
# Setup middleware and error handlers
setup_middleware(app)
setup_error_handlers(app)
logger.info("Sanic application created successfully")
return app
if __name__ == "__main__":
app = create_app()
app.run(host="0.0.0.0", port=8000, workers=4)
Notice how each concern is delegated to a dedicated setup function. The factory itself stays short and readable, acting as an orchestrator rather than a monolith.
Configuration Management
Hardcoding secrets and settings is a recipe for disaster. Use environment variables and a configuration class hierarchy to manage settings across environments. The to_dict method makes it easy to feed configuration into Sanic.
# app/config.py
import os
from typing import Any, Dict
class BaseConfig:
DEBUG = False
TESTING = False
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://user:pass@localhost:5432/app")
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-change-me")
API_PREFIX = "/api/v1"
@classmethod
def to_dict(cls) -> Dict[str, Any]:
return {key: getattr(cls, key) for key in dir(cls) if key.isupper()}
class DevelopmentConfig(BaseConfig):
DEBUG = True
class ProductionConfig(BaseConfig):
DEBUG = False
SECRET_KEY = os.getenv("SECRET_KEY") # Must be set in production
class TestingConfig(BaseConfig):
TESTING = True
DATABASE_URL = "sqlite+aiosqlite:///:memory:"
Choose the configuration at runtime based on an environment variable like APP_ENV. This keeps sensitive values out of source control and makes deployments predictable.
Blueprints for Modular Routing
Blueprints are Sanic's mechanism for grouping related routes. They let you register a set of endpoints under a common URL prefix and apply middleware scoped to that group. Each feature module exposes its blueprint, and the factory wires them together.
# app/blueprints/users/routes.py
from sanic import Blueprint
from sanic.request import Request
from sanic.response import json
from app.blueprints.users.service import UserService
from app.blueprints.users.schemas import UserCreateSchema
users_bp = Blueprint("users", url_prefix="/api/v1/users")
@users_bp.get("/")
async def list_users(request: Request):
service = UserService(request.app.ctx.db_session_factory)
users = await service.get_all_users()
return json({"users": [u.to_dict() for u in users]})
@users_bp.post("/")
async def create_user(request: Request):
schema = UserCreateSchema()
errors = schema.validate(request.json or {})
if errors:
return json({"errors": errors}, status=422)
service = UserService(request.app.ctx.db_session_factory)
user = await service.create_user(schema.load(request.json))
return json({"user": user.to_dict()}, status=201)
@users_bp.get("/<user_id:int>")
async def get_user(request: Request, user_id: int):
service = UserService(request.app.ctx.db_session_factory)
user = await service.get_user_by_id(user_id)
if user is None:
return json({"error": "User not found"}, status=404)
return json({"user": user.to_dict()})
Routes stay thin. They parse input, call a service, and format output. All business logic lives in the service layer, which keeps handlers testable and consistent.
The Service Layer Pattern
The service layer encapsulates business rules and orchestrates interactions between repositories, external APIs, and domain models. By keeping this logic out of route handlers, you achieve a clean separation between HTTP transport and application logic.
# app/blueprints/users/service.py
from typing import List, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.blueprints.users.models import User
class UserService:
def __init__(self, session_factory):
self.session_factory = session_factory
async def get_all_users(self) -> List[User]:
async with self.session_factory() as session:
result = await session.execute(User.__table__.select())
rows = result.fetchall()
return [User.from_row(row) for row in rows]
async def get_user_by_id(self, user_id: int) -> Optional[User]:
async with self.session_factory() as session:
result = await session.execute(
User.__table__.select().where(User.id == user_id)
)
row = result.fetchone()
return User.from_row(row) if row else None
async def create_user(self, data: dict) -> User:
async with self.session_factory() as session:
user = User(name=data["name"], email=data["email"])
session.add(user)
await session.commit()
await session.refresh(user)
return user
async def delete_user(self, user_id: int) -> bool:
async with self.session_factory() as session:
result = await session.execute(
User.__table__.delete().where(User.id == user_id)
)
await session.commit()
return result.rowcount > 0
Services receive dependencies through their constructor, which is a lightweight form of dependency injection. In tests, you can pass a mock session factory without touching the database.
Models and Schemas
Models represent your data structures, while schemas handle validation and serialization. Keeping them separate prevents your database schema from leaking into your API contract. Use a library like marshmallow or pydantic for schemas.
# app/blueprints/users/models.py
from dataclasses import dataclass, asdict
from datetime import datetime
@dataclass
class User:
id: int
name: str
email: str
created_at: datetime
def to_dict(self) -> dict:
data = asdict(self)
data["created_at"] = self.created_at.isoformat()
return data
@classmethod
def from_row(cls, row) -> "User":
return cls(
id=row.id,
name=row.name,
email=row.email,
created_at=row.created_at,
)
# app/blueprints/users/schemas.py
from marshmallow import Schema, fields, validate, ValidationError
class UserCreateSchema(Schema):
name = fields.Str(required=True, validate=validate.Length(min=1, max=100))
email = fields.Email(required=True)
def validate(self, data: dict) -> dict:
try:
self.load(data)
return {}
except ValidationError as err:
return err.messages
This separation lets you evolve your API contract independently of your database schema. For example, you might expose a subset of fields in responses or accept different field names in requests.
Middleware for Cross-Cutting Concerns
Middleware handles concerns that span every request: authentication, logging, request IDs, CORS, and rate limiting. Sanic supports both request and response middleware. Group them in a setup function so the factory can apply them consistently.
# app/core/middleware.py
import time
import uuid
from sanic import Sanic, Request, HTTPResponse
from sanic.log import logger
def setup_middleware(app: Sanic) -> None:
@app.middleware("request")
async def add_request_id(request: Request):
request.ctx.request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
request.ctx.start_time = time.time()
@app.middleware("response")
async def add_response_headers(request: Request, response: HTTPResponse):
duration = time.time() - request.ctx.start_time
response.headers["X-Request-ID"] = request.ctx.request_id
response.headers["X-Response-Time"] = f"{duration:.4f}s"
logger.info(
f"{request.method} {request.path} "
f"status={response.status} time={duration:.4f}s "
f"request_id={request.ctx.request_id}"
)
Using request.ctx to stash per-request data is idiomatic in Sanic. The context object is created fresh for each request and discarded afterward, making it safe for concurrent handling.
Centralized Error Handling
Returning consistent error responses improves client integration and simplifies debugging. Define custom exceptions and a global exception handler that converts them into JSON responses with a uniform shape.
# app/core/errors.py
from sanic import Sanic, Request
from sanic.response import json
from sanic.exceptions import SanicException
class AppError(Exception):
def __init__(self, message: str, status_code: int = 400, details: dict = None):
super().__init__(message)
self.message = message
self.status_code = status_code
self.details = details or {}
class NotFoundError(AppError):
def __init__(self, resource: str, resource_id):
super().__init__(f"{resource} with id '{resource_id}' not found", 404)
class ValidationError(AppError):
def __init__(self, details: dict):
super().__init__("Validation failed", 422, details)
def setup_error_handlers(app: Sanic) -> None:
@app.exception(AppError)
async def handle_app_error(request: Request, exception: AppError):
return json(
{
"error": exception.message,
"details": exception.details,
"request_id": getattr(request.ctx, "request_id", None),
},
status=exception.status_code,
)
@app.exception(SanicException)
async def handle_sanic_error(request: Request, exception: SanicException):
return json(
{
"error": exception.message,
"request_id": getattr(request.ctx, "request_id", None),
},
status=exception.status_code,
)
@app.exception(Exception)
async def handle_unexpected_error(request: Request, exception: Exception):
logger.exception("Unexpected error occurred")
return json(
{
"error": "Internal server error",
"request_id": getattr(request.ctx, "request_id", None),
},
status=500,
)
With this in place, route handlers can raise NotFoundError or ValidationError directly, and the global handler converts them into proper HTTP responses. This eliminates repetitive try/except blocks in every endpoint.
Dependency Injection via Application Context
Sanic's app.ctx is a powerful place to store shared resources like database session factories, Redis clients, and HTTP session pools. Initialize them once at startup and access them in handlers through the app instance.
# app/extensions.py
from sanic import Sanic
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from app.config import BaseConfig
def init_extensions(app: Sanic) -> None:
@app.listener("before_server_start")
async def setup_database(app: Sanic):
engine = create_async_engine(app.config.DATABASE_URL, echo=app.config.DEBUG)
app.ctx.db_engine = engine
app.ctx.db_session_factory = async_sessionmaker(
engine, expire_on_commit=False
)
@app.listener("after_server_stop")
async def close_database(app: Sanic):
if hasattr(app.ctx, "db_engine"):
await app.ctx.db_engine.dispose()
This pattern keeps resource lifecycle explicit. Resources are created when the server starts and cleaned up when it stops, preventing connection leaks across worker restarts.
Testing the Architecture
A well-structured app is straightforward to test. Use Sanic's built-in test client for integration tests and inject mock services for unit tests. The conftest.py file sets up fixtures that create a fresh app instance for each test session.
# tests/conftest.py
import pytest
from app.main import create_app
from app.config import TestingConfig
@pytest.fixture
def app():
app = create_app(TestingConfig)
return app
@pytest.fixture
def test_client(app):
return app.test_client
# tests/test_users.py
import pytest
@pytest.mark.asyncio
async def test_create_user_returns_201(test_client):
payload = {"name": "Alice", "email": "alice@example.com"}
response = await test_client.post("/api/v1/users", json=payload)
assert response.status == 201
body = response.json
assert body["user"]["name"] == "Alice"
assert body["user"]["email"] == "alice@example.com"
@pytest.mark.asyncio
async def test_create_user_rejects_invalid_email(test_client):
payload = {"name": "Alice", "email": "not-an-email"}
response = await test_client.post("/api/v1/users", json=payload)
assert response.status == 422
assert "email" in response.json["details"]
@pytest.mark.asyncio
async def test_get_nonexistent_user_returns_404(test_client):
response = await test_client.get("/api/v1/users/99999")
assert response.status == 404
Because the service layer accepts dependencies through its constructor, you can also write pure unit tests that bypass HTTP entirely, mocking the session factory to return predetermined data.
Best Practices Summary
- Keep route handlers thin. They should parse input, call a service, and return a response. No business logic.
- Use the application factory pattern. It makes testing and multi-environment deployments trivial.
- Group code by feature, not by layer. A
users/folder is easier to navigate than scatteredroutes/,services/, andmodels/directories. - Centralize error handling. Define custom exceptions and let global handlers format responses.
- Manage resources on app.ctx. Create database engines and clients at startup, dispose them at shutdown.
- Validate input at the boundary. Use schemas to reject malformed requests before they reach your services.
- Write integration and unit tests. Thin handlers and injected services make both levels of testing natural.
- Use middleware for cross-cutting concerns. Logging, request IDs, and auth belong in middleware, not in every handler.
- Version your API. Prefix blueprints with
/api/v1so you can introduce breaking changes gracefully. - Run multiple workers in production. Sanic's async model scales well, but multiple processes maximize CPU utilization.
Conclusion
Architecting a Sanic application well is about creating clear boundaries: HTTP transport in routes, business rules in services, data access in repositories, and cross-cutting concerns in middleware. By adopting the application factory pattern, organizing code by feature, and leveraging blueprints, dependency injection, and centralized error handling, you build a codebase that stays fast to develop in even as it grows. Sanic gives you the performance; a disciplined architecture gives you the longevity. Start with the structure outlined here, adapt it to your domain, and your team will thank you for every request that comes through.