I've been working on a FastAPI project that grew to about 10 domains. 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
Every single domain needed the same thing:
@router.post("/user")
async def create_user(user: UserCreate):
db = get_db()
new_user = User(**user.dict())
db.add(new_user)
db.commit()
return new_user
Then I'd write the same thing for products. And orders. And payments. Seven CRUD 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[ProductDTO]):
def __init__(self, product_repository: ProductRepositoryProtocol):
super().__init__(repository=product_repository)
That's the entire setup for a new domain's CRUD. Seven async methods come from the base classes. If I need custom logic, I just override the specific method. The rest stays as-is.
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 a folder has an __init__.py and a DI container in the right place, it gets picked up automatically. No manual registration.
This sounds small but it removed so much friction. New developer wants to add a domain? Just create the folder structure and it works. No need to understand the entire app's wiring first.
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 check import boundaries. If your domain layer tries to import from infrastructure, the commit gets rejected. It felt aggressive at first, but honestly it's saved us from so many "how did this dependency get here?" debugging sessions.
One unexpected thing
I started using Claude Code with this project and ended up building slash commands for common tasks. /new-domain product scaffolds all the files. /review-architecture checks if anything violates the layer rules. There are about 12 of these now.
The one that surprised me most was /onboard. New developers run it and it walks them through the project structure interactively, adapting to their experience level. It's not essential but it cut onboarding time significantly.
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.
The repo
I open-sourced it here if anyone wants to take a look: github.com/Mr-DooSun/fastapi-blueprint
It's been working well for our team 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.
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.