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 languageknowledge item, content type, published, catalog, focus terms, grounding. The same words appear in the code, the docs, the commit messages, and these articles.
  • One aggregateKnowledgeItem: 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 objectsSlug parses 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 errorsDomainError speaks 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:

  1. Swap freedom — chat models, search backends, and email senders changed without touching business logic.
  2. Honest tests — every grounding and routing rule runs in CI against mocks that share contracts with production adapters.
  3. 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.

Updated Aug 12, 2026