← Back to DevBytes

Tortoise-ORM Architecture: Design Patterns and Project Structure

Introduction to Tortoise-ORM Architecture

Tortoise-ORM is an async ORM (Object-Relational Mapper) for Python, designed to work seamlessly with modern asynchronous frameworks like FastAPI, Starlette, Sanic, and aiohttp. Inspired by Django's ORM, Tortoise brings familiar patterns to the async world while maintaining a clean, modular architecture. Understanding its internal design and recommended project structure is essential for building maintainable, scalable applications.

Unlike synchronous ORMs that block the event loop during database operations, Tortoise-ORM leverages Python's asyncio to perform non-blocking I/O. This architectural choice makes it ideal for high-concurrency applications such as real-time APIs, chat systems, and microservices. In this tutorial, we'll explore the architectural foundations, design patterns, and a production-ready project structure.

Core Architecture Overview

At its core, Tortoise-ORM is built around several key components that work together to provide a complete ORM experience:

When you call Tortoise.init(), the runtime discovers all registered models, resolves foreign key references, builds the internal schema graph, and establishes connection pools. This initialization phase is critical — it's where the architectural pieces connect.

Key Design Patterns in Tortoise-ORM

1. Active Record Pattern

Tortoise-ORM primarily follows the Active Record pattern, where each model instance encapsulates both data and behavior. Instances know how to save themselves, delete themselves, and refresh from the database. This pattern keeps the API intuitive and reduces boilerplate.

from tortoise import fields
from tortoise.models import Model

class User(Model):
    id = fields.IntField(pk=True)
    email = fields.CharField(max_length=255, unique=True)
    name = fields.CharField(max_length=100)
    created_at = fields.DatetimeField(auto_now_add=True)

    class Meta:
        table = "users"

# Active Record usage
async def create_user():
    user = User(email="alice@example.com", name="Alice")
    await user.save()
    user.name = "Alice Smith"
    await user.save()
    await user.delete()

2. QuerySet Pattern (Lazy Evaluation)

The QuerySet pattern implements lazy evaluation. When you chain filter methods, no SQL is executed until you explicitly request results via await. This allows efficient query composition and avoids unnecessary database hits.

async def get_active_users():
    # No query executed yet — just building a QuerySet
    queryset = User.filter(is_active=True).exclude(name="admin")

    # Query executes only when we await
    users = await queryset.limit(10).offset(0)

    # Count executes a separate COUNT query
    total = await queryset.count()
    return users, total

3. Registry Pattern

Tortoise uses a global registry to track all models. When models are defined, they register themselves. The Tortoise.init() call finalizes this registry, resolving cross-model references and validating the schema. This pattern enables decoupled model definitions across multiple modules.

from tortoise import Tortoise

async def init_db():
    await Tortoise.init(
        db_url="postgres://user:pass@localhost:5432/mydb",
        modules={"models": ["app.models.user", "app.models.post"]},
    )
    await Tortoise.generate_schemas()

4. Strategy Pattern for Database Backends

Tortoise-ORM abstracts database-specific SQL generation behind a strategy interface. Whether you use PostgreSQL, MySQL, or SQLite, the same model definitions work. The appropriate dialect, connection driver, and SQL generator are selected at initialization based on the database URL scheme.

5. Observer Pattern via Signals

Tortoise supports signals (pre_save, post_save, pre_delete, post_delete) that act as an observer mechanism. This lets you hook into model lifecycle events without modifying core logic — useful for auditing, caching, and validation.

from tortoise.signals import post_save
from typing import Type

@post_save(User)
async def user_post_save(
    sender: Type[User], instance: User, created: bool, using_db, update_fields
):
    if created:
        print(f"New user registered: {instance.email}")
        # Send welcome email, update analytics, etc.

Recommended Project Structure

A well-organized project structure is crucial for maintainability. Below is a production-tested layout that separates concerns, keeps models modular, and integrates cleanly with frameworks like FastAPI.

my_project/
├── app/
│   ├── __init__.py
│   ├── main.py                  # Application entry point
│   ├── config.py                # Settings and configuration
│   ├── database.py              # Tortoise initialization
│   ├── models/
│   │   ├── __init__.py          # Model exports
│   │   ├── base.py              # Abstract base model
│   │   ├── user.py              # User model
│   │   ├── post.py              # Post model
│   │   └── comment.py           # Comment model
│   ├── schemas/                 # Pydantic schemas for serialization
│   │   ├── __init__.py
│   │   ├── user.py
│   │   └── post.py
│   ├── repositories/            # Data access layer
│   │   ├── __init__.py
│   │   ├── user_repo.py
│   │   └── post_repo.py
│   ├── services/                # Business logic layer
│   │   ├── __init__.py
│   │   ├── user_service.py
│   │   └── post_service.py
│   ├── api/                     # Route handlers
│   │   ├── __init__.py
│   │   ├── routes.py
│   │   └── deps.py              # Dependency injection
│   └── core/                    # Cross-cutting concerns
│       ├── __init__.py
│       ├── security.py
│       └── exceptions.py
├── tests/
│   ├── conftest.py
│   ├── test_user_repo.py
│   └── test_user_service.py
├── alembic/                     # Migrations (via Aerich)
├── pyproject.toml
└── .env

Database Initialization Module

Centralizing database setup in a dedicated module keeps configuration clean and testable.

# app/database.py
from tortoise import Tortoise
from app.config import settings

async def init_db() -> None:
    await Tortoise.init(
        db_url=settings.DATABASE_URL,
        modules={"models": [
            "app.models.user",
            "app.models.post",
            "app.models.comment",
        ]},
        use_tz=True,
        timezone="UTC",
    )

async def close_db() -> None:
    await Tortoise.close_connections()

Abstract Base Model

Defining a shared base model reduces duplication and enforces consistency across all entities. Common fields like timestamps and soft-delete flags belong here.

# app/models/base.py
from tortoise import fields
from tortoise.models import Model

class TimestampedModel(Model):
    created_at = fields.DatetimeField(auto_now_add=True)
    updated_at = fields.DatetimeField(auto_now=True)

    class Meta:
        abstract = True

Model Definitions with Relationships

# app/models/user.py
from tortoise import fields
from app.models.base import TimestampedModel

class User(TimestampedModel):
    id = fields.IntField(pk=True)
    email = fields.CharField(max_length=255, unique=True)
    name = fields.CharField(max_length=100)
    is_active = fields.BooleanField(default=True)

    # Reverse relation — posts created by this user
    posts: fields.ReverseRelation["Post"]

    class Meta:
        table = "users"
        indexes = [("email", "is_active")]
# app/models/post.py
from tortoise import fields
from app.models.base import TimestampedModel
from app.models.user import User

class Post(TimestampedModel):
    id = fields.IntField(pk=True)
    title = fields.CharField(max_length=200)
    content = fields.TextField()
    published = fields.BooleanField(default=False)

    author: fields.ForeignKeyRelation[User] = fields.ForeignKeyField(
        "models.User",
        related_name="posts",
        on_delete=fields.CASCADE,
    )
    comments: fields.ReverseRelation["Comment"]

    class Meta:
        table = "posts"

Repository Pattern for Data Access

While Tortoise provides a rich QuerySet API directly on models, wrapping data access in a repository layer decouples your business logic from the ORM. This makes testing easier and centralizes query logic.

# app/repositories/user_repo.py
from typing import Optional
from app.models.user import User

class UserRepository:
    @staticmethod
    async def get_by_id(user_id: int) -> Optional[User]:
        return await User.filter(id=user_id).first()

    @staticmethod
    async def get_by_email(email: str) -> Optional[User]:
        return await User.filter(email=email).first()

    @staticmethod
    async def create(email: str, name: str) -> User:
        return await User.create(email=email, name=name)

    @staticmethod
    async def list_active(limit: int = 50, offset: int = 0):
        return await User.filter(is_active=True).limit(limit).offset(offset)

    @staticmethod
    async def deactivate(user_id: int) -> int:
        # Returns number of rows updated
        return await User.filter(id=user_id).update(is_active=False)

Service Layer for Business Logic

The service layer orchestrates business rules, combining repository calls with domain logic. This is where validation, authorization checks, and cross-entity operations live.

# app/services/user_service.py
from app.repositories.user_repo import UserRepository
from app.core.exceptions import UserAlreadyExistsError, UserNotFoundError

class UserService:
    def __init__(self, repo: UserRepository = UserRepository()):
        self.repo = repo

    async def register_user(self, email: str, name: str):
        existing = await self.repo.get_by_email(email)
        if existing:
            raise UserAlreadyExistsError(f"Email {email} already registered")

        return await self.repo.create(email=email, name=name)

    async def get_user_profile(self, user_id: int):
        user = await self.repo.get_by_id(user_id)
        if not user:
            raise UserNotFoundError(f"User {user_id} not found")
        return user

    async def deactivate_user(self, user_id: int) -> None:
        updated = await self.repo.deactivate(user_id)
        if updated == 0:
            raise UserNotFoundError(f"User {user_id} not found")

Integrating with FastAPI

Tortoise-ORM pairs naturally with FastAPI. Initialize the database on startup and close connections on shutdown using lifespan events.

# app/main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
from app.database import init_db, close_db
from app.api.routes import router

@asynccontextmanager
async def lifespan(app: FastAPI):
    await init_db()
    yield
    await close_db()

app = FastAPI(title="My API", lifespan=lifespan)
app.include_router(router, prefix="/api")
# app/api/routes.py
from fastapi import APIRouter, Depends, HTTPException
from app.services.user_service import UserService
from app.schemas.user import UserCreate, UserOut

router = APIRouter()

def get_user_service() -> UserService:
    return UserService()

@router.post("/users", response_model=UserOut)
async def create_user(
    payload: UserCreate,
    service: UserService = Depends(get_user_service),
):
    try:
        user = await service.register_user(payload.email, payload.name)
        return UserOut.from_orm(user)
    except Exception as e:
        raise HTTPException(status_code=400, detail=str(e))

Pydantic Schemas for Serialization

Tortoise-ORM provides pydantic_model_creator for automatic schema generation, but defining explicit schemas gives you more control over what's exposed in your API.

# app/schemas/user.py
from pydantic import BaseModel, EmailStr
from app.models.user import User

class UserCreate(BaseModel):
    email: EmailStr
    name: str

class UserOut(BaseModel):
    id: int
    email: str
    name: str
    is_active: bool

    class Config:
        from_attributes = True

# Alternative: auto-generated schema
User_Pydantic = User.get_pydantic()

Transactions and Atomic Operations

Tortoise-ORM supports transactions through the in_transaction context manager. Use it whenever multiple writes must succeed or fail together.

from tortoise.transactions import in_transaction

async def transfer_ownership(post_id: int, new_owner_id: int):
    async with in_transaction():
        post = await Post.get(id=post_id).select_for_update()
        new_owner = await User.get(id=new_owner_id)
        post.author = new_owner
        await post.save()
        # If anything above fails, the entire transaction rolls back

Best Practices

Handling N+1 Queries

# Bad: N+1 queries
posts = await Post.all()
for post in posts:
    print(post.author.name)  # Triggers a query per post

# Good: Single query with JOIN
posts = await Post.all().select_related("author")
for post in posts:
    print(post.author.name)  # No extra queries

# Good for many-to-many: prefetch
posts = await Post.all().prefetch_related("comments")

Testing Strategy

Use pytest with pytest-asyncio for async test support. Initialize a test database in conftest.py and clean up between tests.

# tests/conftest.py
import pytest
from tortoise import Tortoise
from app.config import settings

@pytest.fixture(scope="session")
def event_loop():
    import asyncio
    loop = asyncio.new_event_loop()
    yield loop
    loop.close()

@pytest.fixture(scope="session", autouse=True)
async def initialize_db():
    await Tortoise.init(
        db_url="sqlite://:memory:",
        modules={"models": ["app.models.user", "app.models.post"]},
    )
    await Tortoise.generate_schemas()
    yield
    await Tortoise.close_connections()

@pytest.fixture(autouse=True)
async def clean_tables():
    from app.models.user import User
    from app.models.post import Post
    await Post.all().delete()
    await User.all().delete()
    yield
# tests/test_user_service.py
import pytest
from app.services.user_service import UserService

@pytest.mark.asyncio
async def test_register_user():
    service = UserService()
    user = await service.register_user("test@example.com", "Test User")
    assert user.email == "test@example.com"
    assert user.is_active is True

@pytest.mark.asyncio
async def test_duplicate_email_raises():
    service = UserService()
    await service.register_user("dup@example.com", "First")
    with pytest.raises(Exception):
        await service.register_user("dup@example.com", "Second")

Conclusion

Tortoise-ORM's architecture elegantly combines the familiar Active Record pattern with async-first design, making it a powerful choice for modern Python applications. By understanding its core components — the model meta system, lazy QuerySets, the global registry, and pluggable database backends — you can leverage its full potential. Pairing the ORM with a layered project structure (models, repositories, services, and API routes) produces code that is testable, maintainable, and scalable. Following the best practices outlined here — from transaction management and N+1 prevention to proper migration handling and testing isolation — will help you build robust async applications that stand the test of time. Whether you're building a small API or a complex microservice ecosystem, Tortoise-ORM provides the architectural foundation to do it well.

— Ad —

Google AdSense will appear here after approval

← Back to all articles