If you dig through the backend guide for my stock app, you’ll find something a little out of place. There’s a repository filter example with "team.name:eq": "Justice League" sitting right in it. It’s a stock app — so why is a superhero team name in there?
There’s a reason. But to explain it, I have to start with the order in which I built this thing.
The skeleton came before the features
When I started the stock app with some colleagues, I didn’t build features first. I built the common skeleton first. Before anyone went off into their own domain, I wrote out all the shared parts myself, so that when they started, they’d be building on top of an existing frame.
Back then my workflow was a kind of back-and-forth: I set the direction, and Google’s free CLI (Gemini) did the implementation. The problem was that if I tossed it “make me a portfolio repository” without a frame, the AI came back with a slightly different pattern every time. Six domains meant six different ways of doing the same thing. So before fanning out into domains, I had to nail down the common base.
Skeleton, part 1 — three common base classes
Under app/domain/common/base, I set up three base classes.
BaseSqlModel: the fields every table shares (id,inserted_at/by,updated_at/by) plus create/update methods. Add a new domain, inherit this, and the common fields just come along for free.BaseRepository: the common CRUD —get,find,find_all,query,create,delete— wrapped up generically. Internally aQueryBuilderhandles filtering, sorting, pagination, and relationship loading.- The
BaseSchemafamily, four of them: the shared config for request/response schemas.
Six domain modules (member, account, portfolio, and so on) all inherit from these base classes. The one I liked most was BaseRepository. The basic queries were all handled by it, and each domain only had to layer on the special queries it actually needed. Pass find_all a dictionary filter like "stock_name:like" and the SQL just goes out — so I never had to rewrite the same lookup code in a domain repository.
This is where the Justice League comes in. To check whether BaseRepository was really domain-independent, I wrote the filter example using a superhero team instead of stocks. If a filter for team.name equal to "Justice League" reads naturally, that means stocks or superheroes both run through the same API. If you can swap out the domain and the syntax still isn’t awkward, the abstraction is doing its job. That trace is still sitting in the guide today.
Skeleton, part 2 — a 15-chapter backend guide
The base classes alone weren’t enough. So I wrote a guide. Intro, project structure, common module, models, schemas, repository, router, service, tasks, tests, migrations, Docker, exception handling, coding style, and even how to use Gemini. It came out to fifteen chapters.
Why write all of that? Because I needed something I could point the AI at and say “build it inside this frame.” Without the guide, the AI proposes a different pattern each time, and then you burn time reviewing, fixing, and realigning it. With the guide, “look at 05_feature_router.md and make the account domain router” becomes possible. When you pointed Gemini directly at a file with @, it followed the rules in there and stayed consistent.
It was also a document for people. Only when humans and the AI both work off the same standard does it stop being obvious who wrote which piece of code.
What I was left with
Laying down the skeleton first made attaching a new domain light work. Add something like notifications, and a few lines of inheriting BaseSqlModel gets the common fields attached automatically, while BaseRepository already handles the lookups.
More than anything, in a setup where I set the direction and the AI does the implementation, the AI’s output came out in the same shape every time. That made review light. The thought I landed on after doing it: the AI is strong at following a set pattern and weak at creating one. Laying that pattern down ahead of time was the skeleton work.
Nailing down three common base classes before building ten features. Looking back, that was the faster path.