I've been working on a FastAPI codebase that grew to about 10 domains. This repo is the reusable template I extracted from that repetition.
At some point I realized I wasn't really building features anymore. I was just copying the same repository-service-router stack into a new folder, changing the model name, and praying I didn't forget to wire something up.
The worst part was code reviews. Every developer on the team structured things slightly differently. One person puts DTOs in the service layer, another puts them in the router. Someone imports a repository directly from another domain. Nobody notices until it's already merged and tangled.
I kept thinking there has to be a better way to do this.
What was actually repeating
At a simplified level, the repetition looked like this:
@router.post("/users")
async def create_user(user: UserCreate, db: AsyncSession = Depends(get_db)):
new_user = User(**user.model_dump())
db.add(new_user)
await db.commit()
await db.refresh(new_user)
return new_user
Then I'd write the same thing for products. And orders. And payments. Eight operations each, all nearly identical except for the model name.
I tried a few approaches. Code generators felt brittle — the generated code drifts from the template over time and you end up maintaining two things. Mixins got messy fast. What actually worked was plain Python generics.
What I ended up building
class ProductRepository(BaseRepository[ProductDTO]):
def __init__(self, database: Database):
super().__init__(
database=database,
model=ProductModel,
return_entity=ProductDTO
)
class ProductService(BaseService[CreateProductRequest, UpdateProductRequest, ProductDTO]):
def __init__(self, product_repository: ProductRepositoryProtocol):
super().__init__(repository=product_repository)
Once the model, request/response schemas, router, and DI container exist, this is the CRUD base-class hookup for the domain. Eight async methods come from the base classes: create_data, create_datas, get_data_by_data_id, get_datas_by_data_ids, get_datas (paginated via QueryFilter), update_data_by_data_id, delete_data_by_data_id, and count_datas. If I need custom logic, I just override the specific method. The rest stays as-is.
One deliberate compromise: when request fields match service input, I pass the request schema directly instead of creating a separate command DTO. That keeps CRUD domains smaller, but it is not "pure DDD."
No code generation, no magic. It's just inheritance with generics. Your IDE still understands everything, types flow through, and you can cmd+click into any method.
The other thing that was driving me crazy
Every time I added a new domain, I had to go edit the DI container, update the app bootstrap, register the router... it was like 4-5 files of boilerplate changes just to say "hey, this domain exists now."
So I wrote a discover_domains() function. If src/{domain}/ has __init__.py and infrastructure/di/{domain}_container.py, it gets picked up automatically at the app level. You still keep per-domain bootstrap code inside the domain — no central bootstrap.py or container.py edits needed.
This sounds small but it removed so much friction. New developer wants to add a domain? Just create the folder structure and it works.
Keeping the architecture from rotting
The other problem with a growing team is architecture erosion. Someone imports a repository from the domain layer. Someone else calls a service directly from another domain's infrastructure. These tiny violations add up until the dependency graph is a mess.
I added pre-commit hooks that catch the main forbidden edge: src/*/domain/** importing src.*.infrastructure. It doesn't prove the whole graph is perfect, but it removes one common review miss. If your domain layer tries to import from infrastructure, the commit gets rejected.
It felt aggressive at first, but it's saved us from so many "how did this dependency get here?" debugging sessions.
One unexpected thing
This part is optional — the CRUD and DI pattern works without any AI tooling installed.
But I did start using Claude Code with this project and ended up building slash commands for common tasks. /new-domain product scaffolds 44 files (4 DDD layers, baseline tests) in one command. /review-architecture checks if anything violates the layer rules. There are 14 Claude Code skills and 14 Codex CLI skills in the repo now — both point back to the same AGENTS.md rules file, so you can swap between them with just a / vs $ prefix change.
The one that surprised me most was /onboard. New developers run it and it walks them through the project structure interactively. It's not essential, but it reduced the amount of architecture explanation I had to repeat during onboarding.
What I'd do differently
I probably over-engineered the DI container setup early on. dependency-injector is powerful but has a learning curve. If I started over, I might keep it simpler for the first few domains and introduce it later.
Also, I spent too long before writing ADRs (Architecture Decision Records). Once I started documenting why I chose Taskiq over Celery, or why I switched from Poetry to uv, it became so much easier to onboard people and settle debates. We're at 18 active ADRs now.
The repo
I open-sourced it here: github.com/Mr-DooSun/fastapi-agent-blueprint
It's been working well but I'm curious how others handle this. If you're managing a FastAPI project with a lot of domains, what patterns have you settled on? Especially interested in how people handle the "same CRUD, different model" problem.
Update (May 2026 — v0.6.0)
A couple of months after the March post, the project has grown into a fuller platform. Here's what changed in v0.6.0.
JWT auth domain + minimal RBAC
A full src/auth/ domain ships out of the box:
POST /v1/auth/register
POST /v1/auth/login
POST /v1/auth/refresh
POST /v1/auth/logout
GET /v1/auth/me
HS256 tokens, DB-backed refresh token rotation, and a User.role field for admin gating. The NiceGUI admin UI now validates against the same auth domain instead of a plain env-var password.
End-to-end RAG example
src/docs/ is a worked RAG domain — upload documents, ask questions, get structured answers with citations. It boots with zero credentials by default (stub keyword embedder + stub answer agent):
make quickstart # terminal 1: FastAPI on :8001, SQLite
make demo-rag # terminal 2: seeds 3 docs → query → answer
Set EMBEDDING_PROVIDER + EMBEDDING_MODEL and LLM_PROVIDER + LLM_MODEL, plus provider credentials, in _env/quickstart.env to swap in OpenAI or Bedrock — the pipeline code path stays the same.
The reusable query orchestration lives in src/_core/ as RagPipeline(Generic[TChunk]); document chunking uses the shared chunk_text helper, so future domains can reuse the same pieces instead of duplicating retrieval code.
OpenTelemetry (opt-in)
uv sync --extra otel
OTEL_ENABLED=true OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 make dev
Works with Jaeger, Tempo, or Phoenix. Disabled by default — the server boots cleanly without it.
What I should have been clearer about the first time
The original post was mostly "look what I built." In hindsight it should have been upfront about trade-offs.
Don't use this if:
- You're building a single-purpose microservice — DDD layer overhead isn't worth it
- You prefer FastAPI's native
Dependseverywhere — this usesdependency-injectorIoC, which adds indirection - You want a frontend included — tiangolo/full-stack-fastapi-template is the right pick
- You're benchmarking raw requests/sec — use bare ASGI or Robyn
Full comparison vs. tiangolo/full-stack, s3rius/FastAPI-template, teamhide/boilerplate, Litestar, Robyn, and cookiecutter → docs/comparison.md
Links
- GitHub: github.com/Mr-DooSun/fastapi-agent-blueprint
- v0.6.0 Release Notes: releases/tag/v0.6.0
- Full end-to-end demo (auth · RBAC · worker · RAG · OTEL): docs/canonical-demo.md
-
Adoption guide (copy
_core/into an existing project): docs/adoption.md
Top comments (1)
The discover_domains() auto-registration is clever — we hit the same boilerplate explosion in FastAPI and landed on generics too. One gotcha worth watching: override methods can silently shadow the base when signatures drift between versions.