article
Hexagonal Architecture and DDD on a Chat-First Portfolio
How ports-and-adapters and DDD-lite tactical patterns shape this site's Rust backend — the layer map, the ten ports, and the full pipeline from embedding to grounded chat, traced through the architecture.
This site is a portfolio with a RAG chatbot in front of it: a Rust/Axum API, an Angular frontend, MongoDB Atlas for documents and search, OpenAI for embeddings, Gemini for chat. The RAG series covers what the pipeline does. This article covers where the code lives — and why a hobby-sized project still deserves an architecture.
Hexagonal architecture, briefly
Hexagonal architecture (also called ports and adapters, Alistair Cockburn's term) says: your business logic sits in the middle and knows nothing about the outside world. Everything it needs from outside — a database, an LLM API, an email service — is expressed as a port: an interface the core owns. Concrete technologies plug into those ports as adapters.
Two sides of the hexagon:
- Driving side — things that call into the core: HTTP handlers, schedulers, CLIs.
- Driven side — things the core calls out to: persistence, search, AI providers, storage, email.
The payoff is dependency direction. The core never imports MongoDB or reqwest. MongoDB and reqwest import the core's interfaces. Swap the adapter, keep the logic.
DDD, briefly — and honestly
Domain-Driven Design is a big toolbox. The strategic half (bounded contexts, context maps, subdomain heat maps) earns its keep on systems with many teams and many models. A one-person portfolio has exactly one model, so I use the tactical half, deliberately DDD-lite:
- Ubiquitous language — knowledge item, content type, published, catalog, focus terms, grounding. The same words appear in the code, the docs, the commit messages, and these articles.
- One aggregate —
KnowledgeItem: a published project, experience, skill, article, testimonial, or the About singleton. Every surface (chat, SEO pages, admin, the PDF CV) reads from this single collection. - Value objects —
Slugparses and validates on construction; invalid slugs are unrepresentable. Tags normalize themselves. Metadata validates per content type. - Domain services — pure functions for things that are business rules but not entity state: intent routing (
chat_navigation), focus-term extraction (query_focus), skills ranking, date-range sorting. - Domain errors —
DomainErrorspeaks the language of the model ("required field", "invalid slug"), not the language of HTTP or MongoDB.
No event sourcing, no CQRS, no aggregate roots guarding fleets of entities. The corpus is CV-sized; the ceremony should be too.
The hexagon, as built
flowchart LR
subgraph driving [Driving adapters]
Handlers[Axum handlers and SSE]
DTOs[DTO mapping]
end
subgraph core [Application core]
App[Application services<br/>chat rag embeddings admin content]
Domain[Domain<br/>entities value objects rules]
Ports[Ports<br/>traits owned by the domain]
App --> Domain
App --> Ports
end
subgraph driven [Driven adapters]
Mongo[MongoDB repositories]
Atlas[Atlas text and vector search]
MockSearch[Mock search]
AI[Remote AI client<br/>OpenAI and Gemini]
MockAI[Mock AI provider]
S3[S3 storage]
SES[SES email]
Typst[Typst PDF compiler]
GH[GitHub Actions deploy trigger]
end
Handlers --> DTOs --> App
Ports -.implemented by.-> Mongo
Ports -.implemented by.-> Atlas
Ports -.implemented by.-> MockSearch
Ports -.implemented by.-> AI
Ports -.implemented by.-> MockAI
Ports -.implemented by.-> S3
Ports -.implemented by.-> SES
Ports -.implemented by.-> Typst
Ports -.implemented by.-> GH
In the repository this maps one-to-one onto four folders:
| Layer | Folder | Contains |
|---|---|---|
| Domain | backend/src/domain/ |
KnowledgeItem, Slug, metadata validation, routing rules, ports/ traits |
| Application | backend/src/application/ |
ChatService, RagService, RetrievalService, EmbeddingService, admin/content/contact/seo services |
| Infrastructure | backend/src/infrastructure/ |
Mongo repositories, Atlas + mock search, remote + mock AI, S3, SES, JWT, Typst, rate limiting |
| API | backend/src/api/ |
Axum handlers, DTOs, middleware, routes — no business logic allowed |
Ten ports live in domain/ports/: repositories, search, AI, catalog, storage, email, auth, PDF, rate limiting, and deploy triggering. Every port that fronts a paid or cloud service — AI, search, storage, email — has a mock twin next to its production adapter; the local-friendly ones (JWT, Argon2, Typst) run as-is in tests. That pairing is the whole point.
Why this fits a chat-first portfolio
Three forces made the architecture pull its weight here.
Provider churn is guaranteed. Chat moved from OpenAI to Gemini for cost and EU-disclosure reasons; embeddings stayed on OpenAI so the vector index would not need a rebuild. Because both sit behind one AiProvider port with separate chat and embedding configuration, that split was a wiring change in the composition root — not a rewrite of the RAG service.
Tests cannot depend on the cloud. CI has no OpenAI key, no Gemini key, no Atlas cluster. The mock AI provider and mock search adapter implement the same ports as the real ones, so every grounding rule, routing rule, and retrieval merge is an ordinary unit or integration test. A config switch (SEARCH_BACKEND) picks the adapter; the application code cannot tell the difference.
Grounding is a business rule, not a prompt. The non-negotiable rule — the assistant may only claim what the corpus supports — lives in application-layer gates (grounded_validation, grounded_fallback), not inside any adapter. Whichever LLM sits behind the port, the same validation runs on its output.
The flow, end to end
The two pipelines from the RAG series, this time annotated with which layer does what.
Ingestion — from publish to vector. The handler only maps DTOs. The application service orchestrates. The domain builds the embedding input. Adapters talk to OpenAI and MongoDB.
sequenceDiagram participant Admin as Admin UI participant API as Axum handler api layer participant Content as ContentService application participant Item as KnowledgeItem domain participant Embed as EmbeddingService application participant AIPort as AiProvider port participant OpenAI as OpenAI adapter participant Repo as KnowledgeItemRepository port participant Mongo as MongoDB adapter Admin->>API: PUT content item API->>Content: update via DTO mapping Content->>Item: validate slug tags metadata Item-->>Content: ok or DomainError Content->>Repo: persist item Repo->>Mongo: update document Content->>Embed: spawn embedding job on publish Embed->>Item: build embedding input text Embed->>AIPort: embed text AIPort->>OpenAI: POST embeddings 1536-d OpenAI-->>AIPort: vector Embed->>Repo: store embedding Repo->>Mongo: set embedding field Note over Mongo: Atlas indexes vector and text
Retrieval — from question to grounded answer. Deterministic routing happens in pure domain functions before any port is touched. Only when the message falls through to RAG do the search, catalog, and AI ports come into play.
sequenceDiagram
participant Visitor
participant API as Axum handler api layer
participant Chat as ChatService application
participant Route as chat_navigation domain
participant Rag as RagService application
participant Search as KnowledgeItemSearch port
participant Atlas as Atlas adapter
participant Cat as CatalogSource port
participant AIPort as AiProvider port
participant Gemini as Gemini adapter
participant Guard as Grounding gates application
Visitor->>API: POST /api/chat
API->>Chat: message plus session id
Chat->>Route: resolve app command or skills ranking
alt deterministic route
Route-->>Chat: navigate or ranked answer
else falls through to RAG
Chat->>Rag: answer with context
Rag->>AIPort: embed query
par hybrid retrieval
Rag->>Search: text search top 5
Rag->>Search: vector search top 5
end
Search->>Atlas: run both channels
Atlas-->>Rag: merged top 8 hits
Rag->>Cat: load published catalog
Cat-->>Rag: compact fact lines
Rag->>AIPort: grounded completion request
AIPort->>Gemini: stream tokens
Gemini-->>Guard: token stream
Guard-->>API: validated answer plus sources
end
API-->>Visitor: SSE tokens sources done
Notice what the diagrams don't show: no handler builds a prompt, no domain function knows what MongoDB is, and Gemini appears only at the edge, behind the same port the mock provider implements in tests.
What this buys me
For this site, hexagonal + DDD-lite is not architecture astronautics. It is three concrete guarantees:
- Swap freedom — chat models, search backends, and email senders changed without touching business logic.
- Honest tests — every grounding and routing rule runs in CI against mocks that share contracts with production adapters.
- A place for everything — when a new rule appears ("analytical questions must not trigger navigation"), there is exactly one layer it belongs to.
The catch: the code did not start in this shape. Day one had domain entities and mocks, but also nineteen feature folders and a port that lived next to its own adapter. The mid-project refactor that fixed it — and what it cost — is the next article.