<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: doo</title>
    <description>The latest articles on DEV Community by doo (@mr-doosun).</description>
    <link>https://dev.to/mr-doosun</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3845436%2F481cf630-6f72-4d83-ad31-2a4438121aaa.png</url>
      <title>DEV Community: doo</title>
      <link>https://dev.to/mr-doosun</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mr-doosun"/>
    <language>en</language>
    <item>
      <title>My FastAPI project turned into repeated CRUD, so I built a reusable pattern</title>
      <dc:creator>doo</dc:creator>
      <pubDate>Sat, 28 Mar 2026 18:07:32 +0000</pubDate>
      <link>https://dev.to/mr-doosun/i-got-mass-of-crud-in-my-fastapi-project-so-i-fixed-it-3fl0</link>
      <guid>https://dev.to/mr-doosun/i-got-mass-of-crud-in-my-fastapi-project-so-i-fixed-it-3fl0</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;I kept thinking there has to be a better way to do this.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was actually repeating
&lt;/h2&gt;

&lt;p&gt;At a simplified level, the repetition looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@router.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/users&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;UserCreate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AsyncSession&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_db&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="n"&gt;new_user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model_dump&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;new_user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;new_user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;new_user&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I'd write the same thing for products. And orders. And payments. Eight operations each, all nearly identical except for the model name.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I ended up building
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProductRepository&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseRepository&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ProductDTO&lt;/span&gt;&lt;span class="p"&gt;]):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;database&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Database&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;super&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;database&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;database&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ProductModel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;return_entity&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ProductDTO&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProductService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseService&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;CreateProductRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;UpdateProductRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ProductDTO&lt;/span&gt;&lt;span class="p"&gt;]):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;product_repository&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ProductRepositoryProtocol&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;super&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;product_repository&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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: &lt;code&gt;create_data&lt;/code&gt;, &lt;code&gt;create_datas&lt;/code&gt;, &lt;code&gt;get_data_by_data_id&lt;/code&gt;, &lt;code&gt;get_datas_by_data_ids&lt;/code&gt;, &lt;code&gt;get_datas&lt;/code&gt; (paginated via &lt;code&gt;QueryFilter&lt;/code&gt;), &lt;code&gt;update_data_by_data_id&lt;/code&gt;, &lt;code&gt;delete_data_by_data_id&lt;/code&gt;, and &lt;code&gt;count_datas&lt;/code&gt;. If I need custom logic, I just override the specific method. The rest stays as-is.&lt;/p&gt;

&lt;p&gt;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."&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other thing that was driving me crazy
&lt;/h2&gt;

&lt;p&gt;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."&lt;/p&gt;

&lt;p&gt;So I wrote a &lt;code&gt;discover_domains()&lt;/code&gt; function. If &lt;code&gt;src/{domain}/&lt;/code&gt; has &lt;code&gt;__init__.py&lt;/code&gt; and &lt;code&gt;infrastructure/di/{domain}_container.py&lt;/code&gt;, it gets picked up automatically at the app level. You still keep per-domain bootstrap code inside the domain — no central &lt;code&gt;bootstrap.py&lt;/code&gt; or &lt;code&gt;container.py&lt;/code&gt; edits needed.&lt;/p&gt;

&lt;p&gt;This sounds small but it removed so much friction. New developer wants to add a domain? Just create the folder structure and it works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping the architecture from rotting
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;I added pre-commit hooks that catch the main forbidden edge: &lt;code&gt;src/*/domain/**&lt;/code&gt; importing &lt;code&gt;src.*.infrastructure&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;It felt aggressive at first, but it's saved us from so many "how did this dependency get here?" debugging sessions.&lt;/p&gt;

&lt;h2&gt;
  
  
  One unexpected thing
&lt;/h2&gt;

&lt;p&gt;This part is optional — the CRUD and DI pattern works without any AI tooling installed.&lt;/p&gt;

&lt;p&gt;But I did start using Claude Code with this project and ended up building slash commands for common tasks. &lt;code&gt;/new-domain product&lt;/code&gt; scaffolds 44 files (4 DDD layers, baseline tests) in one command. &lt;code&gt;/review-architecture&lt;/code&gt; 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 &lt;code&gt;AGENTS.md&lt;/code&gt; rules file, so you can swap between them with just a &lt;code&gt;/&lt;/code&gt; vs &lt;code&gt;$&lt;/code&gt; prefix change.&lt;/p&gt;

&lt;p&gt;The one that surprised me most was &lt;code&gt;/onboard&lt;/code&gt;. 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;I probably over-engineered the DI container setup early on. &lt;code&gt;dependency-injector&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The repo
&lt;/h2&gt;

&lt;p&gt;I open-sourced it here: &lt;a href="https://github.com/Mr-DooSun/fastapi-agent-blueprint" rel="noopener noreferrer"&gt;github.com/Mr-DooSun/fastapi-agent-blueprint&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Update (May 2026 — v0.6.0)
&lt;/h2&gt;

&lt;p&gt;A couple of months after the March post, the project has grown into a fuller platform. Here's what changed in v0.6.0.&lt;/p&gt;

&lt;h3&gt;
  
  
  JWT auth domain + minimal RBAC
&lt;/h3&gt;

&lt;p&gt;A full &lt;code&gt;src/auth/&lt;/code&gt; domain ships out of the box:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /v1/auth/register
POST /v1/auth/login
POST /v1/auth/refresh
POST /v1/auth/logout
GET  /v1/auth/me
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;HS256 tokens, DB-backed refresh token rotation, and a &lt;code&gt;User.role&lt;/code&gt; field for admin gating. The NiceGUI admin UI now validates against the same auth domain instead of a plain env-var password.&lt;/p&gt;

&lt;h3&gt;
  
  
  End-to-end RAG example
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;src/docs/&lt;/code&gt; 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):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;make quickstart   &lt;span class="c"&gt;# terminal 1: FastAPI on :8001, SQLite&lt;/span&gt;
make demo-rag     &lt;span class="c"&gt;# terminal 2: seeds 3 docs → query → answer&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set &lt;code&gt;EMBEDDING_PROVIDER&lt;/code&gt; + &lt;code&gt;EMBEDDING_MODEL&lt;/code&gt; and &lt;code&gt;LLM_PROVIDER&lt;/code&gt; + &lt;code&gt;LLM_MODEL&lt;/code&gt;, plus provider credentials, in &lt;code&gt;_env/quickstart.env&lt;/code&gt; to swap in OpenAI or Bedrock — the pipeline code path stays the same.&lt;/p&gt;

&lt;p&gt;The reusable query orchestration lives in &lt;code&gt;src/_core/&lt;/code&gt; as &lt;code&gt;RagPipeline(Generic[TChunk])&lt;/code&gt;; document chunking uses the shared &lt;code&gt;chunk_text&lt;/code&gt; helper, so future domains can reuse the same pieces instead of duplicating retrieval code.&lt;/p&gt;

&lt;h3&gt;
  
  
  OpenTelemetry (opt-in)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uv &lt;span class="nb"&gt;sync&lt;/span&gt; &lt;span class="nt"&gt;--extra&lt;/span&gt; otel
&lt;span class="nv"&gt;OTEL_ENABLED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true &lt;/span&gt;&lt;span class="nv"&gt;OTEL_EXPORTER_OTLP_ENDPOINT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://localhost:4317 make dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Works with Jaeger, Tempo, or Phoenix. Disabled by default — the server boots cleanly without it.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I should have been clearer about the first time
&lt;/h3&gt;

&lt;p&gt;The original post was mostly "look what I built." In hindsight it should have been upfront about trade-offs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't use this if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You're building a single-purpose microservice — DDD layer overhead isn't worth it&lt;/li&gt;
&lt;li&gt;You prefer FastAPI's native &lt;code&gt;Depends&lt;/code&gt; everywhere — this uses &lt;code&gt;dependency-injector&lt;/code&gt; IoC, which adds indirection&lt;/li&gt;
&lt;li&gt;You want a frontend included — &lt;a href="https://github.com/fastapi/full-stack-fastapi-template" rel="noopener noreferrer"&gt;tiangolo/full-stack-fastapi-template&lt;/a&gt; is the right pick&lt;/li&gt;
&lt;li&gt;You're benchmarking raw requests/sec — use bare ASGI or Robyn&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Full comparison vs. tiangolo/full-stack, s3rius/FastAPI-template, teamhide/boilerplate, Litestar, Robyn, and cookiecutter → &lt;a href="https://github.com/Mr-DooSun/fastapi-agent-blueprint/blob/main/docs/comparison.md" rel="noopener noreferrer"&gt;docs/comparison.md&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Links
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub&lt;/strong&gt;: &lt;a href="https://github.com/Mr-DooSun/fastapi-agent-blueprint" rel="noopener noreferrer"&gt;github.com/Mr-DooSun/fastapi-agent-blueprint&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;v0.6.0 Release Notes&lt;/strong&gt;: &lt;a href="https://github.com/Mr-DooSun/fastapi-agent-blueprint/releases/tag/v0.6.0" rel="noopener noreferrer"&gt;releases/tag/v0.6.0&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full end-to-end demo&lt;/strong&gt; (auth · RBAC · worker · RAG · OTEL): &lt;a href="https://github.com/Mr-DooSun/fastapi-agent-blueprint/blob/main/docs/canonical-demo.md" rel="noopener noreferrer"&gt;docs/canonical-demo.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adoption guide&lt;/strong&gt; (copy &lt;code&gt;_core/&lt;/code&gt; into an existing project): &lt;a href="https://github.com/Mr-DooSun/fastapi-agent-blueprint/blob/main/docs/adoption.md" rel="noopener noreferrer"&gt;docs/adoption.md&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>showdev</category>
      <category>fastapi</category>
      <category>python</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
