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).
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
- Interfaces belong to the consumer.
app/ports.godeclares small interfaces (one to three methods); adapters satisfy them implicitly. Accept interfaces, return structs. - No framework types in the domain. No gqlgen models, no
pgxrows, nohttp.Request. Domain types are plain structs with methods that enforce rules. context.Contextfirst on every port and use-case method, so deadlines, cancellation, the principal and trace spans flow through.- Errors are wrapped with
%won the way up and mapped to GraphQLextensions.code(or a REST status) only in the adapter's error presenter. Domain errors are sentinel values or typed errors that use cases return unchanged. - Atomic steps are ports.
ClaimRepository.InsertClaimWithinBudgetmakes the budget check and the insert one step (in memory: one lock; in Postgres: a transaction that locks the plan row withSELECT … FOR UPDATE), so concurrent claims can never overspend. A generalTxManager.WithinTxarrives when a use case writes several aggregates (Phase 3). - Authorization runs inside every use case through one concrete
Policy(wrapped by a guard that audits each decision), with the principal taken from the context, so every GraphQL path pays the same check. It is deny-by-default: staff may read everything and approve, but never submit claims. A concrete type is enough here; anAuthorizerinterface would only add indirection (YAGNI).
Testing per layer
| Layer | Test type | Tools | Runs |
|---|---|---|---|
domain/ | Pure unit tests, table-driven: money arithmetic, budget checks, the self-approval rule | go test | Every PR, milliseconds |
app/ | Use cases against in-memory fakes from adapter/memory: authorization, transactions, events published | go test -race | Every PR |
adapter/ | Integration: real Postgres in a container (sqlc queries, migrations, LISTEN/NOTIFY); resolvers through httptest | testcontainers-go | Every PR (Docker on the runner) |
| whole system | End-to-end checks with real tokens against kind locally and dev on EKS (PostSync smoke Job) | test/e2e/verify.sh | Locally 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.
| Piece | What it gives every service |
|---|---|
| Config | Environment variables parsed into a typed struct and validated at startup; a missing or malformed value fails fast with a clear message. |
| Logging | log/slog JSON to stdout with request_id, trace_id and span_id on every line. |
| HTTP server | Read-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). |
| Telemetry | OpenTelemetry setup (traces, metrics, runtime metrics) and a flush on shutdown. |
| Errors | One JSON error envelope for REST: {"error":{"code","message","request_id"}}. GraphQL keeps the same codes in extensions.code. |
| Inbound auth | JWT verifier middleware (signature via JWKS, issuer, audience, expiry, token type) that puts a Principal in the context. |
| Outbound HTTP | Client 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
Why this order
- ① Recover is outermost so a panic anywhere below still becomes a clean 500 with a log line, never a dropped connection.
- ② Request ID before anything logs, so every log line and error envelope can carry it.
- ③ OpenTelemetry before the access log, so the log line can include the
trace_idand the span measures everything after it. - ⑤ Headers and CORS early, so even rejected requests (429, 401) carry security headers and preflights never reach auth.
- ⑥ Limits before work: oversized bodies and slow requests are cut off before parsing.
- ⑦ Rate limit before ⑧ auth, so a flood of junk tokens never costs signature checks.
- ⑧ Authentication, then ⑨ authorization: the transport proves who; the GraphQL directive and the use cases decide what.
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
- demo-api is the public GraphQL experience API. payments-svc is an internal REST mock of a payment processor: a Service of type ClusterIP, no Ingress, so it is unreachable from outside the cluster.
- Service-to-service auth: OAuth 2.0 client credentials. demo-api's confidential Keycloak client
demo-api-svcgets a token with audiencepayments-svc(cached until shortly before expiry). payments-svc validates it with the same kit middleware, only expecting a different audience. The client secret is generated in Secrets Manager and delivered by ESO. - Resilience in demo-api's payments adapter: 2 s timeout per call; up to 2 retries with jittered backoff, only for idempotent requests (an
Idempotency-Keyheader makes a payment retry-safe); a circuit breaker opens after 5 consecutive failures and half-opens after 30 s. While open, the resolver returns a typed "payments unavailable" outcome instead of hanging. - One trace across both services: the kit's outbound transport injects W3C
traceparent; payments-svc's ③ OpenTelemetry middleware continues it, so X-Ray showsdemo-api → payments-svcas a single trace. - NetworkPolicy: payments-svc accepts traffic only from pods labelled
app=demo-api(enforced by the VPC CNI). - Why it matters: the second service is mostly
main.gowiring plus its own small domain; logging, tracing, health, auth, limits and shutdown come from the kit unchanged.