Project 9: Database ORM Integration

Project 9: Database ORM Integration

Build a data layer using SQLModel (Pydantic + SQLAlchemy) that shares models between API validation and database operations, with migrations and relationship handling.


Learning Objectives

By completing this project, you will:

  1. Understand SQLModelโ€™s unified architecture - How it combines Pydantic and SQLAlchemy into a single model
  2. Master table=True vs table=False patterns - When to create database tables vs validation-only models
  3. Handle relationships correctly - Foreign keys, one-to-many, many-to-many with proper loading strategies
  4. Implement the repository pattern - Clean separation between domain logic and data access
  5. Manage database migrations with Alembic - Schema evolution without data loss
  6. Use async database operations - Non-blocking database access for high-performance APIs

Deep Theoretical Foundation

Why SQLModel? The Unification Story

Before SQLModel, developers faced a frustrating duplication problem:

WITHOUT SQLModel (The Pain):
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                                                                             โ”‚
โ”‚  # Pydantic model for API validation                                       โ”‚
โ”‚  class UserCreate(BaseModel):                                              โ”‚
โ”‚      email: str                                                            โ”‚
โ”‚      name: str                                                             โ”‚
โ”‚      password: str                                                         โ”‚
โ”‚                                                                             โ”‚
โ”‚  # SQLAlchemy model for database                                           โ”‚
โ”‚  class User(Base):                                                         โ”‚
โ”‚      __tablename__ = "users"                                               โ”‚
โ”‚      id = Column(Integer, primary_key=True)                                โ”‚
โ”‚      email = Column(String, unique=True)       โ—„โ”€โ”€ Duplicated!            โ”‚
โ”‚      name = Column(String)                      โ—„โ”€โ”€ Duplicated!            โ”‚
โ”‚      hashed_password = Column(String)                                      โ”‚
โ”‚                                                                             โ”‚
โ”‚  # You need ANOTHER model for responses                                    โ”‚
โ”‚  class UserResponse(BaseModel):                                            โ”‚
โ”‚      id: int                                                               โ”‚
โ”‚      email: str                                  โ—„โ”€โ”€ Duplicated again!     โ”‚
โ”‚      name: str                                   โ—„โ”€โ”€ Duplicated again!     โ”‚
โ”‚                                                                             โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

WITH SQLModel (The Solution):
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                                                                             โ”‚
โ”‚  class UserBase(SQLModel):                                                 โ”‚
โ”‚      email: EmailStr                             # Shared fields           โ”‚
โ”‚      name: str                                                             โ”‚
โ”‚                                                                             โ”‚
โ”‚  class User(UserBase, table=True):               # Database table          โ”‚
โ”‚      id: Optional[int] = Field(primary_key=True)                          โ”‚
โ”‚      hashed_password: str                                                  โ”‚
โ”‚                                                                             โ”‚
โ”‚  class UserCreate(UserBase):                     # API input               โ”‚
โ”‚      password: str                                                         โ”‚
โ”‚                                                                             โ”‚
โ”‚  class UserRead(UserBase):                       # API output              โ”‚
โ”‚      id: int                                                               โ”‚
โ”‚                                                                             โ”‚
โ”‚  # Same field definitions, different purposes!                             โ”‚
โ”‚                                                                             โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

SQLModel Comparison

SQLModel was created by Sebastiรกn Ramรญrez (creator of FastAPI) to solve this exact problem. It inherits from both Pydanticโ€™s BaseModel and SQLAlchemyโ€™s declarative base.

Understanding table=True vs table=False

The table parameter is the key that determines whether a SQLModel class creates a database table:

from sqlmodel import SQLModel, Field
from typing import Optional

# table=False (default): Pure Pydantic validation model
class UserBase(SQLModel):
    """No database table created. Used for validation only."""
    email: str
    name: str
    # This class is essentially a Pydantic BaseModel

# table=True: Creates a database table
class User(UserBase, table=True):
    """Database table 'user' is created with these columns."""
    id: Optional[int] = Field(default=None, primary_key=True)
    hashed_password: str
    # This class maps to a real database table

What happens internally:

When table=False (default):
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  class UserBase(SQLModel):                               โ”‚
โ”‚                                                          โ”‚
โ”‚  Pydantic Features:                                      โ”‚
โ”‚    โœ“ Type validation                                    โ”‚
โ”‚    โœ“ JSON serialization                                 โ”‚
โ”‚    โœ“ Schema generation                                  โ”‚
โ”‚                                                          โ”‚
โ”‚  SQLAlchemy Features:                                    โ”‚
โ”‚    โœ— No __tablename__                                   โ”‚
โ”‚    โœ— No Column() mappings                               โ”‚
โ”‚    โœ— No relationship() support                          โ”‚
โ”‚    โœ— Not registered with MetaData                       โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

When table=True:
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  class User(SQLModel, table=True):                       โ”‚
โ”‚                                                          โ”‚
โ”‚  Pydantic Features:                                      โ”‚
โ”‚    โœ“ Type validation                                    โ”‚
โ”‚    โœ“ JSON serialization                                 โ”‚
โ”‚    โœ“ Schema generation                                  โ”‚
โ”‚                                                          โ”‚
โ”‚  SQLAlchemy Features:                                    โ”‚
โ”‚    โœ“ __tablename__ = "user" (auto-generated)           โ”‚
โ”‚    โœ“ id โ†’ Column(Integer, primary_key=True)            โ”‚
โ”‚    โœ“ email โ†’ Column(String)                            โ”‚
โ”‚    โœ“ Registered with MetaData                          โ”‚
โ”‚    โœ“ Can use Relationship()                            โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

The Model Inheritance Pattern

The recommended pattern for SQLModel projects:

                    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                    โ”‚      UserBase       โ”‚
                    โ”‚   (table=False)     โ”‚
                    โ”‚                     โ”‚
                    โ”‚  - email: str       โ”‚
                    โ”‚  - name: str        โ”‚
                    โ”‚  - is_active: bool  โ”‚
                    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                               โ”‚
           โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
           โ”‚                   โ”‚                   โ”‚
           โ–ผ                   โ–ผ                   โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚   UserCreate    โ”‚  โ”‚      User       โ”‚  โ”‚    UserRead     โ”‚
โ”‚ (table=False)   โ”‚  โ”‚  (table=True)   โ”‚  โ”‚ (table=False)   โ”‚
โ”‚                 โ”‚  โ”‚                 โ”‚  โ”‚                 โ”‚
โ”‚ Inherits base + โ”‚  โ”‚ Inherits base + โ”‚  โ”‚ Inherits base + โ”‚
โ”‚ + password: str โ”‚  โ”‚ + id: int       โ”‚  โ”‚ + id: int       โ”‚
โ”‚                 โ”‚  โ”‚ + hashed_pwd    โ”‚  โ”‚ + created_at    โ”‚
โ”‚                 โ”‚  โ”‚ + created_at    โ”‚  โ”‚                 โ”‚
โ”‚ Used for:       โ”‚  โ”‚ + orders: list  โ”‚  โ”‚ Used for:       โ”‚
โ”‚ POST /users     โ”‚  โ”‚                 โ”‚  โ”‚ GET /users/{id} โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ”‚ Database table  โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                     โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Model Inheritance Pattern

Relationship Handling and Foreign Keys

SQLModel supports SQLAlchemy-style relationships:

from sqlmodel import SQLModel, Field, Relationship
from typing import Optional, List

class Team(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    name: str

    # One-to-many: A team has many heroes
    heroes: List["Hero"] = Relationship(back_populates="team")

class Hero(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    name: str

    # Foreign key column
    team_id: Optional[int] = Field(default=None, foreign_key="team.id")

    # Relationship back to Team
    team: Optional[Team] = Relationship(back_populates="heroes")

Relationship types:

ONE-TO-MANY (User has many Orders):
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”         โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚    User     โ”‚ 1     N โ”‚    Order    โ”‚
โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚โ—„โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚
โ”‚ id (PK)     โ”‚         โ”‚ id (PK)     โ”‚
โ”‚ name        โ”‚         โ”‚ user_id (FK)โ”‚
โ”‚             โ”‚         โ”‚ total       โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜         โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

class User(SQLModel, table=True):
    id: Optional[int] = Field(primary_key=True)
    orders: List["Order"] = Relationship(back_populates="user")

class Order(SQLModel, table=True):
    id: Optional[int] = Field(primary_key=True)
    user_id: int = Field(foreign_key="user.id")
    user: Optional[User] = Relationship(back_populates="orders")


MANY-TO-MANY (Users have many Roles, Roles have many Users):
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”         โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”         โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚    User     โ”‚ N     M โ”‚  UserRole   โ”‚ M     N โ”‚    Role     โ”‚
โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚โ—„โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บโ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚
โ”‚ id (PK)     โ”‚         โ”‚ user_id (FK)โ”‚         โ”‚ id (PK)     โ”‚
โ”‚ name        โ”‚         โ”‚ role_id (FK)โ”‚         โ”‚ name        โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜         โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜         โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

class UserRoleLink(SQLModel, table=True):
    user_id: int = Field(foreign_key="user.id", primary_key=True)
    role_id: int = Field(foreign_key="role.id", primary_key=True)

class User(SQLModel, table=True):
    id: Optional[int] = Field(primary_key=True)
    roles: List["Role"] = Relationship(
        back_populates="users",
        link_model=UserRoleLink
    )

class Role(SQLModel, table=True):
    id: Optional[int] = Field(primary_key=True)
    users: List["User"] = Relationship(
        back_populates="roles",
        link_model=UserRoleLink
    )

The Repository Pattern

The repository pattern abstracts database operations behind a clean interface:

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                            API Layer                                     โ”‚
โ”‚                                                                          โ”‚
โ”‚  @app.post("/users")                                                    โ”‚
โ”‚  async def create_user(user: UserCreate):                               โ”‚
โ”‚      return user_repo.create(user)                                      โ”‚
โ”‚                                                                          โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                                      โ”‚
                                      โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                        Repository Layer                                  โ”‚
โ”‚                                                                          โ”‚
โ”‚  class UserRepository:                                                   โ”‚
โ”‚      def create(self, data: UserCreate) -> User: ...                    โ”‚
โ”‚      def get_by_id(self, id: int) -> Optional[User]: ...               โ”‚
โ”‚      def list(self, skip: int, limit: int) -> List[User]: ...          โ”‚
โ”‚      def update(self, id: int, data: UserUpdate) -> User: ...          โ”‚
โ”‚      def delete(self, id: int) -> bool: ...                            โ”‚
โ”‚                                                                          โ”‚
โ”‚  Benefits:                                                               โ”‚
โ”‚    - Testable (can mock the repository)                                 โ”‚
โ”‚    - Swappable (can change database without changing API)               โ”‚
โ”‚    - Clean (business logic doesn't know about SQL)                      โ”‚
โ”‚                                                                          โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                                      โ”‚
                                      โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                         Database Layer                                   โ”‚
โ”‚                                                                          โ”‚
โ”‚  SQLModel Session                                                        โ”‚
โ”‚    - session.add(user)                                                  โ”‚
โ”‚    - session.commit()                                                   โ”‚
โ”‚    - session.exec(select(User).where(...))                             โ”‚
โ”‚                                                                          โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Database Migrations with Alembic

Alembic manages schema changes over time:

Initial State:                    After Migration:
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”           โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚       users         โ”‚           โ”‚       users         โ”‚
โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚   โ”€โ”€โ–บ     โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚
โ”‚ id: INTEGER (PK)    โ”‚           โ”‚ id: INTEGER (PK)    โ”‚
โ”‚ email: VARCHAR      โ”‚           โ”‚ email: VARCHAR      โ”‚
โ”‚ name: VARCHAR       โ”‚           โ”‚ name: VARCHAR       โ”‚
โ”‚                     โ”‚           โ”‚ created_at: DATETIMEโ”‚ โ—„โ”€โ”€ NEW!
โ”‚                     โ”‚           โ”‚ is_active: BOOLEAN  โ”‚ โ—„โ”€โ”€ NEW!
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜           โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Migration file (versions/001_add_user_fields.py):

def upgrade():
    op.add_column('users', sa.Column('created_at', sa.DateTime()))
    op.add_column('users', sa.Column('is_active', sa.Boolean(), default=True))

def downgrade():
    op.drop_column('users', 'created_at')
    op.drop_column('users', 'is_active')

Alembic workflow:

# Initialize Alembic
alembic init alembic

# Generate migration from model changes
alembic revision --autogenerate -m "Add user fields"

# Apply migrations
alembic upgrade head

# Rollback
alembic downgrade -1

# View migration history
alembic history

Async Database Operations

For high-performance APIs, use async database operations:

from sqlmodel import SQLModel
from sqlmodel.ext.asyncio.session import AsyncSession
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker

# Async engine
DATABASE_URL = "postgresql+asyncpg://user:pass@localhost/db"
engine = create_async_engine(DATABASE_URL, echo=True)

# Async session factory
async_session = async_sessionmaker(
    engine,
    class_=AsyncSession,
    expire_on_commit=False
)

# Usage in FastAPI
async def get_session() -> AsyncSession:
    async with async_session() as session:
        yield session

@app.get("/users/{user_id}")
async def get_user(
    user_id: int,
    session: AsyncSession = Depends(get_session)
):
    result = await session.exec(
        select(User).where(User.id == user_id)
    )
    user = result.first()
    if not user:
        raise HTTPException(404)
    return user

Sync vs Async comparison:

SYNCHRONOUS (Blocking):
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Request 1   โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ โ”‚
โ”‚  Request 2   โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘ โ”‚
โ”‚  Request 3   โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–ˆ โ”‚
โ”‚                                                              โ”‚
โ”‚  Each request blocks until database responds                โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

ASYNCHRONOUS (Non-blocking):
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Request 1   โ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–ˆ               โ”‚
โ”‚  Request 2   โ–‘โ–‘โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–ˆ           โ”‚
โ”‚  Request 3   โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–ˆ       โ”‚
โ”‚                                                              โ”‚
โ”‚  Requests interleave during I/O waits                       โ”‚
โ”‚  Same time, more throughput!                                โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

from_attributes for ORM Compatibility

When returning ORM objects from FastAPI, you need from_attributes:

from pydantic import ConfigDict
from sqlmodel import SQLModel, Field
from typing import Optional

class UserRead(SQLModel):
    """Response model that can read from ORM objects."""
    id: int
    email: str
    name: str

    # This allows Pydantic to read from SQLAlchemy objects
    model_config = ConfigDict(from_attributes=True)

# Now this works:
@app.get("/users/{user_id}", response_model=UserRead)
async def get_user(user_id: int, session: Session = Depends(get_session)):
    user = session.get(User, user_id)  # Returns SQLModel table instance
    return user  # Automatically converted to UserRead

How from_attributes works:

Without from_attributes=True:
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                                                              โ”‚
โ”‚  User(table=True) instance                                  โ”‚
โ”‚  โ”œโ”€โ”€ id = 1                                                 โ”‚
โ”‚  โ”œโ”€โ”€ email = "john@example.com"                            โ”‚
โ”‚  โ””โ”€โ”€ name = "John"                                         โ”‚
โ”‚                                                              โ”‚
โ”‚           โ–ผ Try to create UserRead                          โ”‚
โ”‚                                                              โ”‚
โ”‚  ERROR: Input should be a valid dictionary                  โ”‚
โ”‚         (Pydantic expects dict, not ORM object)             โ”‚
โ”‚                                                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

With from_attributes=True:
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                                                              โ”‚
โ”‚  User(table=True) instance                                  โ”‚
โ”‚  โ”œโ”€โ”€ id = 1                                                 โ”‚
โ”‚  โ”œโ”€โ”€ email = "john@example.com"                            โ”‚
โ”‚  โ””โ”€โ”€ name = "John"                                         โ”‚
โ”‚                                                              โ”‚
โ”‚           โ–ผ Pydantic reads attributes                       โ”‚
โ”‚                                                              โ”‚
โ”‚  UserRead(                                                  โ”‚
โ”‚      id=getattr(user, 'id'),                               โ”‚
โ”‚      email=getattr(user, 'email'),                         โ”‚
โ”‚      name=getattr(user, 'name')                            โ”‚
โ”‚  )                                                          โ”‚
โ”‚                                                              โ”‚
โ”‚  SUCCESS: UserRead(id=1, email="john@example.com", ...)    โ”‚
โ”‚                                                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Project Specification

Functional Requirements

Build a complete e-commerce data layer that demonstrates:

  1. User management with authentication fields
  2. Product catalog with categories and inventory
  3. Order processing with line items and status tracking
  4. Full CRUD through a repository pattern
  5. Database migrations with Alembic
  6. Both sync and async database operations

Data Model

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                                                                              โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”       โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”       โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”        โ”‚
โ”‚  โ”‚    Category   โ”‚ 1   N โ”‚    Product     โ”‚       โ”‚     User      โ”‚        โ”‚
โ”‚  โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚โ—„โ”€โ”€โ”€โ”€โ”€โ”€โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚       โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚        โ”‚
โ”‚  โ”‚ id (PK)       โ”‚       โ”‚ id (PK)        โ”‚       โ”‚ id (PK)       โ”‚        โ”‚
โ”‚  โ”‚ name          โ”‚       โ”‚ name           โ”‚       โ”‚ email (unique)โ”‚        โ”‚
โ”‚  โ”‚ description   โ”‚       โ”‚ description    โ”‚       โ”‚ name          โ”‚        โ”‚
โ”‚  โ”‚ created_at    โ”‚       โ”‚ price          โ”‚       โ”‚ hashed_pwd    โ”‚        โ”‚
โ”‚  โ”‚               โ”‚       โ”‚ stock_quantity โ”‚       โ”‚ is_active     โ”‚        โ”‚
โ”‚  โ”‚               โ”‚       โ”‚ category_id(FK)โ”‚       โ”‚ created_at    โ”‚        โ”‚
โ”‚  โ”‚               โ”‚       โ”‚ created_at     โ”‚       โ”‚               โ”‚        โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜       โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜       โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜        โ”‚
โ”‚                                  โ”‚                        โ”‚                 โ”‚
โ”‚                                  โ”‚ N                      โ”‚ 1               โ”‚
โ”‚                                  โ”‚                        โ”‚                 โ”‚
โ”‚                                  โ–ผ                        โ–ผ                 โ”‚
โ”‚                         โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”       โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”         โ”‚
โ”‚                         โ”‚   OrderItem    โ”‚ N   1 โ”‚     Order     โ”‚         โ”‚
โ”‚                         โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ–บโ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚         โ”‚
โ”‚                         โ”‚ id (PK)        โ”‚       โ”‚ id (PK)       โ”‚         โ”‚
โ”‚                         โ”‚ order_id (FK)  โ”‚       โ”‚ user_id (FK)  โ”‚         โ”‚
โ”‚                         โ”‚ product_id(FK) โ”‚       โ”‚ status        โ”‚         โ”‚
โ”‚                         โ”‚ quantity       โ”‚       โ”‚ total_amount  โ”‚         โ”‚
โ”‚                         โ”‚ unit_price     โ”‚       โ”‚ created_at    โ”‚         โ”‚
โ”‚                         โ”‚                โ”‚       โ”‚ updated_at    โ”‚         โ”‚
โ”‚                         โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜       โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜         โ”‚
โ”‚                                                                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

API Endpoints

Categories:
  POST   /categories                     Create category
  GET    /categories                     List categories
  GET    /categories/{id}                Get category with products
  PATCH  /categories/{id}                Update category
  DELETE /categories/{id}                Delete category

Products:
  POST   /products                       Create product
  GET    /products                       List products (with filters)
  GET    /products/{id}                  Get product details
  PATCH  /products/{id}                  Update product
  DELETE /products/{id}                  Delete product
  POST   /products/{id}/stock            Adjust stock

Users:
  POST   /users                          Register user
  GET    /users/{id}                     Get user profile
  PATCH  /users/{id}                     Update user
  DELETE /users/{id}                     Deactivate user

Orders:
  POST   /orders                         Create order
  GET    /orders                         List orders (with filters)
  GET    /orders/{id}                    Get order with items
  PATCH  /orders/{id}/status             Update order status
  DELETE /orders/{id}                    Cancel order

Solution Architecture

Project Structure

ecommerce-sqlmodel/
โ”œโ”€โ”€ app/
โ”‚   โ”œโ”€โ”€ __init__.py
โ”‚   โ”œโ”€โ”€ main.py                   # FastAPI application
โ”‚   โ”œโ”€โ”€ config.py                 # Settings with pydantic-settings
โ”‚   โ”‚
โ”‚   โ”œโ”€โ”€ models/                   # SQLModel models
โ”‚   โ”‚   โ”œโ”€โ”€ __init__.py
โ”‚   โ”‚   โ”œโ”€โ”€ base.py               # Base classes and common fields
โ”‚   โ”‚   โ”œโ”€โ”€ user.py               # User models (table + schemas)
โ”‚   โ”‚   โ”œโ”€โ”€ category.py           # Category models
โ”‚   โ”‚   โ”œโ”€โ”€ product.py            # Product models
โ”‚   โ”‚   โ””โ”€โ”€ order.py              # Order and OrderItem models
โ”‚   โ”‚
โ”‚   โ”œโ”€โ”€ repositories/             # Data access layer
โ”‚   โ”‚   โ”œโ”€โ”€ __init__.py
โ”‚   โ”‚   โ”œโ”€โ”€ base.py               # Generic repository base
โ”‚   โ”‚   โ”œโ”€โ”€ user_repository.py
โ”‚   โ”‚   โ”œโ”€โ”€ category_repository.py
โ”‚   โ”‚   โ”œโ”€โ”€ product_repository.py
โ”‚   โ”‚   โ””โ”€โ”€ order_repository.py
โ”‚   โ”‚
โ”‚   โ”œโ”€โ”€ routes/                   # API routes
โ”‚   โ”‚   โ”œโ”€โ”€ __init__.py
โ”‚   โ”‚   โ”œโ”€โ”€ users.py
โ”‚   โ”‚   โ”œโ”€โ”€ categories.py
โ”‚   โ”‚   โ”œโ”€โ”€ products.py
โ”‚   โ”‚   โ””โ”€โ”€ orders.py
โ”‚   โ”‚
โ”‚   โ”œโ”€โ”€ services/                 # Business logic
โ”‚   โ”‚   โ”œโ”€โ”€ __init__.py
โ”‚   โ”‚   โ”œโ”€โ”€ user_service.py
โ”‚   โ”‚   โ”œโ”€โ”€ product_service.py
โ”‚   โ”‚   โ””โ”€โ”€ order_service.py
โ”‚   โ”‚
โ”‚   โ””โ”€โ”€ db/                       # Database configuration
โ”‚       โ”œโ”€โ”€ __init__.py
โ”‚       โ”œโ”€โ”€ engine.py             # Engine and session
โ”‚       โ””โ”€โ”€ init_db.py            # Database initialization
โ”‚
โ”œโ”€โ”€ alembic/                      # Migrations
โ”‚   โ”œโ”€โ”€ versions/
โ”‚   โ”œโ”€โ”€ env.py
โ”‚   โ””โ”€โ”€ alembic.ini
โ”‚
โ”œโ”€โ”€ tests/
โ”‚   โ”œโ”€โ”€ __init__.py
โ”‚   โ”œโ”€โ”€ conftest.py               # Fixtures
โ”‚   โ”œโ”€โ”€ test_models.py
โ”‚   โ”œโ”€โ”€ test_repositories.py
โ”‚   โ””โ”€โ”€ test_api.py
โ”‚
โ”œโ”€โ”€ pyproject.toml
โ””โ”€โ”€ README.md

Component Diagram

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                                  main.py                                     โ”‚
โ”‚  - FastAPI app                                                               โ”‚
โ”‚  - Exception handlers                                                        โ”‚
โ”‚  - Lifespan events (DB init)                                                โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                                      โ”‚
              โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
              โ–ผ                       โ–ผ                       โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚    routes/users.py    โ”‚ โ”‚  routes/products.py   โ”‚ โ”‚   routes/orders.py    โ”‚
โ”‚  - Input validation   โ”‚ โ”‚  - Input validation   โ”‚ โ”‚  - Input validation   โ”‚
โ”‚  - HTTP handling      โ”‚ โ”‚  - Query params       โ”‚ โ”‚  - Status transitions โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
            โ”‚                         โ”‚                         โ”‚
            โ–ผ                         โ–ผ                         โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  services/user_svc.py โ”‚ โ”‚ services/product_svc  โ”‚ โ”‚  services/order_svc   โ”‚
โ”‚  - Business rules     โ”‚ โ”‚ - Stock management    โ”‚ โ”‚  - Order creation     โ”‚
โ”‚  - Password hashing   โ”‚ โ”‚ - Price calculation   โ”‚ โ”‚  - Total calculation  โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
            โ”‚                         โ”‚                         โ”‚
            โ–ผ                         โ–ผ                         โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                           repositories/                                       โ”‚
โ”‚  - UserRepository      - ProductRepository      - OrderRepository            โ”‚
โ”‚  - Generic CRUD        - Stock queries          - Order with items           โ”‚
โ”‚  - SQL abstraction     - Category filters       - Status updates             โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                                      โ”‚
                                      โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                              db/engine.py                                    โ”‚
โ”‚  - SQLModel engine          - Session factory          - Async support      โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                                      โ”‚
                                      โ–ผ
                             โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                             โ”‚   PostgreSQL    โ”‚
                             โ”‚    Database     โ”‚
                             โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Data Flow for Order Creation

POST /orders
{
  "user_id": 1,
  "items": [
    {"product_id": 101, "quantity": 2},
    {"product_id": 102, "quantity": 1}
  ]
}
         โ”‚
         โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Route: create_order(order_create: OrderCreate)                              โ”‚
โ”‚  - Validate input with Pydantic                                             โ”‚
โ”‚  - Call OrderService                                                         โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                                      โ”‚
                                      โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  OrderService.create_order()                                                 โ”‚
โ”‚  1. Verify user exists                                                       โ”‚
โ”‚  2. Verify products exist and have stock                                    โ”‚
โ”‚  3. Calculate total from product prices ร— quantities                        โ”‚
โ”‚  4. Create Order and OrderItems in transaction                              โ”‚
โ”‚  5. Deduct stock from products                                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                                      โ”‚
                                      โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  OrderRepository.create_with_items(order, items)                             โ”‚
โ”‚  session.begin()                                                             โ”‚
โ”‚  session.add(order)                                                          โ”‚
โ”‚  session.add_all(order_items)                                               โ”‚
โ”‚  session.commit()                                                            โ”‚
โ”‚  session.refresh(order)                                                      โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                                      โ”‚
                                      โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Response: OrderRead                                                         โ”‚
โ”‚  {                                                                           โ”‚
โ”‚    "id": 1,                                                                  โ”‚
โ”‚    "user_id": 1,                                                            โ”‚
โ”‚    "status": "pending",                                                      โ”‚
โ”‚    "total_amount": 149.97,                                                  โ”‚
โ”‚    "items": [                                                                โ”‚
โ”‚      {"product_id": 101, "quantity": 2, "unit_price": 49.99},              โ”‚
โ”‚      {"product_id": 102, "quantity": 1, "unit_price": 49.99}               โ”‚
โ”‚    ]                                                                         โ”‚
โ”‚  }                                                                           โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Phased Implementation Guide

Phase 1: Project Setup and Base Models (1-2 hours)

Goal: Set up the project structure with database connection.

Step 1: Create project and install dependencies:

mkdir ecommerce-sqlmodel && cd ecommerce-sqlmodel
python -m venv venv
source venv/bin/activate

pip install fastapi uvicorn sqlmodel alembic asyncpg psycopg2-binary pydantic-settings python-dotenv

Step 2: Create database configuration:

# app/config.py
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    database_url: str = "postgresql://user:pass@localhost/ecommerce"
    database_echo: bool = False

    class Config:
        env_file = ".env"

settings = Settings()

Step 3: Create database engine:

# app/db/engine.py
from sqlmodel import SQLModel, create_engine, Session
from app.config import settings

engine = create_engine(settings.database_url, echo=settings.database_echo)

def get_session():
    with Session(engine) as session:
        yield session

def create_db_and_tables():
    SQLModel.metadata.create_all(engine)

Step 4: Create base model:

# app/models/base.py
from sqlmodel import SQLModel, Field
from datetime import datetime
from typing import Optional

class TimestampMixin(SQLModel):
    """Mixin for created_at and updated_at fields."""
    created_at: datetime = Field(default_factory=datetime.utcnow)
    updated_at: Optional[datetime] = Field(default=None)

Checkpoint: Can connect to database and create tables.

Phase 2: User Models and Repository (2 hours)

Goal: Implement complete user management with the repository pattern.

Step 1: Create user models:

# app/models/user.py
from sqlmodel import SQLModel, Field, Relationship
from pydantic import EmailStr, ConfigDict
from typing import Optional, List, TYPE_CHECKING
from datetime import datetime

if TYPE_CHECKING:
    from .order import Order

# Base fields shared across models
class UserBase(SQLModel):
    email: EmailStr = Field(unique=True, index=True)
    name: str = Field(min_length=1, max_length=100)
    is_active: bool = Field(default=True)

# Database table
class User(UserBase, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    hashed_password: str
    created_at: datetime = Field(default_factory=datetime.utcnow)

    # Relationships
    orders: List["Order"] = Relationship(back_populates="user")

# API Input: Create
class UserCreate(UserBase):
    password: str = Field(min_length=8, max_length=100)

# API Input: Update
class UserUpdate(SQLModel):
    email: Optional[EmailStr] = None
    name: Optional[str] = Field(None, min_length=1, max_length=100)
    password: Optional[str] = Field(None, min_length=8, max_length=100)
    is_active: Optional[bool] = None

# API Output
class UserRead(UserBase):
    id: int
    created_at: datetime

    model_config = ConfigDict(from_attributes=True)

Step 2: Create user repository:

# app/repositories/user_repository.py
from sqlmodel import Session, select
from typing import Optional, List
from app.models.user import User, UserCreate, UserUpdate
import hashlib

def hash_password(password: str) -> str:
    """Simple hash for demo. Use bcrypt in production!"""
    return hashlib.sha256(password.encode()).hexdigest()

class UserRepository:
    def __init__(self, session: Session):
        self.session = session

    def create(self, user_create: UserCreate) -> User:
        user = User(
            email=user_create.email,
            name=user_create.name,
            hashed_password=hash_password(user_create.password)
        )
        self.session.add(user)
        self.session.commit()
        self.session.refresh(user)
        return user

    def get_by_id(self, user_id: int) -> Optional[User]:
        return self.session.get(User, user_id)

    def get_by_email(self, email: str) -> Optional[User]:
        statement = select(User).where(User.email == email)
        return self.session.exec(statement).first()

    def list(self, skip: int = 0, limit: int = 100) -> List[User]:
        statement = select(User).offset(skip).limit(limit)
        return self.session.exec(statement).all()

    def update(self, user_id: int, user_update: UserUpdate) -> Optional[User]:
        user = self.get_by_id(user_id)
        if not user:
            return None

        update_data = user_update.model_dump(exclude_unset=True)
        if "password" in update_data:
            update_data["hashed_password"] = hash_password(update_data.pop("password"))

        for key, value in update_data.items():
            setattr(user, key, value)

        self.session.add(user)
        self.session.commit()
        self.session.refresh(user)
        return user

    def delete(self, user_id: int) -> bool:
        user = self.get_by_id(user_id)
        if not user:
            return False
        self.session.delete(user)
        self.session.commit()
        return True

Checkpoint: Can create, read, update, delete users through repository.

Phase 3: Product and Category Models (2 hours)

Goal: Implement product catalog with category relationships.

Step 1: Create category model:

# app/models/category.py
from sqlmodel import SQLModel, Field, Relationship
from typing import Optional, List, TYPE_CHECKING
from datetime import datetime
from pydantic import ConfigDict

if TYPE_CHECKING:
    from .product import Product

class CategoryBase(SQLModel):
    name: str = Field(min_length=1, max_length=100)
    description: Optional[str] = Field(None, max_length=500)

class Category(CategoryBase, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    created_at: datetime = Field(default_factory=datetime.utcnow)

    products: List["Product"] = Relationship(back_populates="category")

class CategoryCreate(CategoryBase):
    pass

class CategoryRead(CategoryBase):
    id: int
    created_at: datetime
    product_count: int = 0

    model_config = ConfigDict(from_attributes=True)

class CategoryReadWithProducts(CategoryRead):
    products: List["ProductRead"] = []

Step 2: Create product model:

# app/models/product.py
from sqlmodel import SQLModel, Field, Relationship
from typing import Optional, TYPE_CHECKING
from datetime import datetime
from decimal import Decimal
from pydantic import ConfigDict

if TYPE_CHECKING:
    from .category import Category

class ProductBase(SQLModel):
    name: str = Field(min_length=1, max_length=200)
    description: Optional[str] = Field(None, max_length=2000)
    price: Decimal = Field(ge=0, decimal_places=2)
    stock_quantity: int = Field(ge=0, default=0)

class Product(ProductBase, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    category_id: Optional[int] = Field(default=None, foreign_key="category.id")
    created_at: datetime = Field(default_factory=datetime.utcnow)
    updated_at: Optional[datetime] = None

    category: Optional["Category"] = Relationship(back_populates="products")

class ProductCreate(ProductBase):
    category_id: Optional[int] = None

class ProductUpdate(SQLModel):
    name: Optional[str] = Field(None, min_length=1, max_length=200)
    description: Optional[str] = None
    price: Optional[Decimal] = Field(None, ge=0)
    stock_quantity: Optional[int] = Field(None, ge=0)
    category_id: Optional[int] = None

class ProductRead(ProductBase):
    id: int
    category_id: Optional[int]
    created_at: datetime

    model_config = ConfigDict(from_attributes=True)

class ProductReadWithCategory(ProductRead):
    category: Optional["CategoryRead"] = None

Checkpoint: Can manage products and categories with relationships.

Phase 4: Order Models with Transactions (2-3 hours)

Goal: Implement order processing with proper transaction handling.

Step 1: Create order models:

# app/models/order.py
from sqlmodel import SQLModel, Field, Relationship
from typing import Optional, List, TYPE_CHECKING
from datetime import datetime
from decimal import Decimal
from enum import Enum
from pydantic import ConfigDict

if TYPE_CHECKING:
    from .user import User
    from .product import Product

class OrderStatus(str, Enum):
    pending = "pending"
    confirmed = "confirmed"
    processing = "processing"
    shipped = "shipped"
    delivered = "delivered"
    cancelled = "cancelled"

class OrderItemBase(SQLModel):
    quantity: int = Field(ge=1)
    unit_price: Decimal = Field(ge=0, decimal_places=2)

class OrderItem(OrderItemBase, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    order_id: int = Field(foreign_key="order.id")
    product_id: int = Field(foreign_key="product.id")

    order: Optional["Order"] = Relationship(back_populates="items")
    product: Optional["Product"] = Relationship()

class OrderBase(SQLModel):
    status: OrderStatus = Field(default=OrderStatus.pending)
    total_amount: Decimal = Field(ge=0, decimal_places=2, default=0)

class Order(OrderBase, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    user_id: int = Field(foreign_key="user.id")
    created_at: datetime = Field(default_factory=datetime.utcnow)
    updated_at: Optional[datetime] = None

    user: Optional["User"] = Relationship(back_populates="orders")
    items: List["OrderItem"] = Relationship(back_populates="order")

# API Input
class OrderItemCreate(SQLModel):
    product_id: int
    quantity: int = Field(ge=1)

class OrderCreate(SQLModel):
    user_id: int
    items: List[OrderItemCreate]

# API Output
class OrderItemRead(OrderItemBase):
    id: int
    product_id: int

    model_config = ConfigDict(from_attributes=True)

class OrderRead(OrderBase):
    id: int
    user_id: int
    created_at: datetime
    items: List[OrderItemRead] = []

    model_config = ConfigDict(from_attributes=True)

Step 2: Create order service with transaction:

# app/services/order_service.py
from sqlmodel import Session, select
from typing import Optional
from decimal import Decimal
from app.models.order import Order, OrderItem, OrderCreate, OrderStatus
from app.models.product import Product
from app.models.user import User

class InsufficientStockError(Exception):
    def __init__(self, product_id: int, requested: int, available: int):
        self.product_id = product_id
        self.requested = requested
        self.available = available
        super().__init__(f"Product {product_id}: requested {requested}, available {available}")

class OrderService:
    def __init__(self, session: Session):
        self.session = session

    def create_order(self, order_create: OrderCreate) -> Order:
        # Verify user exists
        user = self.session.get(User, order_create.user_id)
        if not user:
            raise ValueError(f"User {order_create.user_id} not found")

        # Verify products and calculate total
        total = Decimal("0.00")
        order_items = []

        for item in order_create.items:
            product = self.session.get(Product, item.product_id)
            if not product:
                raise ValueError(f"Product {item.product_id} not found")

            if product.stock_quantity < item.quantity:
                raise InsufficientStockError(
                    item.product_id,
                    item.quantity,
                    product.stock_quantity
                )

            item_total = product.price * item.quantity
            total += item_total

            order_items.append({
                "product": product,
                "quantity": item.quantity,
                "unit_price": product.price
            })

        # Create order
        order = Order(
            user_id=order_create.user_id,
            total_amount=total,
            status=OrderStatus.pending
        )
        self.session.add(order)
        self.session.flush()  # Get order.id without committing

        # Create order items and deduct stock
        for item_data in order_items:
            order_item = OrderItem(
                order_id=order.id,
                product_id=item_data["product"].id,
                quantity=item_data["quantity"],
                unit_price=item_data["unit_price"]
            )
            self.session.add(order_item)

            # Deduct stock
            item_data["product"].stock_quantity -= item_data["quantity"]
            self.session.add(item_data["product"])

        self.session.commit()
        self.session.refresh(order)
        return order

    def update_status(self, order_id: int, new_status: OrderStatus) -> Optional[Order]:
        order = self.session.get(Order, order_id)
        if not order:
            return None

        # Status transition validation
        valid_transitions = {
            OrderStatus.pending: [OrderStatus.confirmed, OrderStatus.cancelled],
            OrderStatus.confirmed: [OrderStatus.processing, OrderStatus.cancelled],
            OrderStatus.processing: [OrderStatus.shipped, OrderStatus.cancelled],
            OrderStatus.shipped: [OrderStatus.delivered],
            OrderStatus.delivered: [],
            OrderStatus.cancelled: []
        }

        if new_status not in valid_transitions.get(order.status, []):
            raise ValueError(f"Cannot transition from {order.status} to {new_status}")

        order.status = new_status
        self.session.add(order)
        self.session.commit()
        self.session.refresh(order)
        return order

Checkpoint: Can create orders with stock validation and transactions.

Phase 5: Alembic Migrations (1-2 hours)

Goal: Set up and use Alembic for schema migrations.

Step 1: Initialize Alembic:

alembic init alembic

Step 2: Configure Alembic to use SQLModel:

# alembic/env.py
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
from sqlmodel import SQLModel

# Import all models so they're registered with SQLModel.metadata
from app.models.user import User
from app.models.category import Category
from app.models.product import Product
from app.models.order import Order, OrderItem

# This is the Alembic Config object
config = context.config

# Interpret the config file for Python logging
if config.config_file_name is not None:
    fileConfig(config.config_file_name)

# Use SQLModel metadata
target_metadata = SQLModel.metadata

def run_migrations_offline() -> None:
    """Run migrations in 'offline' mode."""
    url = config.get_main_option("sqlalchemy.url")
    context.configure(
        url=url,
        target_metadata=target_metadata,
        literal_binds=True,
        dialect_opts={"paramstyle": "named"},
    )

    with context.begin_transaction():
        context.run_migrations()

def run_migrations_online() -> None:
    """Run migrations in 'online' mode."""
    connectable = engine_from_config(
        config.get_section(config.config_ini_section, {}),
        prefix="sqlalchemy.",
        poolclass=pool.NullPool,
    )

    with connectable.connect() as connection:
        context.configure(
            connection=connection,
            target_metadata=target_metadata
        )

        with context.begin_transaction():
            context.run_migrations()

if context.is_offline_mode():
    run_migrations_offline()
else:
    run_migrations_online()

Step 3: Generate initial migration:

alembic revision --autogenerate -m "Initial schema"
alembic upgrade head

Checkpoint: Can generate and apply database migrations.

Phase 6: FastAPI Integration and Testing (2-3 hours)

Goal: Wire everything together with FastAPI routes and tests.

Step 1: Create the main application:

# app/main.py
from fastapi import FastAPI, Depends, HTTPException
from sqlmodel import Session
from contextlib import asynccontextmanager

from app.db.engine import create_db_and_tables, get_session
from app.routes import users, products, categories, orders

@asynccontextmanager
async def lifespan(app: FastAPI):
    create_db_and_tables()
    yield

app = FastAPI(
    title="E-Commerce API",
    description="SQLModel-powered e-commerce backend",
    version="1.0.0",
    lifespan=lifespan
)

app.include_router(users.router, prefix="/users", tags=["users"])
app.include_router(categories.router, prefix="/categories", tags=["categories"])
app.include_router(products.router, prefix="/products", tags=["products"])
app.include_router(orders.router, prefix="/orders", tags=["orders"])

@app.get("/health")
async def health():
    return {"status": "healthy"}

Step 2: Create routes:

# app/routes/users.py
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session
from typing import List

from app.db.engine import get_session
from app.models.user import UserCreate, UserRead, UserUpdate
from app.repositories.user_repository import UserRepository

router = APIRouter()

@router.post("", response_model=UserRead, status_code=201)
def create_user(
    user: UserCreate,
    session: Session = Depends(get_session)
):
    repo = UserRepository(session)
    if repo.get_by_email(user.email):
        raise HTTPException(409, "Email already registered")
    return repo.create(user)

@router.get("", response_model=List[UserRead])
def list_users(
    skip: int = 0,
    limit: int = 100,
    session: Session = Depends(get_session)
):
    repo = UserRepository(session)
    return repo.list(skip, limit)

@router.get("/{user_id}", response_model=UserRead)
def get_user(user_id: int, session: Session = Depends(get_session)):
    repo = UserRepository(session)
    user = repo.get_by_id(user_id)
    if not user:
        raise HTTPException(404, "User not found")
    return user

@router.patch("/{user_id}", response_model=UserRead)
def update_user(
    user_id: int,
    user_update: UserUpdate,
    session: Session = Depends(get_session)
):
    repo = UserRepository(session)
    user = repo.update(user_id, user_update)
    if not user:
        raise HTTPException(404, "User not found")
    return user

@router.delete("/{user_id}", status_code=204)
def delete_user(user_id: int, session: Session = Depends(get_session)):
    repo = UserRepository(session)
    if not repo.delete(user_id):
        raise HTTPException(404, "User not found")

Checkpoint: Full API working with all CRUD operations.


Testing Strategy

Unit Tests for Models

# tests/test_models.py
import pytest
from pydantic import ValidationError
from app.models.user import UserCreate, UserUpdate, UserRead
from app.models.order import OrderStatus

def test_user_create_valid():
    user = UserCreate(
        email="test@example.com",
        name="Test User",
        password="securepassword123"
    )
    assert user.email == "test@example.com"
    assert user.name == "Test User"

def test_user_create_invalid_email():
    with pytest.raises(ValidationError) as exc:
        UserCreate(
            email="not-an-email",
            name="Test",
            password="password123"
        )
    errors = exc.value.errors()
    assert any(e["loc"] == ("email",) for e in errors)

def test_user_create_short_password():
    with pytest.raises(ValidationError) as exc:
        UserCreate(
            email="test@example.com",
            name="Test",
            password="short"
        )
    errors = exc.value.errors()
    assert any(e["loc"] == ("password",) for e in errors)

def test_user_update_partial():
    update = UserUpdate(name="New Name")
    assert update.name == "New Name"
    assert update.email is None
    assert update.password is None

def test_order_status_enum():
    assert OrderStatus.pending.value == "pending"
    assert OrderStatus.cancelled.value == "cancelled"

Repository Tests

# tests/test_repositories.py
import pytest
from sqlmodel import Session, SQLModel, create_engine
from sqlmodel.pool import StaticPool
from app.models.user import UserCreate, UserUpdate
from app.repositories.user_repository import UserRepository

@pytest.fixture
def session():
    engine = create_engine(
        "sqlite://",
        connect_args={"check_same_thread": False},
        poolclass=StaticPool
    )
    SQLModel.metadata.create_all(engine)
    with Session(engine) as session:
        yield session

def test_create_user(session):
    repo = UserRepository(session)
    user = repo.create(UserCreate(
        email="test@example.com",
        name="Test User",
        password="password123"
    ))

    assert user.id is not None
    assert user.email == "test@example.com"
    assert user.hashed_password != "password123"

def test_get_user_by_email(session):
    repo = UserRepository(session)
    repo.create(UserCreate(
        email="find@example.com",
        name="Find Me",
        password="password123"
    ))

    user = repo.get_by_email("find@example.com")
    assert user is not None
    assert user.name == "Find Me"

def test_update_user(session):
    repo = UserRepository(session)
    user = repo.create(UserCreate(
        email="update@example.com",
        name="Original",
        password="password123"
    ))

    updated = repo.update(user.id, UserUpdate(name="Updated"))
    assert updated.name == "Updated"
    assert updated.email == "update@example.com"

def test_delete_user(session):
    repo = UserRepository(session)
    user = repo.create(UserCreate(
        email="delete@example.com",
        name="Delete Me",
        password="password123"
    ))

    assert repo.delete(user.id) is True
    assert repo.get_by_id(user.id) is None

Integration Tests

# tests/test_api.py
import pytest
from fastapi.testclient import TestClient
from sqlmodel import Session, SQLModel, create_engine
from sqlmodel.pool import StaticPool
from app.main import app
from app.db.engine import get_session

@pytest.fixture
def client():
    engine = create_engine(
        "sqlite://",
        connect_args={"check_same_thread": False},
        poolclass=StaticPool
    )
    SQLModel.metadata.create_all(engine)

    def get_test_session():
        with Session(engine) as session:
            yield session

    app.dependency_overrides[get_session] = get_test_session

    with TestClient(app) as client:
        yield client

    app.dependency_overrides.clear()

def test_create_user_api(client):
    response = client.post("/users", json={
        "email": "api@example.com",
        "name": "API User",
        "password": "password123"
    })
    assert response.status_code == 201
    data = response.json()
    assert data["email"] == "api@example.com"
    assert "password" not in data
    assert "id" in data

def test_create_user_duplicate_email(client):
    client.post("/users", json={
        "email": "dupe@example.com",
        "name": "First",
        "password": "password123"
    })

    response = client.post("/users", json={
        "email": "dupe@example.com",
        "name": "Second",
        "password": "password123"
    })
    assert response.status_code == 409

def test_create_order_insufficient_stock(client):
    # Create user
    user_resp = client.post("/users", json={
        "email": "buyer@example.com",
        "name": "Buyer",
        "password": "password123"
    })
    user_id = user_resp.json()["id"]

    # Create product with limited stock
    cat_resp = client.post("/categories", json={"name": "Test"})
    prod_resp = client.post("/products", json={
        "name": "Limited Product",
        "price": 10.00,
        "stock_quantity": 5,
        "category_id": cat_resp.json()["id"]
    })
    product_id = prod_resp.json()["id"]

    # Try to order more than available
    response = client.post("/orders", json={
        "user_id": user_id,
        "items": [{"product_id": product_id, "quantity": 10}]
    })
    assert response.status_code == 400
    assert "insufficient" in response.json()["detail"].lower()

Common Pitfalls and Debugging

Pitfall 1: Forgetting table=True

Problem: Model doesnโ€™t create database table.

# Wrong: Missing table=True
class User(SQLModel):
    id: int = Field(primary_key=True)
    name: str

# Correct
class User(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    name: str

Symptom: sqlalchemy.exc.NoSuchTableError: user

Pitfall 2: Optional Primary Key

Problem: Primary key must be Optional for auto-generation.

# Wrong: Can't insert without id
class User(SQLModel, table=True):
    id: int = Field(primary_key=True)  # Required!

# Correct: Optional with default None
class User(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)

Pitfall 3: Circular Import with Relationships

Problem: Models reference each other causing import errors.

# Wrong: Direct import causes circular dependency
from app.models.order import Order

class User(SQLModel, table=True):
    orders: List[Order]  # Error!

# Correct: Use TYPE_CHECKING and string annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
    from app.models.order import Order

class User(SQLModel, table=True):
    orders: List["Order"] = Relationship(back_populates="user")

Pitfall 4: Missing from_attributes

Problem: Canโ€™t return ORM objects as response models.

# Wrong: Missing from_attributes
class UserRead(SQLModel):
    id: int
    name: str

@app.get("/users/{id}", response_model=UserRead)
def get_user(id: int, session: Session = Depends(get_session)):
    return session.get(User, id)  # Error!

# Correct: Add from_attributes
class UserRead(SQLModel):
    id: int
    name: str

    model_config = ConfigDict(from_attributes=True)

Pitfall 5: N+1 Query Problem

Problem: Loading relationships causes too many queries.

# Wrong: N+1 queries when accessing order.items for each order
orders = session.exec(select(Order)).all()
for order in orders:
    print(order.items)  # Each access triggers a query!

# Correct: Use selectinload or joinedload
from sqlalchemy.orm import selectinload

statement = select(Order).options(selectinload(Order.items))
orders = session.exec(statement).all()
# All items loaded in 2 queries total

Pitfall 6: Session Not Refreshed

Problem: Object doesnโ€™t have updated values after commit.

# Wrong: user.id is still None after commit
user = User(name="Test")
session.add(user)
session.commit()
print(user.id)  # None!

# Correct: Refresh to get database-generated values
session.add(user)
session.commit()
session.refresh(user)
print(user.id)  # 1

Pitfall 7: Alembic Not Detecting Changes

Problem: Autogenerate doesnโ€™t see model changes.

Solution: Ensure all models are imported in alembic/env.py:

# alembic/env.py
# Import ALL models here
from app.models.user import User
from app.models.order import Order, OrderItem
from app.models.product import Product
from app.models.category import Category

target_metadata = SQLModel.metadata

Extensions and Challenges

Extension 1: Async Database Operations

Convert to async for better performance:

from sqlmodel.ext.asyncio.session import AsyncSession
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker

DATABASE_URL = "postgresql+asyncpg://user:pass@localhost/db"
engine = create_async_engine(DATABASE_URL)
async_session = async_sessionmaker(engine, class_=AsyncSession)

async def get_async_session() -> AsyncSession:
    async with async_session() as session:
        yield session

@app.get("/users/{user_id}")
async def get_user(
    user_id: int,
    session: AsyncSession = Depends(get_async_session)
):
    result = await session.exec(select(User).where(User.id == user_id))
    return result.first()

Extension 2: Soft Deletes

Implement soft delete pattern:

class SoftDeleteMixin(SQLModel):
    deleted_at: Optional[datetime] = None
    is_deleted: bool = Field(default=False)

class User(UserBase, SoftDeleteMixin, table=True):
    ...

# Repository method
def soft_delete(self, user_id: int) -> bool:
    user = self.get_by_id(user_id)
    if not user:
        return False
    user.is_deleted = True
    user.deleted_at = datetime.utcnow()
    self.session.add(user)
    self.session.commit()
    return True

# Query excludes soft-deleted
def list_active(self):
    statement = select(User).where(User.is_deleted == False)
    return self.session.exec(statement).all()

Extension 3: Query Builder Pattern

Build complex queries fluently:

class ProductQueryBuilder:
    def __init__(self, session: Session):
        self.session = session
        self.statement = select(Product)

    def in_category(self, category_id: int) -> "ProductQueryBuilder":
        self.statement = self.statement.where(
            Product.category_id == category_id
        )
        return self

    def in_stock(self) -> "ProductQueryBuilder":
        self.statement = self.statement.where(
            Product.stock_quantity > 0
        )
        return self

    def price_range(self, min_price: Decimal, max_price: Decimal):
        self.statement = self.statement.where(
            Product.price.between(min_price, max_price)
        )
        return self

    def execute(self) -> List[Product]:
        return self.session.exec(self.statement).all()

# Usage
products = (
    ProductQueryBuilder(session)
    .in_category(1)
    .in_stock()
    .price_range(10, 100)
    .execute()
)

Extension 4: Event Sourcing for Orders

Track all order state changes:

class OrderEvent(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    order_id: int = Field(foreign_key="order.id")
    event_type: str  # "created", "confirmed", "shipped", etc.
    old_status: Optional[str]
    new_status: str
    metadata: dict = Field(default={}, sa_column=Column(JSON))
    created_at: datetime = Field(default_factory=datetime.utcnow)
    created_by: Optional[int] = None

def update_order_status(order_id: int, new_status: OrderStatus, user_id: int):
    order = session.get(Order, order_id)
    old_status = order.status

    # Create event
    event = OrderEvent(
        order_id=order_id,
        event_type="status_change",
        old_status=old_status,
        new_status=new_status,
        created_by=user_id
    )
    session.add(event)

    # Update order
    order.status = new_status
    session.add(order)
    session.commit()

Add search capability with PostgreSQL:

from sqlalchemy import func

class ProductRepository:
    def search(self, query: str) -> List[Product]:
        # PostgreSQL full-text search
        statement = select(Product).where(
            func.to_tsvector('english', Product.name + ' ' + Product.description)
            .match(query)
        )
        return self.session.exec(statement).all()

Real-World Connections

Where This Pattern Appears

  1. FastAPI + SQLModel - Official recommended stack
  2. Django ORM - Similar model-first approach
  3. ActiveRecord (Rails) - Models as tables pattern
  4. Entity Framework (.NET) - Code-first migrations

Industry Examples

  • Stripe - Repository pattern for payment models
  • GitHub - Event sourcing for repository events
  • Shopify - Order and inventory management
  • Netflix - Async database operations for streaming

Production Considerations

  1. Connection Pooling - Use SQLAlchemy pool settings
  2. Read Replicas - Route reads to replicas
  3. Migrations in CI/CD - Run migrations before deployment
  4. Backup Strategy - Regular database backups
  5. Monitoring - Query performance and connection metrics

Self-Assessment Checklist

Core Understanding

  • Can I explain the difference between table=True and table=False?
  • Can I describe how SQLModel combines Pydantic and SQLAlchemy?
  • Can I explain why we separate input, output, and database models?
  • Can I describe the repository pattern and its benefits?

Implementation Skills

  • Can I create SQLModel tables with relationships?
  • Can I implement a repository with full CRUD operations?
  • Can I handle transactions for multi-step operations?
  • Can I set up and run Alembic migrations?

Advanced Skills

  • Can I implement async database operations?
  • Can I prevent N+1 query problems?
  • Can I handle circular imports between models?
  • Can I write comprehensive tests for repositories?

Mastery Indicators

  • Order creation properly validates and deducts stock
  • Migrations generate correctly from model changes
  • Tests cover both success and error cases
  • API returns properly serialized responses

Resources

Documentation

Books

  • โ€œArchitecture Patterns with Pythonโ€ by Harry Percival & Bob Gregory
  • โ€œCosmic Pythonโ€ (free online) - Domain-driven design with Python

Videos

  • Sebastiรกn Ramรญrezโ€™s SQLModel talks
  • ArjanCodes - SQLModel tutorials