Your first module
A stage-by-stage walk-through: from make new-module to a working Orders module with custom fields, validation, a menu entry, and a test.
Assumes you've completed the Quickstart.
1. Scaffold
make new-module name=ordersThis creates modules/orders/ with a working CRUD implementation against a single-field Order table. Open it in your editor — you'll see the full file layout described in project structure.
2. Define your domain model
Edit modules/orders/orders/models.py:
from decimal import Decimal
from simple_module_db.base import create_module_base
from simple_module_db.mixins import AuditMixin, SoftDeleteMixin
from sqlmodel import Field
Base = create_module_base("orders")
class Order(Base, AuditMixin, SoftDeleteMixin, table=True):
__tablename__ = "orders_order"
id: int | None = Field(default=None, primary_key=True)
customer_email: str = Field(max_length=200, index=True)
total: Decimal = Field(max_digits=10, decimal_places=2)
status: str = Field(default="pending", max_length=20)AuditMixinaddscreated_at,updated_at,created_by,updated_by— populated automatically from the request user.SoftDeleteMixinreplacesDELETEwithis_deleted=true.SELECTfilters them out by default; passinclude_deleted=Trueto bypass.__tablename__must be prefixed with the module name under SQLite. On Postgres, the per-module Base puts tables in anordersschema automatically, so the prefix is redundant but harmless.
3. Update the DTOs
Edit modules/orders/orders/contracts/schemas.py:
from decimal import Decimal
from datetime import datetime
from pydantic import ConfigDict
from sqlmodel import Field, SQLModel
class OrderCreate(SQLModel):
customer_email: str = Field(min_length=1, max_length=200)
total: Decimal = Field(ge=0)
class OrderUpdate(SQLModel):
status: str | None = Field(default=None, max_length=20)
class OrderOut(SQLModel):
model_config = ConfigDict(from_attributes=True)
id: int
customer_email: str
total: Decimal
status: str
created_at: datetimeDTOs are plain SQLModel subclasses — not BaseModel and not table=True. They're the public surface other modules import from orders.contracts.
4. Generate a migration
make migration msg="add orders tables"Open host/migrations/versions/XXXX_add_orders_tables.py and eyeball it:
- It should create the
ordersschema (Postgres) or theorders_ordertable (SQLite). - It should set
branch_labels = ("orders",)— this enablesalembic downgrade orders@baseto roll the module back to empty without touching other modules.
Apply:
make migrate5. Wire up service + endpoints
The scaffold already generated working CRUD. Trim it to match your domain or extend it:
# modules/orders/orders/service.py
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import select
from orders.contracts.schemas import OrderCreate, OrderOut, OrderUpdate
from orders.models import Order
class OrderService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def create(self, data: OrderCreate) -> OrderOut:
order = Order(**data.model_dump())
self.session.add(order)
await self.session.flush() # get the DB-assigned id
return OrderOut.model_validate(order)
async def list(self) -> list[OrderOut]:
rows = (await self.session.exec(select(Order))).all()
return [OrderOut.model_validate(r) for r in rows]
async def update(self, order_id: int, data: OrderUpdate) -> OrderOut:
order = await self.session.get(Order, order_id)
if order is None:
raise KeyError(order_id)
for field, value in data.model_dump(exclude_none=True).items():
setattr(order, field, value)
await self.session.flush()
return OrderOut.model_validate(order)Note the absence of self.session.commit(). The per-request get_db dependency commits if (and only if) there were pending writes. See Session lifecycle.
6. Register permissions and menu
# modules/orders/orders/module.py
from fastapi import FastAPI, APIRouter
from simple_module_core.module import ModuleBase, ModuleMeta
from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection
from simple_module_core.permissions import PermissionRegistry
from orders.endpoints.api import router as api_router
from orders.endpoints.views import router as view_router
class OrdersModule(ModuleBase):
meta = ModuleMeta(
name="Orders",
route_prefix="/api/orders",
view_prefix="/orders",
version="0.1.0",
)
def register_permissions(self, registry: PermissionRegistry) -> None:
registry.add_group("Orders", [
"orders.view", "orders.create", "orders.edit", "orders.delete",
])
def register_menu_items(self, registry: MenuRegistry) -> None:
registry.add(
MenuItem(
section=MenuSection.SIDEBAR,
key="orders",
label_key="orders.menu.orders",
href="/orders",
icon="package",
required_permission="orders.view",
order=20,
)
)
def register_routes(
self, api: APIRouter, views: APIRouter
) -> None:
api.include_router(api_router, prefix="/api/orders")
views.include_router(view_router, prefix="/orders")label_key="orders.menu.orders"— translated string. Add it toorders/locales/en.json.required_permission="orders.view"— menu item is hidden for users who don't have it.InertiaLayoutDataMiddlewarefilters menus per request.
7. Enforce permissions on endpoints
# modules/orders/orders/endpoints/api.py
from fastapi import APIRouter, Depends
from simple_module_hosting.permissions import RequiresPermission
from orders.contracts.schemas import OrderCreate, OrderOut
from orders.deps import OrderServiceDep
router = APIRouter(tags=["orders"])
@router.get(
"",
dependencies=[Depends(RequiresPermission("orders.view"))],
)
async def list_orders(service: OrderServiceDep) -> list[OrderOut]:
return await service.list()
@router.post(
"",
status_code=201,
dependencies=[Depends(RequiresPermission("orders.create"))],
)
async def create_order(
data: OrderCreate, service: OrderServiceDep
) -> OrderOut:
return await service.create(data)RequiresPermission checks request.state.principal.permissions and returns 403 if the permission isn't granted.
8. Build the React page
pages/Browse.tsx was scaffolded — extend it:
import { useT } from "@simple-module-py/i18n-react";
import { DataTable, PageHeader } from "@simple-module-py/ui";
import type { OrderOut } from "../contracts";
export default function Browse({ orders }: { orders: OrderOut[] }) {
const { t } = useT();
return (
<>
<PageHeader title={t("orders.browse.title")} />
<DataTable
data={orders}
columns={[
{ key: "id", header: "#" },
{ key: "customer_email", header: t("orders.fields.customer") },
{ key: "total", header: t("orders.fields.total") },
{ key: "status", header: t("orders.fields.status") },
]}
/>
</>
);
}Translations (orders/locales/en.json):
{
"menu": { "orders": "Orders" },
"browse": { "title": "Orders" },
"fields": {
"customer": "Customer",
"total": "Total",
"status": "Status"
}
}Keys flatten at boot: orders.menu.orders, orders.browse.title, etc. See Internationalization.
9. Write a test
# modules/orders/tests/test_api.py
import pytest
@pytest.mark.asyncio
async def test_create_and_list_orders(authenticated_client):
r = await authenticated_client.post(
"/api/orders",
json={"customer_email": "buyer@example.com", "total": "42.00"},
)
assert r.status_code == 201
created = r.json()
assert created["customer_email"] == "buyer@example.com"
r = await authenticated_client.get("/api/orders")
assert r.status_code == 200
assert any(o["id"] == created["id"] for o in r.json())The authenticated_client fixture from conftest.py seeds an admin user and carries a signed session cookie. The db_session fixture creates all module tables and stamps the Alembic head so the boot-time migration check passes. See Fixtures.
Run:
uv run pytest modules/orders/tests/ -v10. Verify end-to-end
make doctor # should report 0 errors
make lint # Ruff + ty + Biome + tsc + file-size cap
make dev # visit http://localhost:8000/ordersIf make doctor flags an SM003 or SM004, the Inertia key in views.py doesn't match the file you created in pages/ — see Pages & discovery.
Next up:
- Models — SQLModel conventions.
- Permissions — how
RequiresPermissionresolves roles. - Events — publishing
OrderPlacedand subscribing from another module.