# ADR-001: Architecture of stub-service — Go-based HTTP Stub/Mock Service
**Status:** Proposed
**Date:** 2026-05-07
**Authors:** backend-agent
---
## Context
Our development and QA workflows require a service-virtualization tool that can stand in for real HTTP services during integration testing, local development, and demo environments. [Mountebank](http://www.mbtest.org/) is the de-facto open-source option, but it presents several pain points for our team:
1. **Node.js runtime** — adds a runtime dependency our Go/Java-centric infrastructure doesn't otherwise need.
2. **No built-in Web UI** — managing imposters requires curl/Postman or third-party UIs.
3. **No AI-assisted authoring** — creating complex stubs is manual and error-prone.
4. **Limited extensibility** — behaviours and predicates are plugin-based in JavaScript, which is awkward to extend from our stack.
We need a lightweight, single-binary alternative written in Go that keeps Mountebank's core concepts (imposters, stubs, predicates, responses, proxies) while adding a web UI and LLM-powered stub generation.
---
## Decision
Build **stub-service** — a Go application that provides:
- An HTTP stub server with rich request matching
- A REST management API (CRUD for imposters/stubs)
- A web UI for visual management and real-time request logging
- An LLM adapter for natural-language stub authoring
- Proxy mode with record/replay capability
### High-Level Architecture
```
┌─────────────────────────────────────────────────────────────────────┐
│ stub-service process │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌────────────────────────┐ │
│ │ Management │ │ Web UI │ │ Stub Listeners │ │
│ │ REST API │ │ (embedded) │ │ (dynamic ports) │ │
│ │ :8080/api/v1 │ │ :8080/ui │ │ :N per imposter │ │
│ └──────┬───────┘ └──────┬───────┘ └───────────┬────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Core Engine │ │
│ │ ┌─────────────┐ ┌──────────┐ ┌───────────┐ ┌────────┐ │ │
│ │ │ Imposter │ │ Matcher │ │ Response │ │ Proxy │ │ │
│ │ │ Manager │ │ Engine │ │ Renderer │ │ Module │ │ │
│ │ └─────────────┘ └──────────┘ └───────────┘ └────────┘ │ │
│ └──────────────────────────┬──────────────────────────────────┘ │
│ │ │
│ ┌───────────────────┼────────────────────┐ │
│ ▼ ▼ ▼ │
│ ┌─────────────┐ ┌──────────────┐ ┌─────────────────┐ │
│ │ Stub Store │ │ Request Log │ │ LLM Adapter │ │
│ │ (SQLite / │ │ (ring buf) │ │ (local CPU / │ │
│ │ bbolt) │ │ │ │ Ollama) │ │
│ └─────────────┘ └──────────────┘ └─────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
```
### Component Breakdown
#### 1. Management REST API (`internal/api`)
The control plane. All imposter/stub CRUD operations happen here.
- Listens on a single admin port (default `:8080`), path prefix `/api/v1`
- JSON request/response bodies
- Endpoints (see API Design section below)
- Provides SSE endpoint for real-time request log streaming
#### 2. Web UI (`internal/ui`)
An embedded single-page application served from the same admin port under `/ui`.
- **Technology:** [Templ](https://templ.guide/) templates + [htmx](https://htmx.org/) + minimal CSS (e.g., Pico CSS or similar classless framework)
- **Rationale:** No JavaScript build step, no npm dependency. Templ compiles to Go, htmx provides dynamic behavior via HTML attributes. The result is a server-rendered UI that feels interactive without a JS framework.
- **Pages:**
- Dashboard — list of all imposters with status, port, hit counts
- Imposter detail — list of stubs, predicates, responses, recent requests
- Stub editor — form-based creation/editing of stubs with predicate/response builders
- Request log — real-time log of incoming requests (SSE-powered)
- LLM assistant — text input for natural-language stub creation
#### 3. Stub Listeners (`internal/server`)
Each imposter binds to a dedicated port and handles incoming traffic.
- Dynamically started/stopped when imposters are created/deleted
- Each listener runs an HTTP handler that delegates to the Matcher Engine
- TLS support via optional per-imposter cert/key configuration
#### 4. Core Engine (`internal/engine`)
The domain logic of the application.
- **Imposter Manager** — lifecycle management of imposters (create, update, delete, start, stop). Coordinates listener creation and stub store persistence.
- **Matcher Engine** — evaluates incoming requests against stub predicates. Returns the first matching stub or a configurable default response. Supports predicate composition (`and`, `or`, `not`).
- **Response Renderer** — produces the HTTP response from a stub definition. Handles static bodies, Go template rendering (with request data as context), latency injection, and header manipulation.
- **Proxy Module** — forwards requests to a real upstream, optionally records the response as a new stub for future replay.
#### 5. Stub Store (`internal/store`)
Persistence layer for imposters, stubs, and request logs.
- **Primary:** SQLite via [modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite) (pure Go, no CGO) or [bbolt](https://github.com/etcd-io/bbolt) for a simpler key-value approach.
- **Decision:** SQLite preferred — it supports rich queries for filtering stubs and request logs, and the schema maps naturally to the relational data model.
- Migrations managed via embedded SQL files (`embed` package).
- Backup: the SQLite file can be copied while the service runs (WAL mode).
- Optional file-based export/import in JSON format for portability.
#### 6. LLM Adapter (`internal/llm`)
Translates natural-language descriptions into stub configurations using a **local, CPU-only** LLM. No external API calls — everything runs on-premise, keeping data private and eliminating network latency / cloud costs.
- **Interface:** A `StubGenerator` interface that takes a text prompt and returns a structured stub definition.
- **Runtime:** [Ollama](https://ollama.com/) runs as a sidecar container (or host-installed binary). It serves an OpenAI-compatible HTTP API on `localhost:11434` and manages model downloads, quantization, and inference. No GPU required.
- **Model selection (CPU, ≤ 60 s per generation):**
- **Primary: `qwen2.5-coder:1.5b-instruct-q4_K_M`** (~1 GB RAM) — strong code/JSON generation for its size; comfortably produces a stub JSON within 15–40 s on a 4-core CPU.
- **Fallback: `phi3:mini-4k-instruct-q4_K_M`** (3.8 B, ~2.3 GB RAM) — higher quality output when the host has ≥ 8 GB RAM; generation stays under 60 s on 4+ cores.
- The model is configurable via `LLM_MODEL` env var. Any Ollama-compatible GGUF model can be substituted.
- **Prompt strategy:** System prompt contains the stub JSON schema and 2-3 few-shot examples. User message is the natural-language description. Response is constrained to JSON via Ollama's `format: "json"` parameter. The result is validated against the stub schema before use.
- **Performance budget:** Generation must complete within **60 seconds** wall-clock time. The adapter enforces a context-based timeout; if the model exceeds it, the request fails with a clear error rather than hanging. Recommended: keep prompts under 512 tokens input and request ≤ 1024 tokens output.
- **Flow:**
1. User types: *"Return 200 with a JSON user profile when GET /api/users/123 is called"*
2. LLM adapter sends the prompt to the local Ollama API (`POST /api/generate`) with schema context and `format: "json"`
3. Response is parsed and validated against the stub schema
4. If valid, the stub is presented to the user for confirmation in the Web UI
5. On confirmation, the stub is saved via the Management API
- **Graceful degradation:** If Ollama is unavailable or the model is not pulled, the `/generate` endpoint returns `503 Service Unavailable` with a diagnostic message. All other stub-service functionality remains unaffected — the LLM feature is strictly optional.
#### 7. Request Log (`internal/log`)
In-memory ring buffer of recent requests per imposter, with optional persistence to SQLite.
- Stores: timestamp, method, path, headers, body (truncated), matched stub ID, response status, latency
- Queryable via Management API (filter by imposter, time range, match status)
- Streamed to the Web UI via SSE
---
### Technology Choices
| Concern | Choice | Rationale |
|---|---|---|
| Language | Go 1.23+ | Single binary, excellent HTTP stdlib, team expertise |
| HTTP framework | `net/http` (stdlib) + [chi](https://github.com/go-chi/chi) router | Minimal dependency, chi adds route params and middleware without abstracting stdlib |
| Web UI | Templ + htmx + Pico CSS | No JS build toolchain, server-rendered, interactive via htmx |
| Database | SQLite (modernc.org/sqlite) | Pure Go, zero CGO, rich SQL queries, single file |
| LLM runtime | [Ollama](https://ollama.com/) sidecar (local CPU) | No cloud dependency, data stays on-premise, zero API cost; `qwen2.5-coder:1.5b` generates stubs in < 60 s on CPU |
| Config | Environment variables + optional YAML file | 12-factor, simple for Docker |
| Logging | `log/slog` (stdlib) | Structured logging, zero deps |
| Testing | `testing` + [testify](https://github.com/stretchr/testify) | Assertions, standard tooling |
| TLS | `crypto/tls` (stdlib) | Per-imposter certs for HTTPS stubs |
---
### API Design Overview
Base URL: `http://localhost:8080/api/v1`
#### Imposters
| Method | Path | Description |
|---|---|---|
| `GET` | `/imposters` | List all imposters |
| `POST` | `/imposters` | Create a new imposter |
| `GET` | `/imposters/{id}` | Get imposter details |
| `PUT` | `/imposters/{id}` | Update imposter |
| `DELETE` | `/imposters/{id}` | Delete imposter and stop listener |
| `DELETE` | `/imposters` | Delete all imposters |
| `POST` | `/imposters/{id}/start` | Start the stub listener |
| `POST` | `/imposters/{id}/stop` | Stop the stub listener |
#### Stubs (nested under imposter)
| Method | Path | Description |
|---|---|---|
| `GET` | `/imposters/{id}/stubs` | List stubs for an imposter |
| `POST` | `/imposters/{id}/stubs` | Add a stub |
| `PUT` | `/imposters/{id}/stubs/{stubId}` | Update a stub |
| `DELETE` | `/imposters/{id}/stubs/{stubId}` | Delete a stub |
#### Request Log
| Method | Path | Description |
|---|---|---|
| `GET` | `/imposters/{id}/requests` | Get request log (paginated) |
| `GET` | `/imposters/{id}/requests/stream` | SSE stream of live requests |
| `DELETE` | `/imposters/{id}/requests` | Clear request log |
#### LLM
| Method | Path | Description |
|---|---|---|
| `POST` | `/generate` | Generate stub config from natural-language prompt |
#### System
| Method | Path | Description |
|---|---|---|
| `GET` | `/health` | Health check |
| `GET` | `/config` | Current configuration (redacted) |
| `POST` | `/import` | Import imposters from JSON file |
| `GET` | `/export` | Export all imposters as JSON |
---
### Data Model
#### Imposter
```json
{
"id": "uuid",
"name": "User Service Mock",
"protocol": "http",
"port": 4545,
"tls": {
"cert": "...",
"key": "..."
},
"default_response": {
"status": 404,
"headers": {"Content-Type": "application/json"},
"body": "{\"error\": \"no matching stub\"}"
},
"stubs": [ ... ],
"state": "running",
"created_at": "2026-05-07T10:00:00Z",
"request_count": 142
}
```
#### Stub
```json
{
"id": "uuid",
"imposter_id": "uuid",
"priority": 1,
"predicates": [ ... ],
"response": { ... },
"hit_count": 37,
"created_at": "2026-05-07T10:00:00Z"
}
```
#### Predicate
Predicates are composable. Each predicate has a `type` and type-specific fields:
```json
{
"type": "equals",
"field": "path",
"value": "/api/users/123"
}
```
```json
{
"type": "regex",
"field": "path",
"value": "^/api/users/\\d+$"
}
```
```json
{
"type": "jsonpath",
"expression": "$.order.items[?(@.quantity > 5)]",
"exists": true
}
```
```json
{
"type": "and",
"predicates": [
{"type": "equals", "field": "method", "value": "POST"},
{"type": "contains", "field": "body", "value": "\"action\":\"create\""}
]
}
```
Supported predicate types:
- `equals` — exact match on method, path, header, query param, or body
- `contains` — substring match
- `regex` — regular expression match
- `jsonpath` — JSONPath expression evaluation
- `xpath` — XPath expression evaluation (for XML bodies)
- `and` — all child predicates must match
- `or` — at least one child predicate must match
- `not` — invert a child predicate
Fields available for matching: `method`, `path`, `query.`, `header.`, `body`
#### Response
```json
{
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"body": "{\"id\": 123, \"name\": \"Alice\"}",
"template": false,
"latency_ms": 0,
"proxy": null
}
```
When `template: true`, the `body` is rendered as a Go template with the request context:
```
{
"echo": "You requested {{.Request.Path}} with method {{.Request.Method}}"
}
```
#### Proxy Response
```json
{
"proxy": {
"to": "https://real-service.example.com",
"mode": "record",
"add_wait_behavior": true
}
}
```
Proxy modes:
- `passthrough` — forward and return, do not record
- `record` — forward, return, and save as a new stub for future replay
- `replay` — use previously recorded response if available, otherwise forward
---
### SQLite Schema (initial)
```sql
CREATE TABLE imposters (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
protocol TEXT NOT NULL DEFAULT 'http',
port INTEGER NOT NULL UNIQUE,
tls_cert TEXT,
tls_key TEXT,
default_response_json TEXT,
state TEXT NOT NULL DEFAULT 'stopped',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE stubs (
id TEXT PRIMARY KEY,
imposter_id TEXT NOT NULL REFERENCES imposters(id) ON DELETE CASCADE,
priority INTEGER NOT NULL DEFAULT 0,
predicates_json TEXT NOT NULL,
response_json TEXT NOT NULL,
hit_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX idx_stubs_imposter ON stubs(imposter_id, priority);
CREATE TABLE request_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
imposter_id TEXT NOT NULL REFERENCES imposters(id) ON DELETE CASCADE,
timestamp TEXT NOT NULL,
method TEXT NOT NULL,
path TEXT NOT NULL,
headers_json TEXT,
body TEXT,
matched_stub_id TEXT,
response_status INTEGER,
latency_ms INTEGER,
created_at TEXT NOT NULL
);
CREATE INDEX idx_request_log_imposter_ts ON request_log(imposter_id, timestamp DESC);
```
---
### Project Layout
```
stub-service/
├── cmd/
│ └── stub-service/
│ └── main.go # Entry point, config loading, wiring
├── internal/
│ ├── api/ # Management REST API handlers
│ │ ├── handler.go
│ │ ├── imposter.go
│ │ ├── stub.go
│ │ ├── requestlog.go
│ │ ├── generate.go # LLM generation endpoint
│ │ └── middleware.go
│ ├── engine/ # Core domain logic
│ │ ├── imposter.go # Imposter manager
│ │ ├── matcher.go # Predicate matching engine
│ │ ├── renderer.go # Response rendering (static, template)
│ │ └── proxy.go # Proxy/record/replay
│ ├── model/ # Domain types
│ │ ├── imposter.go
│ │ ├── stub.go
│ │ ├── predicate.go
│ │ └── response.go
│ ├── store/ # Persistence
│ │ ├── store.go # Store interface
│ │ ├── sqlite.go # SQLite implementation
│ │ └── migrations/ # Embedded SQL migrations
│ │ └── 001_init.sql
│ ├── server/ # Dynamic stub HTTP listeners
│ │ └── listener.go
│ ├── llm/ # LLM integration (local, CPU-only)
│ │ ├── generator.go # StubGenerator interface + timeout logic
│ │ └── ollama.go # Ollama HTTP client implementation
│ ├── ui/ # Web UI
│ │ ├── templates/ # Templ files
│ │ ├── static/ # CSS, minimal JS (htmx)
│ │ └── handler.go # UI route handlers
│ └── config/ # Configuration loading
│ └── config.go
├── docs/
│ └── adr/
│ └── adr-001.md # This document
├── go.mod
├── go.sum
├── Dockerfile
├── docker-compose.yaml
├── Makefile
└── README.md
```
---
### Deployment Model
**Docker (primary):**
```dockerfile
# Build stage
FROM golang:1.23-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /stub-service ./cmd/stub-service
# Runtime stage
FROM alpine:3.20
RUN apk add --no-cache ca-certificates
COPY --from=build /stub-service /usr/local/bin/stub-service
EXPOSE 8080
VOLUME /data
ENTRYPOINT ["stub-service"]
```
**docker-compose.yaml (local dev):**
```yaml
services:
stub-service:
build: .
ports:
- "8080:8080" # Admin API + Web UI
- "4545-4600:4545-4600" # Range for stub listeners
volumes:
- stub-data:/data
environment:
STUB_ADMIN_PORT: "8080"
STUB_DATA_DIR: "/data"
STUB_LOG_LEVEL: "debug"
LLM_OLLAMA_URL: "http://ollama:11434"
LLM_MODEL: "qwen2.5-coder:1.5b-instruct-q4_K_M"
LLM_TIMEOUT: "60s"
depends_on:
ollama:
condition: service_started
ollama:
image: ollama/ollama:latest
volumes:
- ollama-models:/root/.ollama
# CPU-only — no GPU runtime required
# Pull the model on first start: docker compose exec ollama ollama pull qwen2.5-coder:1.5b-instruct-q4_K_M
volumes:
stub-data:
ollama-models:
```
**Configuration (environment variables):**
| Variable | Default | Description |
|---|---|---|
| `STUB_ADMIN_PORT` | `8080` | Admin API and Web UI port |
| `STUB_DATA_DIR` | `./data` | SQLite database directory |
| `STUB_LOG_LEVEL` | `info` | Log level (debug, info, warn, error) |
| `STUB_PORT_RANGE_START` | `4545` | Start of port range for stub listeners |
| `STUB_PORT_RANGE_END` | `4600` | End of port range for stub listeners |
| `LLM_OLLAMA_URL` | `http://localhost:11434` | Ollama API base URL |
| `LLM_MODEL` | `qwen2.5-coder:1.5b-instruct-q4_K_M` | Ollama model tag for stub generation |
| `LLM_TIMEOUT` | `60s` | Maximum wall-clock time for a single generation request |
---
## Consequences
### Positive
- **Single binary** — no runtime dependencies beyond the OS; trivial to deploy.
- **Embedded Web UI** — no separate frontend build/deploy pipeline; Templ + htmx keeps it all in Go.
- **SQLite** — zero operational overhead, no external database to manage, easy backup (copy a file).
- **Local LLM integration** — significantly lowers the barrier for creating complex stubs, especially for QA engineers unfamiliar with JSONPath/regex predicates. Runs entirely on-premise (CPU-only, no GPU), so sensitive request/response data never leaves the network and there are zero API costs.
- **Mountebank-compatible concepts** — teams familiar with Mountebank can transfer their mental model (imposters, stubs, predicates).
- **Port-per-imposter isolation** — each mock service gets its own port, matching how real services are addressed.
### Negative
- **Port range management** — dynamic port allocation requires firewall/Docker port-range configuration.
- **SQLite single-writer** — under very heavy concurrent management API load, SQLite's single-writer lock could be a bottleneck. Acceptable for a testing/dev tool.
- **LLM resource usage** — the Ollama sidecar with a 1.5 B model requires ~1 GB RAM and consumes noticeable CPU during generation (~15–40 s on 4 cores). Larger fallback models (3.8 B) need ~2.3 GB RAM. The feature is optional — the service works fully without Ollama running.
- **No multi-protocol support initially** — only HTTP/HTTPS. TCP/SMTP stubs (which Mountebank supports) are deferred to a future iteration.
### Risks
- **Templ maturity** — Templ is relatively new. Mitigation: the UI is simple enough that switching to `html/template` is feasible with moderate effort.
- **JSONPath/XPath complexity** — Go's ecosystem for JSONPath and XPath is less mature than Node.js. Mitigation: use well-maintained libraries ([github.com/PaesslerAG/jsonpath](https://github.com/PaesslerAG/jsonpath), [github.com/antchfx/xmlquery](https://github.com/antchfx/xmlquery)) and test thoroughly.
---
## References
- [Mountebank](http://www.mbtest.org/) — the inspiration
- [chi router](https://github.com/go-chi/chi)
- [Templ](https://templ.guide/) — Go HTML templating
- [htmx](https://htmx.org/) — HTML-driven interactivity
- [modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite) — pure Go SQLite