Olly is a health insurance platform built as a Go microservices monorepo. Eight backend services share a PostgreSQL cluster (separate database per service), communicate via Kafka events, and authenticate with Keycloak JWTs.
| Layer | Technology | Pattern |
|---|---|---|
| HTTP Router | go-chi/chi | Middleware groups, closure-based handlers |
| Service Layer | Plain Go structs | Interface-based deps, constructor injection |
| Repository Layer | GORM v2 | Interface + GORM implementation, sentinel errors |
| Database | PostgreSQL | Per-service schema, Goose migrations, JSONB documents |
| Messaging | Kafka (segmentio/kafka-go) | Transactional outbox, event envelopes |
| Auth | Keycloak + lestrrat-go/jwx | JWT middleware with JWKS cache |
| Observability | OpenTelemetry + slog | Trace provider init at startup, structured logging |
| API Gateway | APISIX | Route-based proxying to services |
The domain model and ERD are documented at /erd/.
Further reading:
cmd/, internal/, pkg/Every service follows this layout exactly. Consistency matters — you should be able to navigate any service without re-learning the structure.
services/<name>/ ├── cmd/server/main.go # Startup: config → DB → migrations → deps → serve ├── internal/ │ ├── config/config.go # Env-var config struct with envOr() │ ├── handler/ │ │ ├── handler.go # chi router + middleware chain + Deps struct │ │ └── claims.go # Domain-specific handlers (one file per resource) │ ├── service/ │ │ ├── claim.go # Business logic, injected repo interfaces │ │ └── errors.go # Service-level sentinel errors │ ├── repository/ │ │ ├── repository.go # Interfaces + ErrNotFound │ │ └── gorm_claims.go # GORM implementations │ ├── client/ # HTTP clients to other services │ ├── kafka/ # Consumer + event handlers │ └── outbox/ # Outbox worker for reliable Kafka publishing └── migrations/ # SQL files run by Goose on startup
internal/ package. Services communicate only via HTTP APIs or Kafka events.
| Package | Import Path | What It Provides |
|---|---|---|
domain | github.com/olly/domain | All GORM model structs (Claim, Policy, Party, etc.) |
db | github.com/olly/db | GORM connection factory (Open), Goose migration runner |
middleware | github.com/olly/middleware | JWT auth, health probes (/healthz, /readyz), OTel init, structured logging |
ruleengine | github.com/olly/ruleengine | Rule evaluation (used by enrollment) |
Every service follows this exact sequence. If any step fails, the service exits immediately — no partial startups.
func main() { // 1. Load config from env vars cfg, err := config.Load() // 2. Initialize OpenTelemetry (traces + logs) — returns compound shutdown shutdown, err := ollotel.Init(ctx, ollotel.Config{ServiceName: "claims"}) defer shutdown(ctx) // 2a. Wire slog → trace correlation + otelslog bridge fan-out logging.SetDefault("claims") slog.Info("outbox worker configured", "env", cfg.Profile.Env, "poll_interval", cfg.Profile.OutboxPollInterval, "kafka_batch_timeout", cfg.Profile.KafkaBatchTimeout, "kafka_batch_size", cfg.Profile.KafkaBatchSize) // 3. Open database connection db, err := ollydb.Open(cfg.DatabaseURL) // 4. Run Goose migrations ollydb.RunMigrations(db, "migrations") // 5. Wire repositories (interfaces) claimRepo := repository.NewGormClaimRepository(db) // 6. Wire HTTP clients for other services eligClient := client.NewEligibilityClient(cfg.EligibilityURL) // 7. Wire service layer (inject repos + clients) claimSvc := service.NewClaimService(service.ClaimServiceDeps{...}) // 8. Start background workers (outbox, Kafka consumer) go outboxWorker.Run(ctx) go consumer.Run(ctx) // 9. Serve HTTP with graceful shutdown srv := &http.Server{Handler: handler.New(handler.Deps{...})} // signal.Notify → srv.Shutdown(30s timeout) }
slog.Error(...); os.Exit(1). Never start a half-wired service.
ollotel.Init now wires both an OTLP trace exporter and an OTLP log exporter (sdklog.LoggerProvider). The returned shutdown is a compound func — a single defer shutdown(ctx) flushes both. logging.SetDefault(service) must be called immediately after so every subsequent slog.*Context call fan-outs to the stdout JSON handler and the otelslog bridge.
Further reading:
main() wiring, run() functions, and graceful shutdownfunc New(deps Deps) http.Handler { r := chi.NewRouter() r.Use(chiMiddleware.RequestID) r.Use(chiMiddleware.Recoverer) // Health checks — no auth r.Get("/healthz", health.Healthz) r.Get("/readyz", health.Readyz(deps.DBPing)) // Internal routes — service-to-service, no JWT r.Group(func(r chi.Router) { r.Get("/internal/claims/{locator}", internalGetClaim(...)) }) // Protected routes — JWT required r.Group(func(r chi.Router) { r.Use(auth.JWTMiddleware(auth.Config{JWKSURI: deps.JWKSUri})) r.Post("/claims", submitClaim(deps.Claims)) r.Get("/claims/{locator}", getClaim(deps.Claims)) }) return otelhttp.NewHandler(r, "claims-service") }
Handlers are closure functions that capture their service dependency and return http.HandlerFunc:
func submitClaim(svc ClaimServiceIface) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // 1. Decode request var body SubmitClaimRequest if err := json.NewDecoder(r.Body).Decode(&body); err != nil { respond(w, 400, map[string]string{"error": "invalid request body"}) return } // 2. Call service claim, err := svc.SubmitClaim(r.Context(), body) if err != nil { writeError(w, err) // Maps service errors → HTTP status return } // 3. Respond respond(w, 202, claim) } }
func writeError(w http.ResponseWriter, err error) { var valErr service.ErrValidation switch { case errors.Is(err, service.ErrNotFound): respond(w, 404, map[string]string{"error": err.Error()}) case errors.As(err, &valErr): respond(w, 400, map[string]string{"error": valErr.Error()}) case errors.Is(err, service.ErrInvalidTransition): respond(w, 409, map[string]string{"error": err.Error()}) default: respond(w, 500, map[string]string{"error": "internal server error"}) } }
{"error": "message"}. Never leak stack traces or internal details in the 500 case.
Further reading:
func submitClaim(svc) http.HandlerFuncThe service layer contains business logic. It receives repository interfaces (not concrete GORM types) and HTTP clients for cross-service calls.
type ClaimService struct { db *gorm.DB claims repository.ClaimRepository // interface lines repository.ClaimLineRepository // interface outbox repository.OutboxRepository // interface eligibility *client.EligibilityClient // HTTP client enrollment *client.EnrollmentClient log *slog.Logger } type ClaimServiceDeps struct { DB *gorm.DB Claims repository.ClaimRepository Lines repository.ClaimLineRepository Outbox repository.OutboxRepository Eligibility *client.EligibilityClient Enrollment *client.EnrollmentClient } func NewClaimService(deps ClaimServiceDeps) *ClaimService { return &ClaimService{ db: deps.DB, claims: deps.Claims, // ... all deps injected log: slog.Default(), } }
gorm.io/gorm directly for queries. All data access goes through repository interfaces. The *gorm.DB field is only used for wrapping multi-repo operations in a transaction.
var ErrNotFound = errors.New("not found") type ClaimRepository interface { Create(ctx context.Context, claim *domain.Claim) error GetByLocator(ctx context.Context, locator string) (*domain.Claim, error) GetByID(ctx context.Context, id uuid.UUID) (*domain.Claim, error) UpdateStatus(ctx context.Context, id uuid.UUID, from, to domain.ClaimStatus, event domain.ClaimEvent) error ListByPolicy(ctx context.Context, policyID uuid.UUID) ([]domain.Claim, error) }
func (r *GormClaimRepository) GetByLocator(ctx context.Context, locator string) (*domain.Claim, error) { var c domain.Claim if err := r.db.WithContext(ctx).First(&c, "locator = ?", locator).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrNotFound // Wrap GORM error as domain error } return nil, err } return &c, nil }
gorm.ErrRecordNotFound in handlers. Always check repository.ErrNotFound or service.ErrNotFound instead.
r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var c domain.Claim if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). First(&c, "id = ?", id).Error; err != nil { return err } // ... mutate and save within the same tx })
Further reading:
| Layer | Creates | Checks |
|---|---|---|
| Repository | var ErrNotFound = errors.New("not found") | errors.Is(err, gorm.ErrRecordNotFound) |
| Service | var ErrNotFound, ErrValidation, ErrInvalidTransition | errors.Is(err, repository.ErrNotFound) |
| Handler | HTTP status codes | errors.Is(err, service.ErrNotFound), errors.As(err, &valErr) |
// Good — adds context about what failed return nil, fmt.Errorf("get policy: %w", err) return nil, fmt.Errorf("apply accumulators: %w", err) // Bad — no context return nil, err
// Always exit on infrastructure failures at startup if err != nil { slog.Error("failed to open database", "error", err) os.Exit(1) }
Further reading:
errors.Is(), errors.As(), %w wrappingAll GORM models live in a shared domain package — not in individual services. This is the single source of truth for data structures.
type Claim struct { ID uuid.UUID `gorm:"type:uuid;primaryKey"` Locator string `gorm:"uniqueIndex;not null"` PolicyID uuid.UUID `gorm:"type:uuid;not null;index"` Status ClaimStatus `gorm:"not null"` IncidentDate time.Time `gorm:"type:date;not null"` Document []byte `gorm:"type:jsonb"` CreatedAt time.Time UpdatedAt time.Time } func (Claim) TableName() string { return "claims.claims" } func (c *Claim) BeforeCreate(_ *gorm.DB) error { if c.ID == uuid.Nil { c.ID = uuid.New() } return nil }
BeforeCreate hookCLM-2026-000001)claims.claims, enrollment.policiesDocument, Data, CoverageTerms stored as []byte*string, *time.Time, *uuid.UUIDdecimal.Decimal for money — mapped to numeric(14,4), never float64type Config struct { Port string DatabaseURL string KeycloakJWKSURI string KafkaBrokers string EligibilityURL string // ... } func Load() (*Config, error) { envfile.Load(envOr("SECRETS_FILE", "")) // Validate required vars dbURL := os.Getenv("DATABASE_URL") if dbURL == "" { return nil, fmt.Errorf("config: DATABASE_URL is required") } return &Config{ Port: envOr("PORT", "8080"), DatabaseURL: dbURL, // ... }, nil } func envOr(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback }
envOr() helper provides defaults for optional vars. Required vars return an error if missing.
Timing-sensitive knobs (Kafka batching, outbox polling) are bundled into a Profile selected at boot from ENVIRONMENT. Services never tune these individually — they read cfg.Profile and pass the fields through to the Kafka writer and outbox loop. A single env-var override (OUTBOX_POLL_INTERVAL, KAFKA_BATCH_TIMEOUT, KAFKA_BATCH_SIZE) can tune one knob without flipping profiles.
type Profile struct { Env string KafkaBatchTimeout time.Duration KafkaBatchSize int OutboxPollInterval time.Duration } func ForEnvironment(env string) Profile { switch env { case "prod": return Profile{Env: "prod", KafkaBatchTimeout: 1 * time.Second, KafkaBatchSize: 100, OutboxPollInterval: 500 * time.Millisecond} default: // dev / unknown → dev defaults return Profile{Env: "dev", KafkaBatchTimeout: 10 * time.Millisecond, KafkaBatchSize: 1, OutboxPollInterval: 50 * time.Millisecond} } }
| Knob | Dev default | Prod default | Override |
|---|---|---|---|
KafkaBatchTimeout | 10ms | 1s | KAFKA_BATCH_TIMEOUT |
KafkaBatchSize | 1 | 100 | KAFKA_BATCH_SIZE |
OutboxPollInterval | 50ms | 500ms | OUTBOX_POLL_INTERVAL |
cfg.Profile and passes the fields into kafka.Writer (BatchTimeout, BatchSize) and the outbox Run loop ticker. Do not introduce per-service timing fields — delete them and wire the profile instead. Invalid override values are silently ignored (typo-resistant).
published_at) went from ~3.3s → 17–54ms across 5 runs (~60–200× faster) after flipping all services to ENVIRONMENT=dev.
Services publish events reliably using the transactional outbox pattern: write to the business table and the outbox table in the same DB transaction. A background worker polls the outbox and publishes to Kafka. Every request produces one distributed trace covering HTTP → db.Transaction → outbox.enqueue → async outbox.publish_entry → kafka.write → consumer, with logs correlated in Loki via trace_id.
CREATE TABLE <schema>.outbox_entries ( id UUID PRIMARY KEY, topic TEXT NOT NULL, key TEXT NOT NULL, payload JSONB NOT NULL, trace_context JSONB, -- W3C propagation headers created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), published_at TIMESTAMPTZ ); CREATE INDEX idx_outbox_unpublished ON <schema>.outbox_entries(created_at) WHERE published_at IS NULL;
claims.outbox_entries) and has the uniform shape (topic, key, payload, trace_context). Every service with an outbox must carry the trace_context JSONB column (added in 00003_outbox_trace_context.sql for care, 0006_outbox_trace_context.sql for consent, equivalent for billing/enrollment/claims).
// Same transaction: create claim + enqueue outbox entry. // runInTx wraps both writes so a crash between them is impossible. s.db.Transaction(func(tx *gorm.DB) error { if err := s.claims.Create(ctx, tx, &claim); err != nil { return err } return s.outbox.Enqueue(ctx, tx, repository.OutboxMessage{ Topic: "claims.events", Key: claim.Locator, Payload: eventJSON, }) })
GormOutboxRepository.Enqueue (explicit span + trace injection)func (r *GormOutboxRepository) Enqueue(ctx context.Context, tx *gorm.DB, msg OutboxMessage) error { ctx, span := tracer.Start(ctx, "outbox.enqueue", trace.WithAttributes( attribute.String("outbox.topic", msg.Topic), attribute.String("outbox.key", msg.Key), attribute.String("outbox.service", "claims"), )) defer span.End() // Capture W3C propagation into trace_context JSONB carrier := propagation.MapCarrier{} otel.GetTextMapPropagator().Inject(ctx, carrier) traceCtxJSON, _ := json.Marshal(carrier) return tx.Create(&domain.OutboxEntry{ ID: uuid.New(), Topic: msg.Topic, Key: msg.Key, Payload: msg.Payload, TraceContext: traceCtxJSON, }).Error }
GormOutboxRepository.Enqueue must (a) start an explicit outbox.enqueue span with outbox.topic, outbox.key, outbox.service attributes, and (b) inject W3C propagation headers into the trace_context column. This makes TraceQL queries like { name = "outbox.enqueue" && resource.service.name = "claims" && outbox.key = "CLM-2026-000042" } a direct lookup instead of a parent/child walk.
// Poll interval comes from cfg.Profile.OutboxPollInterval (50ms dev / 500ms prod) ticker := time.NewTicker(profile.OutboxPollInterval) for range ticker.C { ctx, tickSpan := tracer.Start(ctx, "outbox.tick") entries := fetch(ctx) // outbox.fetch child span for i, e := range entries { // Restore the ORIGINATING request's context from trace_context JSONB var carrier propagation.MapCarrier json.Unmarshal(e.TraceContext, &carrier) parentCtx := otel.GetTextMapPropagator().Extract(ctx, carrier) pubCtx, pubSpan := tracer.Start(parentCtx, "outbox.publish_entry", trace.WithAttributes( attribute.Int64("outbox.age_ms", time.Since(e.CreatedAt).Milliseconds()), attribute.Int("outbox.queue_position", i), attribute.Int("outbox.queue_total", len(entries)), attribute.String("event.type", e.EventType()), )) // Inject restored context into Kafka headers → consumer continues same trace headers := kafkaHeadersFromCtx(pubCtx) writer.WriteMessages(pubCtx, kafka.Message{ Key: []byte(e.Key), Value: e.Payload, Headers: headers, }) // wrapped in kafka.write child span markPublished(pubCtx, e.ID) // outbox.mark_published child span pubSpan.End() } tickSpan.End() }
The resulting span tree for every outbox row:
outbox.tick
├── outbox.fetch
└── outbox.publish_entry ← under originating request's trace_id
├── kafka.write
└── outbox.mark_published
trace_context and start outbox.publish_entry under the originating request's trace — not under outbox.tick. Then inject the restored context into kafka.Message.Headers so downstream consumers continue the same trace. "Why was this slow?" is then answered by reading the trace, not by reasoning about layered batching.
type EventEnvelope struct { ID string // UUID EventType string // "policy.issued", "claim.submitted" OccurredAt time.Time Payload json.RawMessage }
| Event | Published When | Consumed By |
|---|---|---|
policy.issued | Policy issuance committed | Eligibility, Billing, Notifications |
policy.cancelled | Cancellation applied | Eligibility, Billing |
element.added | Coverage element added to policy | Eligibility |
element.removed | Coverage element removed | Eligibility |
claims, billing, enrollment, care, consent, document-service. All use GormOutboxRepository with trace_context; all run the granular-span worker under cfg.Profile.
Further reading:
trace_contextAll service routes except /healthz, /readyz, and /internal/* require a JWT Bearer token validated against Keycloak's JWKS endpoint.
// Middleware caches JWKS keys and validates tokens r.Group(func(r chi.Router) { r.Use(auth.JWTMiddleware(auth.Config{JWKSURI: cfg.KeycloakJWKSURI})) // ... protected routes }) // Extract claims in handlers claims := auth.ClaimsFromContext(r.Context()) orgLocator := auth.OrgLocatorFromContext(r.Context()) // employer scoping
| Route Prefix | Auth | Purpose |
|---|---|---|
/healthz, /readyz | None | Kubernetes probes |
/internal/* | None (gateway-protected) | Service-to-service calls |
| Everything else | JWT Bearer | External API |
party_locator — Member's party identifier (member app)org_locator — Employer's scheme identifier (employer app)sub — Keycloak user ID-- +goose Up CREATE SCHEMA IF NOT EXISTS claims; -- +goose Up CREATE TABLE claims.claims ( id UUID PRIMARY KEY, locator TEXT NOT NULL, policy_id UUID NOT NULL, status TEXT NOT NULL, document JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT claims_locator_key UNIQUE (locator) ); CREATE INDEX idx_claims_policy ON claims.claims(policy_id); -- +goose Down DROP TABLE IF EXISTS claims.claims;
0001_create_schema.sql, 0002_create_claims.sql-- +goose Down for rollbackclaims.claims, billing.invoicesTIMESTAMPTZ (not TIMESTAMP) for all time columnsollydb.RunMigrations()Further reading:
// Standard Go testing with interface-based mocks func TestGetClaim_NotFound(t *testing.T) { mockRepo := &mockClaimRepo{ getByLocator: func(ctx context.Context, loc string) (*domain.Claim, error) { return nil, repository.ErrNotFound }, } svc := service.NewClaimService(service.ClaimServiceDeps{Claims: mockRepo}) // ... test handler returns 404 }
// E2E tests hit running services over HTTP with real auth tokens func TestClaimLifecycle(t *testing.T) { token, err := setup.FetchToken(cfg, setup.Member) if err != nil { t.Skip("Keycloak unreachable", err) } // Submit claim, poll for terminal status... }
# Run all E2E tests (requires services running) cd tests/e2e && GOWORK=off go test ./... -v -timeout 90s -count=1 # Run by service cd tests/e2e && GOWORK=off go test ./claims/... -v -timeout 90s -count=1
GOWORK=off is required — prevents workspace interferencet.Skip() when services are unreachablesync.OnceFurther reading:
Every request produces one distributed trace and a set of structured logs correlated to that trace by trace_id. The pipeline: service → OTel Collector → Tempo (traces) + Loki (logs) → Grafana, with click-through navigation in both directions.
We use Go's standard log/slog package. A thin slog.Handler wrapper (logging.ContextHandler) reads trace.SpanContextFromContext(ctx) on every record and attaches trace_id / span_id as top-level attributes. A second handler bridges every record to the global otelslog LoggerProvider so it ships via OTLP alongside the stdout JSON.
// main() — installs both the trace-context handler AND the otelslog bridge logging.SetDefault("claims") // Hot paths must use *Context variants so trace_id gets attached slog.InfoContext(ctx, "claim submitted", "locator", claim.Locator, "policyId", claim.PolicyID) slog.ErrorContext(ctx, "failed to publish event", "error", err)
main() calls logging.SetDefault("<service>") immediately after ollotel.Init. Hot-path log calls (handlers, services, consumers, dispatchers) must use slog.InfoContext / ErrorContext / WarnContext so they inherit the request's span context. Startup / migration / config logs can stay on the non-Context variants — there's no ctx to attach anyway.
slog (not fmt.Println or log.Printf). Always include structured key-value pairs. slog.Error for errors, slog.Warn for degraded-but-working, slog.Info for business events.
ollotel.Init wires three things and returns a compound shutdown:
sdktrace.TracerProvider → global tracersdklog.LoggerProvider → global logger provider (used by the otelslog bridge above)// chi router wrapped — every HTTP request gets an automatic span return otelhttp.NewHandler(r, "claims-service")
Two paths, one destination (Loki):
olly-* container via the Docker socket, labels by service name) → Loki on :3101. This is the legacy path and still captures APISIX/infra logs.trace_id / span_id as Loki labels via loki.resource.labels / loki.attribute.labels hints. service.name is promoted to the service_name Loki label (underscore — the loki exporter's promotion convention).uid: loki and derivedFields regex "trace_id":"(\w+)" / "span_id":"(\w+)" matching the JSON log format our slog handler produces. Each renders as a clickable link into Tempo.tracesToLogsV2 with a customQuery filtering by traceID and matching Loki label service_name (not service — without this fix, clicking a span fails to find the logs).Result: click a log line in Loki → jump to the trace in Tempo; click a span in Tempo → jump to the filtered Loki query.
All timing-sensitive knobs — Kafka batch timeout, batch size, outbox poll interval — are bundled in a Profile selected at boot from the ENVIRONMENT env var. See §9 Configuration. Boot logs emit one slog.Info("outbox worker configured", …) line so operators can confirm the active profile from Loki.
| Endpoint | Purpose | Auth |
|---|---|---|
GET /healthz | Liveness — always returns 200 | None |
GET /readyz | Readiness — checks DB ping | None |
POST /claims).db.Transaction → outbox.enqueue, then (after OutboxPollInterval + Kafka batch timeout) a later child subtree outbox.tick → outbox.publish_entry → kafka.write → outbox.mark_published — all under the same trace_id.{service_name="claims"} | json | trace_id="...". All logs from the request + the async publish appear together.# Build & test make build # Build all Go services make test # Unit tests across all services make lint # golangci-lint make fmt # gofmt all Go code # Local stack make local-up # Start docker-compose (postgres, keycloak, kafka, etc.) make local-down # Stop (preserves volumes) make local-down-clean # Stop + wipe volumes make keycloak-set-passwords # Reset test user passwords to Olly2026 # Run services make run-all # Start all Go services in background make stop-all # Stop background services # E2E tests (requires services running) make test-e2e # All E2E tests make test-e2e-claims # Claims only # Single service dev cd services/claims && go build ./... cd services/claims && go test ./... cd services/claims && go test ./internal/handler/... -run TestSubmitClaim -v
cmd/, internal/, pkg/