← Back to DevBytes

Sanic Architecture: Design Patterns and Project Structure

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:

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

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles