Tutorial — building the Users feature¶
This tutorial walks through wiring the Users feature using every SDK convention. By the end you'll have:
- A SQLAlchemy
UserModelwith audit + soft-delete columns - Pydantic schemas for create / update / response / filter
- A repository, service and controller subclassing the SDK bases
- Routers with
Depends-injected controllers - Domain exception subclasses serialized by the SDK's exception handler
- A paginated
GET /usersand a signupPOST /users(with password hashing viaPasswordUtils)
Tip for the impatient
If you only want to copy the layout, scaffold it: tempest new my-service. The CLI ships the same skeleton this tutorial walks through.
Auth flow already ships
This tutorial shows how to build signup/login with BaseRepository + BaseService + BaseController — the foundation for any feature. For the complete auth flow (signup + email activation + JWT login + password reset), the SDK ships UserAuthService + make_auth_router since v0.31.0; jump to the Auth flow » recipe when you want the shortcut instead of implementing it manually.
Following along
Full-file snippets — the ones that open with a bare file-path comment (e.g. # src/db/models/user.py) — are standalone: paste each into the path shown. Blocks marked (continued) or with a # ... above ... comment append to the file shown just above; they are not a new file. The full project tree is the mandatory project layout from Architecture →.
We'll build a complete Users feature from scratch, end to end. Every file below is something you write in your project; SDK primitives are imported.
1. Project layout¶
The canonical layout every Python service shipped against this SDK should adopt — main.py is a one-liner, src/server.py exposes both run() and the importable app (or re-exports it from src/api/app.py), api/dependencies/ is always a package (auth + factory providers), controllers/ is mandatory even when it's only a thin pass-through, and repositories/ lives under db/.
my-service/
├── main.py # one-liner: from src.server import run; run()
└── src/
├── __init__.py # re-exports `run` from src.server
├── server.py # programmatic uvicorn.run(...) + module-level `app`
├── core/
│ ├── __init__.py
│ ├── settings.py # Settings(mixins..., BaseAppSettings)
│ └── exceptions.py # domain exceptions (UserNotFoundError, ...)
├── db/
│ ├── __init__.py # re-exports BaseModel + every model
│ ├── models/
│ │ ├── __init__.py
│ │ └── user.py # UserModel(BaseModel)
│ └── repositories/
│ ├── __init__.py
│ └── user.py # UserRepository(BaseRepository[UserModel])
├── schemas/
│ ├── __init__.py
│ └── user.py # UserCreate/Update/Response/Filter
├── services/
│ ├── __init__.py
│ └── user.py # UserService — business logic
├── controllers/
│ ├── __init__.py
│ └── user.py # UserController — orchestration (thin pass-through OK)
└── api/
├── __init__.py
├── app.py # create_app() — middleware, CORS, exception handlers, routers
├── routers/
│ ├── __init__.py
│ └── users.py
└── dependencies/ # ALWAYS a package, never a flat module
├── __init__.py
├── auth.py # X-Token / current_user / require_role dependencies
├── resources.py # infra singletons (db/storage/mail) + get_db/get_session
└── controllers.py # get_<X>_controller / get_<X>_service factories
Each __init__.py re-exports every public symbol from its directory so consumers always do from src.schemas import UserCreateSchema (not from src.schemas.user import UserCreateSchema). This keeps refactors painless — move files around without breaking imports.
If your service has no controllers/services/repositories yet, still ship empty packages with the right names — uniformity matters more than skipping a directory. Drop db/, utils/, queue/ or tasks/ only when the service genuinely doesn't need persistence/utilities/messaging.
2. Settings, server, app factory & entry point¶
Four files map onto four responsibilities:
| File | Responsibility |
|---|---|
src/core/settings.py |
Settings(...mixins, BaseAppSettings) — one source of truth for env vars. |
src/api/dependencies/resources.py |
infra singletons (db = AsyncDatabaseManager(**settings.database_kwargs()), and — opt-in — storage/mail) + get_db / get_session providers. Single owner of resource lifecycle. |
src/api/app.py |
thin create_app() factory — middleware + CORS + exception handlers + router includes + module-level app instance. Imports the resources from dependencies, never builds them. |
src/server.py |
run() invoking uvicorn.run("src.api.app:app", ...) programmatically, plus re-exports app so external runners (gunicorn, uvicorn CLI) can import it. |
main.py |
Process entry point — a single line under if __name__ == "__main__": calling run(). |
# src/core/settings.py
from tempest_fastapi_sdk import BaseAppSettings, DatabaseSettings, ServerSettings
class Settings(ServerSettings, DatabaseSettings, BaseAppSettings):
"""All environment-driven configuration lives here.
BaseAppSettings ships `env_file=.env`, `extra=ignore`,
`case_sensitive=True`, `frozen=True` and `str_strip_whitespace=True`.
ServerSettings adds SERVER_HOST/PORT/RELOAD, DatabaseSettings adds
DATABASE_URL/ECHO/POOL_*.
"""
JWT_SECRET: str
JWT_ALGORITHM: str = "HS256"
JWT_TTL_HOURS: int = 1
SMTP_HOST: str = "localhost"
SMTP_PORT: int = 587
SMTP_USERNAME: str | None = None
SMTP_PASSWORD: str | None = None
SMTP_FROM_ADDR: str = "noreply@example.com"
UPLOAD_DIR: str = "./var/uploads"
settings = Settings()
BaseAppSettings is always the last base
Every settings mixin the SDK ships (ServerSettings,
DatabaseSettings, RedisSettings, …) already inherits
BaseAppSettings — that is how each one carries the canonical
model_config (env_file=".env", extra="ignore",
case_sensitive=True) regardless of ordering.
The consequence: BaseAppSettings must come last in the base list.
Python forbids a base from preceding its own subclass (C3
linearization), so writing
# docs-guard: skip — this is the mistake the warning describes
class Settings(BaseAppSettings, DatabaseSettings): # ❌ fails to import
...
fails at import time with:
TypeError: Settings: BaseAppSettings must be the LAST base —
DatabaseSettings already subclasses it, so listing BaseAppSettings
before it is an invalid method resolution order (MRO). Move
BaseAppSettings to the end of the base list:
class Settings(DatabaseSettings, BaseAppSettings).
Before v0.159.1 the message was pydantic's raw TypeError (Cannot
create a consistent method resolution order (MRO) for bases
BaseAppSettings, DatabaseSettings), plus a misleading [metaclass]
error from mypy on the same line. The cause is identical in both
versions: move BaseAppSettings to the end.
Infra resources (the database now; storage / mail later) live in
src/api/dependencies/resources.py, built once and reached through
get_* providers. app.py only imports them, so it never bloats with
resource construction:
# src/api/dependencies/resources.py
from tempest_fastapi_sdk import AsyncDatabaseManager
from src.core.settings import settings
db = AsyncDatabaseManager(**settings.database_kwargs())
get_session = db.session_dependency # Depends(get_session) -> AsyncSession
def get_db() -> AsyncDatabaseManager:
"""Return the process-wide database manager."""
return db
# src/api/app.py
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from fastapi import FastAPI
from tempest_fastapi_sdk import (
RequestIDMiddleware,
make_health_router,
register_exception_handlers,
)
from src.api.dependencies.resources import db
from src.api.routers import users
@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
"""Connect on startup, dispose on shutdown."""
await db.connect()
try:
yield
finally:
await db.disconnect()
def create_app() -> FastAPI:
"""Build and configure the FastAPI app."""
app = FastAPI(title="my-service", version="0.1.0", lifespan=lifespan)
app.add_middleware(RequestIDMiddleware)
register_exception_handlers(app)
# Meta endpoints sit at the root prefix.
app.include_router(make_health_router(db=db, version="0.1.0"))
# Business endpoints sit under /api/<domain>.
app.include_router(users.router, prefix="/api")
return app
app = create_app()
# src/server.py
from tempest_fastapi_sdk import run_server
from src.api.app import app # noqa: F401 — re-exported for external runners
from src.core.settings import settings
def run() -> None:
"""Start the API server programmatically."""
run_server("src.api.app:app", settings=settings)
__all__: list[str] = ["app", "run"]
run_server reads SERVER_HOST / SERVER_PORT / SERVER_RELOAD from settings (falling back to 127.0.0.1 / 8000 / False) and forwards any extra kwargs (workers=, log_config=, ssl_*=) verbatim to uvicorn.run. See the Programmatic server entry point recipe.
Bind defaults: 127.0.0.1 for internal services (the SDK's ServerSettings.SERVER_HOST default), 0.0.0.0 only when the service is consumed by a separate origin (e.g. a frontend dev server). Never start uvicorn via subprocess.run(["uvicorn", ...]) — always go through run_server (or uvicorn.run("src.api.app:app", ...) directly) so reload, signal handling and graceful shutdown behave correctly.
3. ORM model¶
# src/db/models/user.py
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from tempest_fastapi_sdk import BaseModel
class UserModel(BaseModel):
"""One row per registered user.
Inherits from BaseModel, so it automatically gets:
- id (UUID v4, cross-DB portable via sqlalchemy.Uuid)
- is_active (bool, soft-delete flag)
- created_at, updated_at (timezone-aware TIMESTAMP, set by Python AND
the DB so the instance attribute is populated right after flush)
- __tablename__ = "user" (auto: class name without "Model" suffix,
snake-cased; override by assigning __tablename__ explicitly)
- __eq__/__hash__ by (type, id) so the same row across sessions
compares equal
- to_dict(exclude, include, remove_none) and
update_from_dict(data, allowed_fields) helpers
"""
name: Mapped[str] = mapped_column(String(64), nullable=False)
email: Mapped[str] = mapped_column(String(128), unique=True, nullable=False)
password_hash: Mapped[str] = mapped_column(String(128), nullable=False)
Re-export it:
# src/db/models/__init__.py
from src.db.models.user import UserModel
__all__: list[str] = ["UserModel"]
# src/db/__init__.py
from src.db.models import UserModel
from tempest_fastapi_sdk import BaseModel
__all__: list[str] = ["BaseModel", "UserModel"]
Tip: Always import models in
src/db/__init__.py. SQLAlchemy needs to "see" every model beforeBaseModel.metadatais complete, so Alembic autogenerate andcreate_tables()work correctly.
4. Schemas¶
Installation
The schemas below use EmailStr, which needs the email-validator package — pull it in with the [email] extra (uv add "tempest-fastapi-sdk[email]") or via pydantic[email]. Further down the service uses PasswordUtils (bcrypt hashing), which needs the [auth] extra — uv add "tempest-fastapi-sdk[auth]".
The recommended naming pattern: one *Create, *Update, *Response and *Filter schema per resource.
# src/schemas/user.py
from pydantic import EmailStr, Field
from tempest_fastapi_sdk import (
BasePaginationFilterSchema,
BaseResponseSchema,
BaseSchema,
)
class UserCreateSchema(BaseSchema):
"""Payload for POST /users."""
name: str = Field(min_length=1, max_length=64)
email: EmailStr
password: str = Field(min_length=8, max_length=128)
class UserUpdateSchema(BaseSchema):
"""Partial payload for PATCH /users/{id}. Every field optional."""
name: str | None = Field(default=None, min_length=1, max_length=64)
email: EmailStr | None = None
class UserResponseSchema(BaseResponseSchema):
"""Outbound representation.
Inherits id/is_active/created_at/updated_at from BaseResponseSchema
(timestamps already normalized to UTC by the field validator).
"""
name: str
email: EmailStr
class UserFilterSchema(BasePaginationFilterSchema):
"""Query-string filters for GET /users.
Inherits page/page_size/order_by/ascending/is_active from
BasePaginationFilterSchema. Add domain-level filters below.
"""
name: str | None = None # ILIKE %name% search
email: EmailStr | None = None # exact-match filter
# src/schemas/__init__.py
from src.schemas.user import (
UserCreateSchema,
UserFilterSchema,
UserResponseSchema,
UserUpdateSchema,
)
__all__: list[str] = [
"UserCreateSchema",
"UserFilterSchema",
"UserResponseSchema",
"UserUpdateSchema",
]
5. Domain exceptions¶
The SDK ships generic NotFoundException, ConflictException, etc. Subclass them per domain so the isinstance / except DomainError matching stays explicit. Class-level message / code / status_code are defaults the constructor falls back to — you can also override any of them at the raise site without subclassing:
# src/core/exceptions.py
from tempest_fastapi_sdk import ConflictException, NotFoundException
class UserNotFoundError(NotFoundException):
"""Subclass kept only for ``except UserNotFoundError`` matching."""
message: str = "Usuário não encontrado"
code: str = "USER_NOT_FOUND"
class UserEmailAlreadyTakenError(ConflictException):
message: str = "Já existe um usuário com esse e-mail"
code: str = "USER_EMAIL_TAKEN"
For one-off codes you don't need a subclass — pass them to the constructor:
from uuid import UUID
from tempest_fastapi_sdk import NotFoundException
order_id = UUID("6f1c3d84-2a55-4d0b-9d7e-0c1a2b3c4d5e")
raise NotFoundException(
"Pedido não encontrado",
code="ORDER_NOT_FOUND",
details={"order_id": str(order_id)},
)
The SDK's exception handler (register_exception_handlers) serializes them to:
The frontend branches on code, not on the (potentially translated) message.
6. Repository¶
For plain CRUD you don't need a subclass at all — instantiate BaseRepository directly and bind the model via the constructor:
# anywhere a session is in scope
import asyncio
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from tempest_fastapi_sdk import BaseRepository
from src.db.models import UserModel
# In a service the session comes from `db.get_session_context()`; here, SQLite.
session = AsyncSession(create_async_engine("sqlite+aiosqlite:///:memory:"))
repository = BaseRepository(session, model=UserModel)
async def main() -> None:
"""Run this example."""
await repository.add(
UserModel(
email="ana@example.com",
name="Ana",
password_hash="<bcrypt-hash>",
)
)
asyncio.run(main())
Subclass when you want to bake in domain-specific messages, swap the not-found exception, override the mapper methods or add custom queries. The constructor signature (not class attributes) is the contract:
# src/db/repositories/user.py
from sqlalchemy.ext.asyncio import AsyncSession
from tempest_fastapi_sdk import BaseRepository
from src.core.exceptions import UserNotFoundError
from src.db.models import UserModel
from src.schemas import UserResponseSchema
class UserRepository(BaseRepository[UserModel]):
"""Data-access layer for users."""
def __init__(self, session: AsyncSession) -> None:
super().__init__(
session,
model=UserModel,
not_found_exception=UserNotFoundError,
not_found_message="Usuário não encontrado",
create_conflict_message="Já existe um usuário com esse e-mail",
update_conflict_message="Conflito ao atualizar usuário",
)
def map_to_schema(self, instance: UserModel) -> UserResponseSchema:
return UserResponseSchema.model_validate(instance)
def map_to_response(self, instance: UserModel) -> UserResponseSchema:
return self.map_to_schema(instance)
The base repo gives you 20+ methods for free — see the reference table. Add custom queries on top of the same UserRepository:
# src/db/repositories/user.py (continued)
from tempest_fastapi_sdk import BaseRepository
from src.db.models import UserModel
class UserRepository(BaseRepository[UserModel]):
# ... __init__ and mappers above ...
# ──────── custom queries on top of the inherited bulk + read methods ────────
async def get_by_email(self, email: str) -> UserModel:
"""Look up a user by email. Raises ``UserNotFoundError`` on miss."""
return await self.get({"email": email})
The highlighted block (under the divider comment) is what you typically add per project — everything above it is the boilerplate the base class already takes care of.
7. Service¶
The service is where business rules live. It calls one or more repositories and never touches HTTP or SQLAlchemy types directly.
Inherit from BaseService[RepositoryT, ResponseT]. Doing so gives you get_by_id, get_or_none, list, paginate, count, exists and delete for free — every one is already wired to repository.map_to_response (sync or async). Override only the methods that need domain logic; add new ones for use cases the base doesn't cover (signup, password reset, etc.):
# src/services/user.py
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from tempest_fastapi_sdk import BaseService, PasswordUtils
from src.core.exceptions import UserEmailAlreadyTakenError
from src.db.repositories import UserRepository
from src.schemas import UserCreateSchema, UserResponseSchema, UserUpdateSchema
class UserService(BaseService[UserRepository, UserResponseSchema]):
"""Business logic for the user domain.
Inherits the canonical read-path methods (``get_by_id`` / ``list`` /
``paginate`` / ``count`` / ``exists`` / ``delete``) from
:class:`BaseService` and adds the write-path methods that need
domain rules (uniqueness check, password hashing, mass-assignment
guard).
"""
def __init__(
self,
repository: UserRepository,
*,
passwords: PasswordUtils,
) -> None:
"""Initialize the service.
Args:
repository (UserRepository): User-domain repository.
passwords (PasswordUtils): Shared bcrypt helper.
"""
super().__init__(repository)
self.passwords: PasswordUtils = passwords
# ──────── overrides: domain rules live here ────────
async def signup(self, data: UserCreateSchema) -> UserResponseSchema:
"""Create a new user, enforcing email uniqueness + hashing the password."""
if await self.repository.exists({"email": data.email}):
raise UserEmailAlreadyTakenError()
instance = self.repository.map_to_model(
{
**data.to_dict(exclude=["password"]),
"password_hash": self.passwords.hash(data.password),
},
)
instance = await self.repository.add(instance)
return self.repository.map_to_response(instance)
async def update(
self,
user_id: UUID,
data: UserUpdateSchema,
) -> UserResponseSchema:
"""Apply a partial update, whitelisting the columns that may change."""
instance = await self.repository.get_by_id(user_id)
instance.update_from_dict(
data.to_dict(),
allowed_fields={"name", "email"}, # prevents mass-assignment
)
instance = await self.repository.update(instance)
return self.repository.map_to_response(instance)
async def soft_delete(self, user_id: UUID) -> None:
"""Flip ``is_active=False`` instead of hard-deleting."""
await self.repository.soft_delete(user_id)
The methods you do not write — get_by_id(user_id), get_or_none(filters), list(filters=None, order_by=None, ascending=True), paginate(filters=None, order_by=None, page=1, page_size=20, ascending=True), count(filters), exists(filters), delete(user_id) — already exist on the base, already await an async map_to_response, and already return the typed UserResponseSchema declared in the generic parameter.
When the use case needs a custom pipeline (joins, projections, transactional fan-out), override the inherited method. The signature stays the same so the controller doesn't notice:
from typing import Any
from tempest_fastapi_sdk import BaseService
from src.db.repositories import UserRepository
from src.schemas import UserResponseSchema
class UserService(BaseService[UserRepository, UserResponseSchema]):
# ... __init__ and overrides above ...
async def list( # override the inherited pass-through
self,
filters: dict[str, Any] | None = None,
order_by: Any | None = None,
ascending: bool = True,
) -> list[UserResponseSchema]:
"""List active users only — domain rule baked into the base method."""
merged: dict[str, Any] = {**(filters or {}), "is_active": True}
return await super().list(filters=merged, order_by=order_by, ascending=ascending)
8. Controller¶
Even when there's no orchestration to do, controllers/ exists as a thin pass-through so the import graph stays uniform across services. The day a use case needs to coordinate two services (or fan out to a queue), the controller is already there.
Inherit from BaseController[ServiceT, ResponseT]. The base forwards get_by_id, list, paginate, count and delete to the service for you — you only declare methods that add cross-service coordination or that don't exist on the service (custom use cases like signup):
# src/controllers/user.py
from uuid import UUID
from tempest_fastapi_sdk import BaseController
from src.schemas import UserCreateSchema, UserResponseSchema, UserUpdateSchema
from src.services.user import UserService
class UserController(BaseController[UserService, UserResponseSchema]):
"""Orchestrate user use cases.
Today every method is a thin pass-through to ``UserService``. As
soon as a use case needs to coordinate more than one service —
e.g. signup also sends a welcome email and enqueues a CRM sync —
the orchestration lives here, not in the router and not in the
service.
"""
# ──────── new methods for use cases the base doesn't cover ────────
async def signup(self, data: UserCreateSchema) -> UserResponseSchema:
"""Create a user and (eventually) trigger downstream side effects."""
return await self.service.signup(data)
async def update(
self,
user_id: UUID,
data: UserUpdateSchema,
) -> UserResponseSchema:
"""Domain-specific partial update — distinct from the base ``delete``."""
return await self.service.update(user_id, data)
async def soft_delete(self, user_id: UUID) -> None:
"""Soft-delete instead of the inherited hard ``delete``."""
await self.service.soft_delete(user_id)
get_by_id / list / paginate / count are not redeclared — BaseController already exposes them. When the cross-service coordination day arrives, override the pass-through in place:
from tempest_fastapi_sdk import BaseController
from src.schemas import UserCreateSchema, UserResponseSchema
from src.services import UserService
class UserController(BaseController[UserService, UserResponseSchema]):
# ... methods above ...
async def signup(self, data: UserCreateSchema) -> UserResponseSchema:
"""Create the user, send a welcome email, enqueue the CRM sync."""
user = await self.service.signup(data)
await self.emails.send_welcome(user) # second dependency
await self.tasks.enqueue("crm.user.created", {"id": str(user.id)})
return user
The router signature never changes — only the controller's body grows.
9. Dependency providers¶
api/dependencies/ is always a package. auth.py hosts the shared-secret / current-user dependencies; resources.py hosts the infra singletons (db, and — opt-in — storage/mail) with the get_db / get_session providers; controllers.py (or services.py when there is no controller layer yet) hosts the factory providers the routers depend on. Never construct controllers, services, or infra resources inline inside the router file (or in app.py).
# src/api/dependencies/controllers.py
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from tempest_fastapi_sdk import PasswordUtils
from src.api.dependencies.resources import get_session
from src.controllers.user import UserController
from src.db.repositories import UserRepository
from src.services.user import UserService
# Stateless utilities — instantiate once per process.
_passwords: PasswordUtils = PasswordUtils()
def get_user_controller(
session: AsyncSession = Depends(get_session),
) -> UserController:
"""Wire repository → service → controller for a single request."""
repository = UserRepository(session)
service = UserService(repository=repository, passwords=_passwords)
return UserController(service=service)
# src/api/dependencies/__init__.py
from src.api.dependencies.controllers import get_user_controller
__all__: list[str] = ["get_user_controller"]
10. Router¶
Routers receive controllers via FastAPI Depends — no inline construction, no business logic, no DB calls. Business endpoints sit under /api/<domain> (the prefix is added at the include site in src/api/app.py); meta endpoints (/health, /tool-spec) stay at the root prefix.
# src/api/routers/users.py
from uuid import UUID
from fastapi import APIRouter, Depends, status
from tempest_fastapi_sdk import BasePaginationSchema
from src.api.dependencies import get_user_controller
from src.controllers.user import UserController
from src.schemas import (
UserCreateSchema,
UserFilterSchema,
UserResponseSchema,
UserUpdateSchema,
)
router = APIRouter(prefix="/users", tags=["users"])
@router.post(
"",
response_model=UserResponseSchema,
status_code=status.HTTP_201_CREATED,
)
async def create_user(
data: UserCreateSchema,
controller: UserController = Depends(get_user_controller),
) -> UserResponseSchema:
return await controller.signup(data)
@router.get("/{user_id}", response_model=UserResponseSchema)
async def get_user(
user_id: UUID,
controller: UserController = Depends(get_user_controller),
) -> UserResponseSchema:
return await controller.get_by_id(user_id)
@router.patch("/{user_id}", response_model=UserResponseSchema)
async def update_user(
user_id: UUID,
data: UserUpdateSchema,
controller: UserController = Depends(get_user_controller),
) -> UserResponseSchema:
return await controller.update(user_id, data)
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
user_id: UUID,
controller: UserController = Depends(get_user_controller),
) -> None:
await controller.soft_delete(user_id)
@router.get("", response_model=BasePaginationSchema[UserResponseSchema])
async def list_users(
filters: UserFilterSchema = Depends(),
controller: UserController = Depends(get_user_controller),
) -> BasePaginationSchema[UserResponseSchema]:
result = await controller.paginate(
filters=filters.get_conditions(),
order_by=filters.order_by,
page=filters.page,
page_size=filters.page_size,
ascending=filters.ascending,
)
return BasePaginationSchema[UserResponseSchema](**result)
11. Pagination¶
The pagination contract is enforced end-to-end by SDK primitives:
UserFilterSchema(BasePaginationFilterSchema)parses?page=&page_size=&order_by=&ascending=&is_active=&name=from the query string and exposes.get_conditions()returning only the domain-level filters (without pagination keys).UserRepository.paginate(...)runs the query with the filter dict + ordering + offset/limit + count, returning the dict{items, total, page, page_size, pages}that you wrap inBasePaginationSchema[UserResponseSchema].BasePaginationSchema[UserResponseSchema]wraps the result so OpenAPI documents the response shape correctly.
Returns:
{
"items": [
{"id": "...", "name": "Ana ...", "email": "...", ...},
...
],
"total": 142,
"page": 2,
"page_size": 20,
"pages": 8
}
Recap¶
You wired the Users feature end to end, one layer at a time, each backed by an SDK primitive:
- Model —
UserModel(BaseModel)getsid/is_active/created_at/updated_at+ helpers for free. - Schemas —
UserCreateSchema/UserUpdateSchema/UserResponseSchema/UserFilterSchemaoverBaseSchema/BaseResponseSchema/BasePaginationFilterSchema. - Exceptions — domain subclasses (
UserNotFoundError,UserEmailAlreadyTakenError) serialized byregister_exception_handlers. - Repository —
UserRepository(BaseRepository[UserModel])— 20+ inherited methods + custom queries. - Service —
UserService(BaseService[...])— business rules (email uniqueness, password hashing) over the inherited read-path. - Controller —
UserController(BaseController[...])— orchestration (thin pass-through today, coordination tomorrow). - Router — endpoints with
UserControllerinjected viaDepends, mounted under/api/users. - Pagination —
BasePaginationFilterSchema→paginate(...)→BasePaginationSchema[UserResponseSchema]end to end.
Next steps¶
- Auth flow » — swap the manual
signupforUserAuthService+make_auth_router(signup + email activation + JWT login + password reset). - Database » — Alembic migrations, bulk operations, and the
BaseRepositoryaudit/soft-delete mixins. - Testing » — test the feature with
pytest+ in-memory SQLite. - Server entry point » — middleware, CORS, health, tool-spec and
run_server. - Security » — harden the service (rate limit, CSRF, idempotency, body-size limits).