eks-gitops-demo ← PlatformApp & observabilityClean architectureService kitMicroservices

Code architecture: clean layers, a shared service kit, two services

How the Go code in the new eks-gitops-demo repo is organised. Companion to the platform page and the app and observability page. Prepared 2026-10-16. Phase 1 is built (the API, service kit, playground, CI and bootstrap infrastructure); later phases are design.

1. Clean architecture in Go

Three layers plus a composition root. Business rules sit in the middle and know nothing about GraphQL, HTTP or Postgres; everything technical sits at the edge and plugs in through small interfaces (ports).

Nested layers: cmd/api wires adapters, which surround the app layer of use cases and ports, which surrounds the domain layer. Dependencies point inward: adapter depends on app, app depends on domain, domain depends on nothing. cmd/api/main.gocomposition root: wiring only adapter/implements the ports; the only code that imports gqlgen, pgx, net/http clients inbound graphql (gqlgen, thin) dataloaders error presenter health /healthz /readyz outbound postgres (sqlc, pgx) memory (tests, local) oidc token verifier payments REST client events: LISTEN/NOTIFY app/use cases + ports SubmitSupportRequest, DecideSupportRequest, SubmitClaim domain/ entities · Money · Date rules: budget check, separation of duties imports nothing ports: Repos · Relationships · Auditor · Clock · IDs Dependency rule: arrows point inward adapter/knows frameworks, drivers, protocols app/orchestrates; declares the ports it needs domain/pure Go: no framework, no I/O depends on depends on cmd/api picks concrete adapters at startup and injects them into the use cases.

Grey = composition root · orange = adapters · green = application layer · blue = domain.

eks-gitops-demo/
├── cmd/
│   ├── api/main.go              composition root: config → adapters → use cases → server
│   └── payments/main.go         second service, same kit (see section 3)
├── internal/
│   ├── domain/                  entities, value objects, rules, domain errors · imports nothing
│   │   ├── money.go  date.go
│   │   ├── participant.go  plan.go  budget.go  booking.go  claim.go
│   │   └── supportrequest.go    separation of duties: the decider is never the requester
│   ├── app/                     use cases + the ports they need
│   │   ├── ports.go             repositories, Relationships, Auditor, Clock, IDGenerator (later: EventPublisher, PaymentsClient)
│   │   ├── supportrequest.go    SubmitSupportRequest, DecideSupportRequest
│   │   └── claim.go             SubmitClaim (idempotent)
│   ├── adapter/
│   │   ├── graphql/             schema, thin gqlgen resolvers, dataloaders, error presenter
│   │   ├── postgres/            sqlc queries, repositories, transactions, LISTEN/NOTIFY events
│   │   ├── memory/              in-memory repositories for unit tests and local runs
│   │   ├── oidc/                JWT verifier → Principal
│   │   └── payments/            REST client: client credentials, retry, circuit breaker
│   ├── paymentsvc/              the second service's own domain + app + adapters
│   └── platform/                the shared service kit (section 2)
├── migrations/                  SQL migrations, run by the PreSync Job
├── deploy/helm/{demo-api,payments-svc}/
└── test/e2e/                    verify script against a running stack

Go idioms the layout relies on

Testing per layer

LayerTest typeToolsRuns
domain/Pure unit tests, table-driven: money arithmetic, budget checks, the self-approval rulego testEvery PR, milliseconds
app/Use cases against in-memory fakes from adapter/memory: authorization, transactions, events publishedgo test -raceEvery PR
adapter/Integration: real Postgres in a container (sqlc queries, migrations, LISTEN/NOTIFY); resolvers through httptesttestcontainers-goEvery PR (Docker on the runner)
whole systemEnd-to-end checks with real tokens against kind locally and dev on EKS (PostSync smoke Job)test/e2e/verify.shLocally and after every sync

Coverage gate: ≥ 80% on domain/ + app/ combined (the code that carries the business rules). Adapters are covered by integration tests rather than a percentage.

Go: a domain rule, a port and a use case (sketch)
// internal/domain/supportrequest.go
var (
	ErrSelfApproval = errors.New("cannot decide your own support request")
	ErrNotPending   = errors.New("support request is not pending")
)

type SupportRequest struct {
	ID          string
	PlanID      string
	RaisedBy    string // subject of the participant or nominee who raised it
	Category    Category
	Estimate    Money
	Status      RequestStatus
	Reason      string
}

// Decide applies the separation-of-duties rule before any state change.
func (r *SupportRequest) Decide(decider string, approve bool, reason string) error {
	if decider == r.RaisedBy {
		return ErrSelfApproval
	}
	if r.Status != StatusSubmitted {
		return ErrNotPending
	}
	r.Status, r.Reason = StatusRejected, reason
	if approve {
		r.Status = StatusApproved
	}
	return nil
}

// internal/app/ports.go — small interfaces, declared where they are used
type SupportRequests interface {
	Get(ctx context.Context, id string) (domain.SupportRequest, error)
	Save(ctx context.Context, r domain.SupportRequest) error
}
type Plans interface {
	Get(ctx context.Context, id string) (domain.Plan, error)
}
type Bookings interface {
	Save(ctx context.Context, b domain.ServiceBooking) error
}
type EventPublisher interface {
	Publish(ctx context.Context, e domain.Event) error
}
type TxManager interface {
	WithinTx(ctx context.Context, fn func(ctx context.Context) error) error
}

// internal/app/supportrequest.go
type DecideSupportRequest struct {
	Policy   *Policy
	Tx       TxManager
	Requests SupportRequests
	Plans    Plans
	Bookings Bookings
	Events   EventPublisher
}

func (uc DecideSupportRequest) Execute(ctx context.Context, p Principal, in DecideInput) (domain.SupportRequest, error) {
	if err := uc.Policy.Can(ctx, p, ActionDecideSupportRequest, Resource{ID: in.ID}); err != nil {
		return domain.SupportRequest{}, err
	}
	var out domain.SupportRequest
	err := uc.Tx.WithinTx(ctx, func(ctx context.Context) error {
		r, err := uc.Requests.Get(ctx, in.ID)
		if err != nil {
			return fmt.Errorf("load support request %s: %w", in.ID, err)
		}
		if err := r.Decide(p.Subject, in.Approve, in.Reason); err != nil {
			return err // domain error, mapped to SelfApprovalNotAllowedError at the edge
		}
		if in.Approve {
			plan, err := uc.Plans.Get(ctx, r.PlanID)
			if err != nil {
				return fmt.Errorf("load plan %s: %w", r.PlanID, err)
			}
			booking, err := plan.Reserve(r.Category, r.Estimate, in.ProviderID) // budget rule lives in the domain
			if err != nil {
				return err
			}
			if err := uc.Bookings.Save(ctx, booking); err != nil {
				return fmt.Errorf("save booking: %w", err)
			}
		}
		if err := uc.Requests.Save(ctx, r); err != nil {
			return fmt.Errorf("save support request: %w", err)
		}
		out = r
		return uc.Events.Publish(ctx, domain.SupportRequestDecided{ID: r.ID, Status: r.Status})
	})
	return out, err
}

The resolver only translates GraphQL input into DecideInput, calls Execute, and lets the error presenter turn domain.ErrSelfApproval into the SelfApprovalNotAllowedError union member.

2. A shared service kit

internal/platform is the reusable base every service is built on, so a second service gets logging, tracing, health, security and graceful shutdown on day one instead of re-inventing them.

PieceWhat it gives every service
ConfigEnvironment variables parsed into a typed struct and validated at startup; a missing or malformed value fails fast with a clear message.
Logginglog/slog JSON to stdout with request_id, trace_id and span_id on every line.
HTTP serverRead-header, read and write timeouts; graceful shutdown on SIGTERM: readiness flips to 503, new connections stop, in-flight requests drain (up to 20 s). A Kubernetes preStop sleep lets the ALB deregister the pod first.
Health/healthz liveness (the process is alive, no dependencies) and /readyz readiness (database ping, JWKS loaded).
TelemetryOpenTelemetry setup (traces, metrics, runtime metrics) and a flush on shutdown.
ErrorsOne JSON error envelope for REST: {"error":{"code","message","request_id"}}. GraphQL keeps the same codes in extensions.code.
Inbound authJWT verifier middleware (signature via JWKS, issuer, audience, expiry, token type) that puts a Principal in the context.
Outbound HTTPClient with the OpenTelemetry transport, per-call timeout, retries with jittered backoff for idempotent calls only, a circuit breaker, and a cached client-credentials token source.

Libraries: chi, standard log/slog, coreos/go-oidc, golang.org/x/time/rate (token bucket), golang.org/x/oauth2/clientcredentials, sony/gobreaker, OpenTelemetry otelhttp.

Middleware chain

Middleware order from outermost to innermost: recover, request id, OpenTelemetry, access log, security headers and CORS, limits, rate limit, authentication, then the GraphQL layer. Request →outermost first; the response unwinds in reverse order ① Recoverpanic → 500logs the stack ② Request IDX-Request-IDin ctx + logs ③ OpenTelemetryotelhttp span+ RED metrics ④ Access logslog JSONstatus · latency ⑤ Headers, CORSsecurity headersorigin allowlist ⑥ Limitsbody 256 KiBdeadline 10 s ⑦ Rate limittoken bucketper client IP ⑧ AuthenticateBearer JWT →Principal in ctx ⑨ GraphQL@auth directivedepth, cost limits green = platform kit, identical in every service · orange = per service (GraphQL only in demo-api) public paths (/healthz, /readyz) skip ⑧; payments-svc runs ①–⑧ with its own expected audience

Why this order

Go: assembling the chain with chi (sketch)
func NewRouter(cfg Config, log *slog.Logger, verifier kit.Verifier, ready kit.ReadyFunc, gql http.Handler) http.Handler {
	r := chi.NewRouter()
	r.Use(
		kit.Recover(log),                         // ① outermost: catches panics from everything below
		kit.RequestID,                            // ② id exists before anything logs
		kit.OTel("demo-api"),                     // ③ span + RED metrics cover the rest of the chain
		kit.AccessLog(log),                       // ④ status, latency, request_id, trace_id
		kit.SecurityHeaders,                      // ⑤
		kit.CORS(cfg.AllowedOrigins),             // ⑤ allowlist, never "*" with credentials
		kit.Limits(1<<20, 10*time.Second),        // ⑥ body size, request deadline
		kit.RateLimit(cfg.RatePerSec, cfg.Burst), // ⑦ before auth: floods never reach JWKS checks
	)
	r.Get("/healthz", kit.Live)
	r.Get("/readyz", kit.Ready(ready))
	r.With(kit.Authenticate(verifier)).Handle("/graphql", gql) // ⑧ then gqlgen: ⑨ @auth, limits, presenter
	return r
}

3. Microservice demo: two services, one kit

The browser reaches demo-api through the ALB. demo-api gets a client-credentials token from Keycloak and calls payments-svc, which is reachable only inside the cluster and validates the token against Keycloak's keys. Both services send traces to X-Ray as one trace. BrowserSPA · user token ALB · HTTPSapp.frankbluemind.com demo-apiGraphQL · public via Ingressplatform kit + payments adaptertimeout · retry · circuit breaker payments-svcREST · ClusterIP onlyplatform kit + JWT check (aud)NetworkPolicy: demo-api only Keycloakclient demo-api-svc · JWKS AWS X-Rayone trace, 2 services ② call + Bearer ① token (cached) ③ JWKS
← App and observabilityPlatform →