21 KiB
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 is the de-facto open-source option, but it presents several pain points for our team:
- Node.js runtime — adds a runtime dependency our Go/Java-centric infrastructure doesn't otherwise need.
- No built-in Web UI — managing imposters requires curl/Postman or third-party UIs.
- No AI-assisted authoring — creating complex stubs is manual and error-prone.
- 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) │ │ (Anthropic / │ │
│ │ bbolt) │ │ │ │ OpenAI) │ │
│ └─────────────┘ └──────────────┘ └─────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
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 templates + htmx + 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 (pure Go, no CGO) or 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 (
embedpackage). - 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.
- Interface: A
StubGeneratorinterface that takes a text prompt and returns a structured stub definition. - Implementation: HTTP client calling the Anthropic Messages API (Claude) or OpenAI Chat Completions API, configurable via environment variables.
- Prompt strategy: System prompt contains the stub JSON schema and examples. User message is the natural-language description. The LLM returns a JSON stub definition that is validated before being applied.
- Flow:
- User types: "Return 200 with a JSON user profile when GET /api/users/123 is called"
- LLM adapter sends the prompt with schema context
- Response is parsed and validated against the stub schema
- If valid, the stub is presented to the user for confirmation in the Web UI
- On confirmation, the stub is saved via the Management API
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 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 client | Direct HTTP (Anthropic SDK or net/http) |
Avoid heavy SDK deps; a thin wrapper suffices |
| Config | Environment variables + optional YAML file | 12-factor, simple for Docker |
| Logging | log/slog (stdlib) |
Structured logging, zero deps |
| Testing | testing + 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
{
"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
{
"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:
{
"type": "equals",
"field": "path",
"value": "/api/users/123"
}
{
"type": "regex",
"field": "path",
"value": "^/api/users/\\d+$"
}
{
"type": "jsonpath",
"expression": "$.order.items[?(@.quantity > 5)]",
"exists": true
}
{
"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 bodycontains— substring matchregex— regular expression matchjsonpath— JSONPath expression evaluationxpath— XPath expression evaluation (for XML bodies)and— all child predicates must matchor— at least one child predicate must matchnot— invert a child predicate
Fields available for matching: method, path, query.<param>, header.<name>, body
Response
{
"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
{
"proxy": {
"to": "https://real-service.example.com",
"mode": "record",
"add_wait_behavior": true
}
}
Proxy modes:
passthrough— forward and return, do not recordrecord— forward, return, and save as a new stub for future replayreplay— use previously recorded response if available, otherwise forward
SQLite Schema (initial)
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
│ │ ├── generator.go # StubGenerator interface
│ │ ├── anthropic.go # Anthropic/Claude implementation
│ │ └── openai.go # OpenAI 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):
# 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):
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_PROVIDER: "anthropic"
LLM_API_KEY: "${LLM_API_KEY}"
LLM_MODEL: "claude-sonnet-4-6"
volumes:
stub-data:
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_PROVIDER |
(none) | LLM provider: anthropic or openai |
LLM_API_KEY |
(none) | LLM API key |
LLM_MODEL |
claude-sonnet-4-6 |
Model to use for stub generation |
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).
- LLM integration — significantly lowers the barrier for creating complex stubs, especially for QA engineers unfamiliar with JSONPath/regex predicates.
- 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 dependency — the generation feature requires external API access and incurs cost. It is optional — the service works fully without it.
- 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/templateis 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, github.com/antchfx/xmlquery) and test thoroughly.
References
- Mountebank — the inspiration
- chi router
- Templ — Go HTML templating
- htmx — HTML-driven interactivity
- modernc.org/sqlite — pure Go SQLite