A tiny, but real distributed system built for learning and demonstration purposes.
It uses Go, PostgreSQL, Kafka, and Docker Compose to showcase API design, event-driven processing, observability, and idempotency.
tbd simulates a simple Order Processing System โ small enough to run locally, but rich enough to demonstrate real distributed system concepts:
- RESTful API
- PostgreSQL database
- Kafka-based event stream
- Background worker for async processing
- OpenTelemetry tracing + Prometheus metrics + Grafana dashboards
- Jaeger for distributed tracing
- pgAdmin and Kafka UI for observability
flowchart LR
%% Overall direction
%% Styles (optional; safe defaults)
classDef client fill:#eef,stroke:#88a;
classDef services fill:#eefeef,stroke:#6a6;
classDef messaging fill:#fff6dd,stroke:#cc9;
classDef data fill:#e8f2ff,stroke:#59a;
classDef observ fill:#f5f5f5,stroke:#999;
%% Client
subgraph client[Client]
direction TB
k6["k6 Load Tester"]:::client
end
%% Load Balancer
subgraph lb[Load Balancer]
direction TB
nginx["Nginx<br/>(Round-robin)"]:::services
end
%% Core services
subgraph services[Services]
direction TB
api["API (Go)<br/>REST<br/>(scalable)"]:::services
worker["Worker<br/>(Kafka Consumer/Producer)<br/>(scalable)"]:::services
end
%% Messaging
subgraph msg[Messaging]
direction TB
kafka[("(Kafka)<br/>order.created / order.processed")]:::messaging
end
%% Data
subgraph data[Data]
direction TB
postgres["Postgres"]:::data
end
%% Observability
subgraph obs[Observability]
direction TB
otel["OpenTelemetry Collector"]:::observ
jaeger["Jaeger"]:::observ
prometheus["Prometheus"]:::observ
grafana["Grafana"]:::observ
pgadmin["pgAdmin"]:::observ
kafkaui["Kafka UI"]:::observ
end
%% Happy-path flow
k6 --> nginx
nginx --> api
api -->|"(1) Write order"| postgres
api -->|"(2) Publish order.created"| kafka
kafka --> worker
worker -->|"(3) Update status"| postgres
worker -->|"(4) Emit order.processed"| kafka
%% Telemetry & UIs
api -->|"Traces & metrics"| otel
worker -->|"Traces & metrics"| otel
otel --> jaeger
otel --> prometheus
prometheus --> grafana
postgres --> pgadmin
kafka --> kafkaui
Order
{
"id": "uuid",
"customer_email": "user@example.com",
"amount_cents": 1299,
"status": "pending|processing|completed|failed|canceled",
"created_at": "...",
"updated_at": "..."
}| Service | Purpose |
|---|---|
| nginx | Load balancer for API instances on port 8080 |
| api | Go REST API, exposes /v1/orders endpoints (scalable) |
| worker | Kafka consumer/producer; processes order.created events (scalable) |
| postgres | Relational DB for orders |
| pgadmin | Database UI on port 5050 |
| kafka | Message broker (single-node cluster or Redpanda) |
| kafka-ui | Kafka topic browser on port 8082 |
| otel-collector | Receives traces/metrics from services |
| jaeger | Distributed tracing UI on port 16686 |
| prometheus | Metrics collection on port 9090 |
| grafana | Dashboards on port 3000 |
| Method | Path | Description |
|---|---|---|
GET |
/healthz |
Liveness check |
GET |
/readyz |
Readiness (checks DB + Kafka) |
GET |
/metrics |
Prometheus scrape endpoint |
POST |
/v1/orders |
Create order (requires Idempotency-Key; see details below) |
GET |
/v1/orders/{id} |
Retrieve order by ID |
GET |
/v1/orders |
List orders (?status=&page=&page_size=) |
POST |
/v1/orders/{id}/cancel |
Cancel pending order |
Use an Idempotency-Key header for POST /v1/orders to ensure safe retries.
Example:
curl -X POST http://localhost:8080/v1/orders -H "Content-Type: application/json" -H "Idempotency-Key: $(uuidgen)" -d '{"customer_email":"a@b.com","amount_cents":1234}'- The API stores
{ key, request_hash, response, order_id }for each key. - Repeated calls with the same key replay the original response.
- Prevents duplicate orders on network retries.
- TTL for dedup cache: 24โ72h (configurable).
Note:
Idempotency-KeyโIf-Match.
If-Match(with ETags) handles concurrency for updates.
Idempotency-Keydeduplicates retries of the same create request.
| Topic | Description |
|---|---|
order.created |
Emitted by API when a new order is created |
order.processed |
Emitted by Worker after successful processing |
Future topics (for robust error handling):
order.failedโ Emitted by Worker on processing failureorder.dlqโ Dead letter queue for poison messages after max retries
- Docker + Docker Compose
- Go โฅ 1.22
- make (optional)
- k6 (for load testing)
docker compose up --buildCheck UIs:
- API โ http://localhost:8080
- pgAdmin โ http://localhost:5050
- Kafka UI โ http://localhost:8082
- Jaeger โ http://localhost:16686
- Prometheus โ http://localhost:9090
- Grafana โ http://localhost:3000
This project uses gotestsum for enhanced test output with color-coded results and better formatting. It's the industry-standard test runner for production Go projects.
Installation:
go install gotest.tools/gotestsum@latestTests are executed via the Makefile, which provides race detection and disables test caching for reliable results:
# Run unit tests
make test
# Run integration tests (requires Docker)
make integration
# Run all tests
make test-allThe Makefile uses the following flags:
-raceโ Detects data races in concurrent code-count=1โ Disables test caching for consistent behavior--format testnameโ Displays readablepackage.TestNameoutput
Tests follow a behavior-focused naming pattern to ensure they remain resilient to implementation changes:
Pattern: Test<Behavior> with descriptive subtests describing expected outcomes.
Example:
func TestCreateOrder(t *testing.T) {
t.Run("creates pending order with valid input", func(t *testing.T) { /* ... */ })
t.Run("returns validation error when email is invalid", func(t *testing.T) { /* ... */ })
t.Run("returns error when repository fails", func(t *testing.T) { /* ... */ })
}Why this pattern:
- Resilient โ Method names are implementation details; tests verify behavior/contracts
- Readable โ Natural language describes what the system does, not how
- Maintainable โ Refactoring methods doesn't require renaming tests
Test types:
- Unit tests โ Use in-memory mocks for application layer (commands, queries, domain logic)
- Integration tests โ Use testcontainers for adapter layer (PostgreSQL, Kafka)
This project uses golang-migrate/migrate for database schema versioning and migrations.
macOS:
brew install golang-migrateLinux:
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.17.0/migrate.linux-amd64.tar.gz | tar xvz
sudo mv migrate /usr/local/bin/Go install:
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latestApply all pending migrations:
migrate -path migrations \
-database "postgres://tbd:secret@localhost:5432/tbd?sslmode=disable" \
upRollback last migration:
migrate -path migrations \
-database "postgres://..." \
down 1Check migration version:
migrate -path migrations \
-database "postgres://..." \
versionmigrate create -ext sql -dir migrations -seq create_payments_tableThis generates:
000003_create_payments_table.up.sql(apply changes)000003_create_payments_table.down.sql(rollback changes)
The API automatically runs pending migrations on startup (see cmd/api/main.go for the initialization logic).
Disable auto-migration for production by setting:
AUTO_MIGRATE=falseServices are configured via environment variables. See docker-compose.yml for the complete setup.
| Variable | Default | Description |
|---|---|---|
API_PORT |
8080 |
HTTP server port |
LOG_LEVEL |
info |
Log level: debug, info, warn, error |
DB_HOST |
localhost |
PostgreSQL host |
DB_PORT |
5432 |
PostgreSQL port |
DB_USER |
tbd |
Database user |
DB_PASSWORD |
secret |
Database password |
DB_NAME |
tbd |
Database name |
DB_MAX_CONNS |
25 |
Maximum database connections |
DB_MAX_IDLE_CONNS |
5 |
Maximum idle database connections |
DB_CONN_MAX_LIFETIME |
5m |
Maximum connection lifetime |
KAFKA_BROKERS |
localhost:9092 |
Comma-separated Kafka broker addresses |
KAFKA_TOPIC_ORDER_CREATED |
order.created |
Topic for order creation events |
KAFKA_TOPIC_ORDER_PROCESSED |
order.processed |
Topic for order processed events |
IDEMPOTENCY_TTL |
72h |
Time-to-live for idempotency keys (24hโ168h) |
AUTO_MIGRATE |
true |
Run database migrations on startup |
OTEL_EXPORTER_OTLP_ENDPOINT |
localhost:4317 |
OpenTelemetry collector endpoint |
OTEL_SERVICE_NAME |
tbd-api |
Service name for traces/metrics |
| Variable | Default | Description |
|---|---|---|
LOG_LEVEL |
info |
Log level |
DB_HOST |
localhost |
PostgreSQL host |
DB_PORT |
5432 |
PostgreSQL port |
DB_USER |
tbd |
Database user |
DB_PASSWORD |
secret |
Database password |
DB_NAME |
tbd |
Database name |
KAFKA_BROKERS |
localhost:9092 |
Kafka broker addresses |
KAFKA_CONSUMER_GROUP |
tbd-workers |
Consumer group ID for worker instances |
KAFKA_TOPIC_ORDER_CREATED |
order.created |
Topic to consume from |
KAFKA_TOPIC_ORDER_PROCESSED |
order.processed |
Topic to publish to |
WORKER_CONCURRENCY |
5 |
Number of concurrent message processors |
OTEL_EXPORTER_OTLP_ENDPOINT |
localhost:4317 |
OpenTelemetry collector endpoint |
OTEL_SERVICE_NAME |
tbd-worker |
Service name for traces/metrics |
# Database
DB_HOST=postgres
DB_PORT=5432
DB_USER=tbd
DB_PASSWORD=secret
DB_NAME=tbd
# Kafka
KAFKA_BROKERS=kafka:9092
# Observability
OTEL_EXPORTER_OTLP_ENDPOINT=otel-collector:4317
LOG_LEVEL=debug
# API-specific
API_PORT=8080
IDEMPOTENCY_TTL=72h
AUTO_MIGRATE=true
# Worker-specific
KAFKA_CONSUMER_GROUP=tbd-workers
WORKER_CONCURRENCY=5| Component | Tool | Notes |
|---|---|---|
| Tracing | OpenTelemetry + Jaeger | Follow request โ event โ processing trace |
| Metrics | Prometheus + Grafana | HTTP latency, Kafka lag, worker stats |
| Logs | Structured JSON | Includes trace_id, span_id, order_id |
Key Metrics to Monitor:
http_request_duration_secondsโ API endpoint latency (P50, P95, P99)http_requests_totalโ Request count by status codekafka_producer_latency_secondsโ Time to publish eventskafka_consumer_lagโ Consumer group lag per partitiondb_query_duration_secondsโ Database query performanceorders_created_totalโ Business metric: orders createdorders_processed_totalโ Business metric: orders processedidempotency_hits_totalโ Duplicate request prevention rate
Metrics follow a distributed, per-concern pattern rather than a centralized approach:
- Database metrics (
internal/database/metrics.go) โ Query duration, connection pool stats - Kafka metrics (
internal/kafka/metrics.go) โ Producer/consumer latency, publish success - HTTP metrics (
internal/orders/adapters/http/metrics.go) โ Request duration, status codes - Business metrics (
internal/orders/metrics/metrics.go) โ Orders created, processing duration
Why this pattern:
- Separation of Concerns โ Each infrastructure package owns its observability
- Encapsulation โ HTTP middleware only accesses HTTP metrics, not DB/Kafka metrics
- Scalability โ Adding Redis/gRPC doesn't bloat a central metrics file
- Go Idiomatic โ Follows patterns used in Kubernetes, Prometheus, and Jaeger codebases
All metrics share the same OpenTelemetry MeterProvider initialized in internal/telemetry/otel.go.
This project demonstrates production-ready error handling and resilience patterns.
| Operation | Retries | Backoff | Notes |
|---|---|---|---|
| Kafka publish | 3 | Exponential: 100ms โ 500ms โ 2s | API fails request if Kafka unavailable |
| DB queries | 3 | Linear: 50ms intervals | Automatic retry on transient errors (connection loss, deadlock) |
| Worker message processing | โ | At-least-once delivery | Kafka consumer auto-commits only on success |
| Component | Timeout | Rationale |
|---|---|---|
| HTTP request | 30s | Prevents client hanging indefinitely |
| DB query | 5s | Fails fast on slow queries |
| Kafka publish | 10s | Allows retries but prevents indefinite blocking |
| Worker processing | 60s | Per-message processing limit |
| Graceful shutdown | 30s | Finish in-flight requests before terminating |
- API behavior: Returns
503 Service Unavailable - Readiness check:
/readyzfails โ load balancer stops routing traffic - Worker behavior: Stops consuming, waits for reconnection
- Recovery: Auto-reconnects when Kafka comes back online
- API behavior:
/readyzfails immediately - Worker behavior: Stops processing, retries DB connection
- Recovery: Connection pool auto-reconnects
- Kafka behavior: Consumer group rebalances partitions
- Message replay: Another worker re-processes the message from last commit
- Idempotency: Duplicate processing is safe (idempotency keys prevent duplicate orders)
- Current: Worker retries indefinitely (can cause consumer lag)
- Planned: After 3 failed attempts, publish to
order.dlqtopic - Manual review: DLQ messages require investigation
- POST /v1/orders: Uses
Idempotency-Keyheader to prevent duplicate orders - Worker processing: Updates are idempotent (setting status to "processed" multiple times is safe)
- Event publishing: Kafka's at-least-once delivery + idempotent consumers ensure exactly-once semantics
Planned for external API calls or slow dependencies:
- Threshold: 5 consecutive failures โ open circuit
- Half-open retry: After 30s cooldown
- Monitoring: Expose
circuit_breaker_statemetric
- All errors include
trace_idandspan_idfor correlation - Failed requests are visible in Jaeger with error tags
- Worker failures show full trace: API โ Kafka โ Worker โ DB
Example script: loadtest/orders.js
import http from 'k6/http';
import { check } from 'k6';
import { uuidv4 } from 'https://jslib.k6.io/k6-utils/1.4.0/index.js';
export let options = { vus: 20, duration: '30s' };
export default function () {
const headers = {
'Content-Type': 'application/json',
'Idempotency-Key': uuidv4(),
};
const body = JSON.stringify({
customer_email: `user${__VU}@example.com`,
amount_cents: 1999,
});
const res = http.post('http://localhost:8080/v1/orders', body, { headers });
check(res, { 'status 202': (r) => r.status === 202 });
}Run:
k6 run loadtest/orders.js# Tail service logs
docker compose logs -f api
docker compose logs -f worker
# Inspect recent Kafka messages
docker exec -it kafka kafka-console-consumer --bootstrap-server localhost:9092 --topic order.created --from-beginningYou can simulate a true distributed system by running multiple service replicas:
docker compose up --scale api=3 --scale worker=3How it works:
- API scaling: Nginx load balancer distributes requests across all API instances (round-robin)
- Worker scaling: Kafka consumer groups automatically distribute partitions across all worker instances
- Database: Single PostgreSQL instance (write scaling requires read replicas or sharding, beyond this demo's scope)
All requests still go through http://localhost:8080 (nginx), which transparently load balances to the API instances.
Observing distributed behavior:
- Check nginx load balancing:
docker compose logs -f nginx - Monitor Kafka partition assignment: Open Kafka UI at http://localhost:8082
- Watch worker coordination:
docker compose logs -f worker
For advanced simulation:
- Add artificial latency with
tc netem - Kill a worker to observe partition rebalancing:
docker compose kill tbd-worker-1 - Kill an API instance to observe load balancer failover:
docker compose kill tbd-api-1 - Stop Kafka briefly and observe retry/backpressure:
docker compose stop kafka
| Feature | Description |
|---|---|
| gRPC API | Mirror the REST endpoints using Protobuf |
| ghz testing | Benchmark gRPC latency and throughput |
| Outbox pattern | Atomic DB write + event publish |
| Saga orchestration | Multi-step distributed workflows |
| Service auth | mTLS or JWT for inter-service calls |
| Kubernetes | Run the same topology with k3d or kind |
tbd/
โโโ cmd/
โ โโโ api/ # API entrypoint: wires HTTP server + use cases + adapters
โ โโโ worker/ # Worker entrypoint: wires Kafka consumer + use cases
โโโ internal/
โ โโโ orders/ # Orders bounded context
โ โ โโโ domain/
โ โ โ โโโ order.go # Order entity (aggregate root)
โ โ โ โโโ status.go # Status value object
โ โ โ โโโ events.go # Domain events (OrderCreated, OrderProcessed, etc.)
โ โ โโโ app/
โ โ โ โโโ commands/
โ โ โ โ โโโ create_order.go # CreateOrderHandler
โ โ โ โ โโโ cancel_order.go # CancelOrderHandler
โ โ โ โ โโโ mark_processed.go # MarkProcessedHandler
โ โ โ โโโ queries/
โ โ โ โโโ get_order.go # GetOrderHandler
โ โ โ โโโ list_orders.go # ListOrdersHandler
โ โ โโโ ports/
โ โ โ โโโ repository.go # OrderRepository interface
โ โ โ โโโ event_bus.go # EventBus interface (generic, tech-agnostic)
โ โ โ โโโ idempotency.go # IdempotencyStore interface (generic)
โ โ โโโ metrics/
โ โ โ โโโ metrics.go # Business metrics (orders created, processing duration)
โ โ โโโ adapters/
โ โ โโโ http/ # HTTP handlers, routing, validation, DTOs
โ โ โ โโโ handlers.go
โ โ โ โโโ routes.go
โ โ โ โโโ dto.go
โ โ โ โโโ metrics.go # HTTP request metrics
โ โ โ โโโ middleware.go # HTTP metrics middleware
โ โ โโโ grpc/ # gRPC handlers (future)
โ โ โโโ postgres/ # OrderRepository impl using pgx/sqlc
โ โ โ โโโ repository.go
โ โ โ โโโ queries.sql
โ โ โโโ kafka/ # EventBus impl (Kafka-specific producer/consumer)
โ โ โ โโโ producer.go
โ โ โ โโโ consumer.go
โ โ โโโ idempotency/ # IdempotencyStore impl (Postgres-backed)
โ โ โโโ store.go
โ โโโ database/ # Database infrastructure
โ โ โโโ postgres.go # Connection pooling setup
โ โ โโโ migrate.go # golang-migrate runner
โ โ โโโ health.go # Health check helper
โ โ โโโ metrics.go # Database query metrics
โ โโโ kafka/ # Kafka infrastructure
โ โ โโโ noop.go # No-op EventBus implementation
โ โ โโโ metrics.go # Kafka producer/consumer metrics
โ โโโ telemetry/ # Observability setup
โ โโโ otel.go # OpenTelemetry initialization (MeterProvider, TracerProvider)
โ โโโ tracing.go # Jaeger tracer setup and span helpers
โ โโโ logging.go # Structured logger (zerolog/zap)
โโโ configs/
โ โโโ docker/ # Docker-specific config files
โ โโโ grafana/ # Grafana dashboard JSONs
โโโ loadtest/
โ โโโ orders.js # k6 load test script
โโโ migrations/
โ โโโ 000001_create_orders_table.up.sql
โ โโโ 000001_create_orders_table.down.sql
โ โโโ 000002_create_idempotency_table.up.sql
โ โโโ 000002_create_idempotency_table.down.sql
โโโ docker-compose.yml
โโโ Makefile
โโโ README.md
Key Design Principles:
- Ports (interfaces) use generic, tech-agnostic names (
EventBus,IdempotencyStore) - Adapters (implementations) use specific names (
kafka/,postgres/) for clarity - Infrastructure packages (
database/,kafka/) provide shared setup and helpers - Metrics are distributed per concern (
database/metrics.go,kafka/metrics.go, etc.) for encapsulation - Single idempotency location under
orders/adapters/idempotency/(not duplicated)
- Tiny footprint โ everything runs locally.
- Real semantics โ async events, retries, DLQs, idempotency.
- Observability first โ traces, metrics, logs are first-class.
- Language focus โ idiomatic Go with context propagation.
- Safe failure โ at-least-once delivery with deduplication.
MIT License ยฉ 2025 โ tbd project contributors