article
From Feature Folders to Hexagonal Layers: A Mid-Project Refactor
The backend did not start hexagonal. How eighteen feature folders became four layers in one 148-file commit — port ownership, handler splitting, transitional facades, and what the discipline costs day to day.
The previous article shows this site's backend as a clean hexagon: domain in the middle, ten ports, adapters at the edge. That picture is true today. It was not true on day one, and pretending otherwise would waste the most useful part of the story.
This is the honest version: what the code looked like at the start, why I restructured it three weeks in, and which difficulties were real versus imagined.
Day one: DDD-lite, but no hexagon
The first backend commit (21 June 2026) already carried real DDD tactical patterns — a domain/ folder with the KnowledgeItem entity, a Slug value object that validates on parse, typed DomainErrors, and a repository trait. Mock adapters arrived within days: a mock AI provider alongside the OpenAI client, a mock search alongside Atlas. Grounding rules were unit-testable from the start.
So the ingredients of hexagonal architecture were there. The structure was not. Code organized itself by feature, and by early July the backend had eighteen top-level folders:
admin ai assets auth bin chat config contact content
db domain email embeddings middleware rag routes seo storage
Each feature folder mixed everything it needed: chat/ held DTOs, Axum handlers, orchestration, and skills ranking in one place. db/ held every MongoDB repository and the Atlas search adapter and the mock search. It worked — features shipped daily — but three cracks kept widening.
The three cracks
1. Ports owned by their adapters. The AiProvider trait — the contract the whole RAG pipeline depends on — lived in ai/, right next to remote.rs and mock.rs. The port belonged to the plug, not the socket. Worse, its CompletionRequest struct referenced a type from config/: a core contract importing infrastructure. Meanwhile the repository trait sat in domain/repository.rs and the search trait in domain/search.rs — the same architectural idea, scattered across three conventions.
2. Handlers doing business work. chat/handlers.rs had grown to 480 lines mixing HTTP concerns (SSE framing, status codes, DTO parsing) with orchestration (session loading, routing decisions, RAG calls, persistence). The site-deploy handler was 267 lines of the same blend. Every new chat feature made the entanglement worse, and testing orchestration meant standing up HTTP.
3. No answer to "where does this go?" With eighteen folders, every new concern triggered a placement debate with myself. Rate limiting: middleware or its own folder? The catalog cache: rag/ or db/? Inconsistent answers accumulated as drift.
The trigger for finally acting was the intent-routing work from the RAG lessons article. Weeks of routing refinements had made chat_navigation and query_focus — pure domain functions — the most-tested, most-churned code in the repository. The domain layer was visibly carrying the product. It deserved a structure that said so.
The refactor: one commit, 148 files
On 12 July 2026 the whole backend moved into four layers in a single commit: 148 files changed, ~3,450 insertions, ~2,845 deletions.
flowchart LR
subgraph before ["Before: 18 feature folders"]
chatF[chat<br/>dto handlers service]
adminF[admin<br/>dto handlers services]
aiF[ai<br/>trait remote mock]
dbF[db<br/>repos atlas mock]
ragF[rag]
more[13 more]
end
subgraph after ["After: 4 layers"]
apiL[api<br/>handlers dto middleware routes]
appL[application<br/>chat rag admin content ...]
domL[domain<br/>entities rules ports]
infraL[infrastructure<br/>persistence ai storage email ...]
end
chatF --> apiL
chatF --> appL
adminF --> apiL
adminF --> appL
aiF --> domL
aiF --> infraL
dbF --> infraL
ragF --> appL
The moves that mattered, in order of how much thinking they took:
Consolidating the ports. All contract traits moved into domain/ports/ — one folder, one convention, ten ports. The AiProvider trait left the adapter module and finally lost its config import: the reasoning-effort type became a domain enum, mapped at the adapter edge. This was the only part of the refactor that changed meaning rather than location, and it is the part I should have done weeks earlier.
Splitting the fat handlers. The 480-line chat handler became two files with a clean seam: api/handlers/chat/handlers.rs (292 lines — HTTP, SSE framing, DTO mapping) and application/chat/service.rs (230 lines — sessions, routing, RAG orchestration, persistence). The ChatService returns a ChatOutcome; the handler decides how to stream it. The site-deploy handler split the same way. Neither split required rewriting logic — the seam was already there conceptually, just not physically.
Keeping the tests green: transitional facades. The integration-test suite imported paths like notnull_api::chat::… and notnull_api::db::… everywhere. Rewriting 148 files and every test import in one commit is how refactors die. Instead, lib.rs grew re-export facades:
// Transitional public facades keep integration-test and downstream
// imports stable while implementation modules live in their hexagonal layers.
pub mod db {
pub use crate::infrastructure::persistence::*;
}
Old paths kept compiling; the test suite validated the move instead of fighting it. The facades are still there today — honest scaffolding, removable at leisure.
flowchart TB api[api layer] --> application[application layer] application --> domain["domain: entities rules ports"] infrastructure[infrastructure adapters] -.implements ports.-> domain lib[lib.rs composition root] --> api lib -.wires adapters into services.-> infrastructure
One rule survived as the enforcement mechanism, written into the repo's agent instructions: business logic lives in domain/ and application/ — never in route handlers. Every code review (human or AI) checks against it.
What was hard — and what was not
Hard: deciding to do it mid-project. The refactor produced zero user-visible features during days when the site had a growing backlog. The justification was the routing wars: churn had moved into domain logic, and the feature-folder layout taxed exactly that churn. Refactor when the pain is measurable, not when the diagram looks impure.
Hard: port ownership, conceptually. Moving files is mechanical. Deciding that the domain owns the AI contract — and therefore that the domain defines what "reasoning effort" means, with adapters translating to each provider's dialect — is the actual hexagonal lesson. Ownership inverted; imports followed.
Hard-ish: the composition root. After the split, lib.rs wires every Arc<dyn Port> into every service by hand — no DI framework, just a long run() function. It is verbose and it is fine: one place where all concrete choices (Atlas vs mock, remote AI vs mock, S3 on or off) become explicit.
Not hard: the move itself. Rust made the mechanical part safe. Every misplaced import is a compile error; the borrow checker does not care about folders. A morning of mv, path fixing, and facade writing — validated by an unchanged test suite.
The permanent tax. DTO structs mirror domain structs and someone must map them. New ports need mock adapters kept in contract parity. The composition root grows with every service. At this project's size the tax is roughly an extra file per feature — cheap next to the alternative I lived with for three weeks: orchestration logic trapped behind HTTP.
Lessons
- Tactical DDD from day one, layers when they earn it. Entities, value objects, and mocks paid off immediately. The full four-layer structure only paid off once orchestration complexity arrived — about three weeks in.
- Ports belong to the domain, physically. A trait next to its implementation quietly inverts ownership;
domain/ports/makes the direction visible in the file tree. - Refactor through facades, not through test rewrites. Re-exports let the old test suite validate the new structure. Scaffolding you can delete later beats a big-bang import rewrite.
- Watch where the churn lives. When the most-edited, most-tested code is pure domain logic, the architecture should put that code at the center. The commit history knew before I did.
- A 148-file commit is fine if behavior does not change. The refactor commit shipped no feature and fixed no bug — that is exactly what made it reviewable.
The hexagon in the previous article is real, but it was earned, not designed on a whiteboard. The best time to draw the boundaries was day one; the second-best time was the day the pain became measurable. I took the second, and the site never stopped shipping.