Reference implementation of a Go microservice platform on Dapr, comparing three client approaches (custom HTTP, custom gRPC, Dapr Go SDK) across four Dapr building blocks: Pub/Sub with content-based routing, State Store, Secret Store, and Service Invocation. Production-ready K8s deployment with KinD + cloud-provider-kind for local parity, Testcontainers-backed integration tests, and a CI pipeline that provisions a full Dapr cluster on every PR.
| Layer | Technology | Rationale |
|---|---|---|
| Language | Go 1.26.2 | Static binaries, minimal runtime, strong concurrency primitives |
| Dapr runtime | v1.17.4 | Decouples building-block APIs from broker/store implementations |
| HTTP framework | Fiber v3 | Fasthttp-backed; lower allocation/request than net/http under load |
| Database driver | pgx v5 | Native Postgres protocol + prepared-statement pooling (no database/sql overhead) |
| Event backbone | Redis Streams (pub/sub) + Redis (state) | Dapr's default low-ceremony broker; swap-in any supported backend via component YAML |
| RPC | gRPC + Protobuf | Strongly-typed inter-service contracts, HTTP/2 multiplexing, native tracing headers |
| Container | Alpine + BuildKit cache mounts | ~12–21 MB images; go mod download cached across builds |
| Orchestration | Kubernetes + KinD + cloud-provider-kind | LoadBalancer Service support in KinD via the kind-team-maintained host-side controller — closes the parity gap with cloud K8s |
| Observability | Zipkin via Dapr sidecar | Zero-code distributed tracing; Dapr propagates W3C Trace Context across hops |
| Secrets | secretstores.kubernetes (K8s-native) |
RBAC-scoped; no external Vault dependency |
| Resiliency | Dapr Resiliency CRD |
Declarative retries, timeouts, circuit breakers per target app/component |
| Test layers | Unit (Go testing), Integration (Testcontainers), E2E (KinD + curl) | Each layer catches a different class of defect |
| Code quality | golangci-lint, gosec, hadolint, mermaid-cli | Enforced via composite make static-check gate |
| Dependency updates | Renovate | Single customManagers regex tracks every _VERSION constant |
| Block | Component | Store | Access pattern |
|---|---|---|---|
| Pub/Sub (content-routed) | pubsub.redis |
Redis Streams | CloudEvent type → declarative Subscription route |
| State Store | state.redis |
Redis | Dapr Go SDK key/value API |
| Secret Store | secretstores.kubernetes |
K8s Secrets | Credentials fetched at startup, no env vars |
| Service Invocation | Dapr sidecar-to-sidecar | gRPC | Fully-qualified app ID for cross-namespace routing, mTLS, Zipkin traces |
Two delivery modes. Kubernetes (Option A) is closest to production; local dev (Option B) iterates faster on single-binary changes.
make kind-up # create KinD cluster, install cloud-provider-kind + Dapr + Dashboard, deploy stack
make e2e # run 11 end-to-end assertions against the deployed cluster (sdk-http mode)
make e2e-all # run all 11 assertions across all 4 client modes (sdk-http, sdk-grpc, custom-http, custom-grpc)
make k8s-status # show pods/services across all namespaces
make kind-down # destroy everythingAccess deployed services via kubectl port-forward:
| Service | Local URL | Port-forward command |
|---|---|---|
| Inventory REST API | http://localhost:3000 | kubectl port-forward svc/inventory 3000:3000 -n dapr-go-hero-inventory |
| Dapr Dashboard | http://localhost:8080 | kubectl port-forward svc/dapr-dashboard 8080:8080 -n dapr-system |
| Zipkin UI | http://localhost:9411 | kubectl port-forward svc/zipkin 9411:9411 -n dapr-go-hero |
| Redis | localhost:6379 | kubectl port-forward svc/redis 6379:6379 -n dapr-go-hero |
| PostgreSQL | localhost:5432 | kubectl port-forward svc/postgres 5432:5432 -n dapr-go-hero |
Query examples:
curl http://localhost:3000/v1/widgets/widget | jq
curl http://localhost:3000/v1/gadgets/gadget | jq
curl http://localhost:3000/v1/products/thingamajig | jqSingle-binary processes with a Dapr sidecar each. Useful for tight feedback loops on code changes.
make deps # install mise + pinned tool versions
make build # compile both binaries
make test # unit tests
make run-products # terminal 1: products gRPC service
make run # terminal 2: inventory service (default: SDK HTTP mode)
make send-all # terminal 3: publish 3 CloudEvents
make get-all # query REST endpointsLocal dev requires a running PostgreSQL container and secrets.json. See Local dev setup.
Core (both modes):
| Tool | Version | Purpose |
|---|---|---|
| Git | 2.0+ | Version control |
| Go | 1.26.2+ | Auto-installed via mise on make deps |
| GNU Make | 3.81+ | Build orchestration |
| Docker | BuildKit-enabled | Container builds; required by integration tests (Testcontainers) |
| jq | any | JSON response formatting |
Kubernetes mode (Option A):
| Tool | Version | Purpose |
|---|---|---|
| KinD | 0.31+ | Local Kubernetes control plane in a container |
| kubectl | bundled with KinD | K8s CLI |
| Dapr CLI | 1.17+ | dapr init -k installs runtime into KinD |
Local dev mode (Option B):
| Tool | Version | Purpose |
|---|---|---|
| Dapr CLI | 1.17+ | dapr init provides self-hosted Redis + Zipkin |
| PostgreSQL | 16+ | Run as Docker container, see Local dev setup |
Optional:
| Tool | Purpose |
|---|---|
| protoc | Regenerate gRPC code from proto/products/products.proto |
| act | Run GitHub Actions workflow locally (make ci-run) |
| hadolint | Auto-installed by make docker-lint |
Install everything:
make depsOrganized by feature (pkg/features/{widgets,gadgets,products}), not by type. Dapr pub/sub requires a single callback URL per app, so each feature's subscriptions are merged in pkg/dapr/subscription.go before being returned from /dapr/subscribe.
Both the custom HTTP/gRPC wrappers (pkg/dapr/client_http.go, client_grpc.go) and the Dapr Go SDK (client_sdk.go) are implemented against the same state.Store / secrets.Store interfaces. This isolates the integration surface, simplifies swapping implementations, and documents the trade-offs (SDK: less code, protocol-agnostic; custom: explicit control, smaller dependency set).
Fiber v3 is used for the public REST API (port 3000). Built on fasthttp, Fiber has lower per-request allocation than net/http under sustained load, which matters for gateway-fronted services. Dapr's internal callback server uses net/http via the SDK — no conflict.
Dapr pub/sub callbacks (e.g., /widgets.v1) are delivery mechanics, not a public API. They listen on a separate port from the REST API to prevent accidental exposure and simplify Ingress/Service rules.
pkg/config/config.go centralizes every host/port/app-id behind env vars with sensible defaults. A small built-in loadDotenv helper reads .env on startup without overwriting existing process env vars (zero dependencies — the helper is ~20 lines in pkg/config/config.go). K8s deployments set the same keys via env: in the pod spec — one source of truth, two consumers.
- Unit (
_test.go): pure logic, mocked interfaces, millisecond execution. Run in CI on every push. - Integration (
//go:build integration): widgets repo against real PostgreSQL via Testcontainers-go; gadgets repo against a stub Dapr state sidecar (httptest); products repo against a real gRPC server on an ephemeral port;pkg/dapr/client_httpagainst a stub sidecar. Catches schema drift, wire-format drift, and SQL injection regressions. Run in CI on every push. - E2E (
tests/e2e.sh): KinD cluster + cloud-provider-kind + Dapr + full manifest deploy. Verifies sidecar injection, pub/sub routing, cross-namespace service invocation, and Zipkin trace propagation. Run in CI on every push (~3–5 min).
Gadget and Products repositories are covered by mock-based unit tests plus real E2E — adding a third integration layer for those would duplicate what E2E already validates.
Each CloudEvent arrives on the inventory topic. Dapr's content-based routing selects one of three handlers based on event.type:
Source: docs/diagrams/c4-container.puml · regenerate with make diagrams. Related ADRs: ADR-0001 (adopt Dapr), ADR-0003 (four client modes).
Cross-cutting concerns visible above:
- Resiliency (
k8s/dapr/resiliency.yaml): service invocation to products uses exponential retry (3×, 200ms→10s) guarded by a circuit breaker (trips after 5 consecutive failures, 10s cool-down). State writes retry 3× (exponential 100ms→5s). Pub/Sub has both an outbound publish-retry policy and an inbound subscriber-handler retry (unlimited exponential) so events redeliver after downstream recovery. Redis Streams'processingTimeout=30s+redeliverInterval=5sprovide the broker-level at-least-once guarantee underneath. - Tracing (
k8s/dapr/configuration.yamltracing block): both sidecars sample 100% and ship W3C Trace Context spans to Zipkin. Inventory → products invocation and every Redis/PostgreSQL hop appear as a single connected trace;tests/e2e.shasserts spans exist for bothinventoryandproductsapp-IDs after each run. - Access control (products namespace
configuration.yaml):defaultAction: denywithinventoryin the allowlist. Any other app-ID calling the products sidecar is rejected at the sidecar boundary before reaching the app; mTLS between sidecars (mtls.enabled: true) authenticates the caller's identity. - Subscription registration (dual-mode): K8s
SubscriptionCRD (k8s/dapr/subscription.yaml) declares the topic and route rules declaratively; in parallel, each feature package also registers programmatically via the app's/dapr/subscribeendpoint, withpkg/dapr/subscription.gomerging all per-feature subscriptions into one response. Either path alone is functional; both are present so the example exercises the full API surface.
| event.type | Route | Storage backend | Dapr building block |
|---|---|---|---|
widget.v1 |
/widgets.v1 |
PostgreSQL (creds from Secret Store) | Secret Store + direct DB |
gadget.v1 |
/gadgets.v1 |
Redis via state.redis |
State Store |
default (e.g. thingamajig.v1) |
/products.v1 |
Products gRPC service | Service Invocation |
Dapr envelopes events in CloudEvents and propagates W3C Trace Context headers across every hop — all three flows appear as a single trace in Zipkin.
Service Invocation to the Products service uses a generated gRPC client pointed at the local Dapr sidecar, with dapr-app-id metadata attached. In K8s with per-service namespaces, the app ID is fully-qualified (products.dapr-go-hero-products) because Dapr's default name resolver is namespace-scoped — see docs/containerize-and-deploy.md for the resolution semantics.
Source: docs/diagrams/c4-deployment.puml · regenerate with make diagrams. Decision: see ADR-0002 for the MetalLB → cloud-provider-kind rationale.
%%{init: {'theme':'base','themeVariables':{'background':'#FFFFFF','fontFamily':'ui-sans-serif, system-ui, sans-serif','primaryColor':'#FFFFFF','primaryTextColor':'#000000','primaryBorderColor':'#000000','secondaryColor':'#FFFFFF','tertiaryColor':'#FFFFFF','lineColor':'#0070E5','edgeLabelBackground':'#FFFFFF','clusterBkg':'#FFFFFF','clusterBorder':'#0070E5','titleColor':'#0070E5'}}}%%
graph TB
subgraph Client["External (host)"]
C[curl / browser]
end
subgraph Cluster["KinD cluster"]
subgraph LB["Host-side"]
ML[cloud-provider-kind<br/>LoadBalancer controller]
end
subgraph DaprSystem["dapr-system"]
DS[Dapr control plane<br/>Operator · Sentry · Placement<br/>Scheduler · Sidecar Injector]
DD[Dapr Dashboard]
end
subgraph Infra["dapr-go-hero (infrastructure)"]
R[(Redis<br/>6379)]
P[(PostgreSQL<br/>5432)]
Z[Zipkin<br/>9411]
end
subgraph InvNS["dapr-go-hero-inventory"]
ISVC{{Service LB :3000}}
IPOD[inventory pod<br/>app + daprd sidecar]
ISEC[[Secret: postgres]]
ISA((ServiceAccount<br/>+ secret-reader Role))
ICRD[Dapr CRDs:<br/>pubsub, statestore,<br/>secretstore, subscription,<br/>resiliency, configuration]
end
subgraph ProdNS["dapr-go-hero-products"]
PSVC{{Service :50151}}
PPOD[products pod<br/>app + daprd sidecar]
PSA((ServiceAccount))
PCRD[Dapr CRD:<br/>configuration]
end
ML -.assigns LB IP.-> ISVC
C -->|HTTP :3000| ISVC --> IPOD
IPOD -->|pub/sub| R
IPOD -->|state| R
IPOD -->|SQL| P
IPOD -->|GetSecret kubernetes| ISEC
IPOD -.->|traces| Z
PPOD -.->|traces| Z
IPOD ==>|"gRPC via Dapr<br/>(fully-qualified app ID)"| PPOD
DS -.manages.-> IPOD
DS -.manages.-> PPOD
ISA -.grants.-> IPOD
PSA -.grants.-> PPOD
ICRD -.configures.-> IPOD
PCRD -.configures.-> PPOD
end
classDef plain fill:#FFFFFF,stroke:#0070E5,color:#000000,stroke-width:1px
classDef dapr fill:#0070E5,stroke:#0070E5,color:#FFFFFF,stroke-width:1px
classDef emphasis fill:#000000,stroke:#0070E5,color:#FFFFFF,stroke-width:1px
class C,ML,R,P,Z,ISEC,ISA,ICRD,PSA,PCRD,ISVC,PSVC plain
class DS,DD dapr
class IPOD,PPOD emphasis
%%{init: {'theme':'base','themeVariables':{'background':'#FFFFFF','fontFamily':'ui-sans-serif, system-ui, sans-serif','primaryColor':'#FFFFFF','primaryTextColor':'#000000','primaryBorderColor':'#0070E5','actorBkg':'#FFFFFF','actorBorder':'#0070E5','actorTextColor':'#000000','actorLineColor':'#0070E5','signalColor':'#0070E5','signalTextColor':'#0070E5','labelBoxBkgColor':'#0070E5','labelBoxBorderColor':'#0070E5','labelTextColor':'#FFFFFF','loopTextColor':'#0070E5','noteBkgColor':'#FFFFFF','noteBorderColor':'#0070E5','noteTextColor':'#000000','altBackground':'#FFFFFF','activationBkgColor':'#0070E5','activationBorderColor':'#0070E5','sequenceNumberColor':'#FFFFFF'}}}%%
sequenceDiagram
autonumber
actor Pub as Publisher<br/>(curl / make send-*)
participant IDaprd as inventory daprd
participant Redis as Redis pub/sub
participant IApp as inventory app
participant PG as PostgreSQL
participant RState as Redis state
participant PDaprd as products daprd
participant PApp as products app
Pub->>+IDaprd: POST /v1.0/publish/pubsub/inventory<br/>CloudEvent (type=widget.v1)
IDaprd->>Redis: XADD inventory
Redis-->>IDaprd: deliver to subscribers
IDaprd->>+IApp: route by event.type
alt widget.v1 → /widgets.v1
IApp->>+PG: INSERT widget (via Secret Store creds)
PG-->>-IApp: ok
else gadget.v1 → /gadgets.v1
IApp->>+RState: SET gadget:id
RState-->>-IApp: ok
else default → /products.v1 (thingamajig)
IApp->>IDaprd: gRPC SaveProduct<br/>header dapr-app-id: products.dapr-go-hero-products
IDaprd->>+PDaprd: invoke (cross-namespace name resolution)
PDaprd->>+PApp: SaveProduct RPC
PApp-->>-PDaprd: empty response
PDaprd-->>-IDaprd: ok
end
IApp-->>-IDaprd: 204 No Content
IDaprd-->>-Pub: 204 No Content
Note over IDaprd,PDaprd: All hops traced → Zipkin
| Namespace | Contents | RBAC |
|---|---|---|
dapr-go-hero |
Infrastructure: Redis, PostgreSQL, Zipkin | — |
dapr-go-hero-inventory |
Inventory deployment + six Dapr CRDs | ServiceAccount + secret-reader Role (required by secretstores.kubernetes) |
dapr-go-hero-products |
Products gRPC service + Configuration CRD | ServiceAccount with minimal permissions |
Per-service namespaces enforce least-privilege RBAC and scope Dapr access-control policies.
| File | Kind | Purpose |
|---|---|---|
pubsub.yaml |
Component | Redis Streams pub/sub, FQDN-addressed |
statestore.yaml |
Component | Redis state store, scoped to inventory |
secretstore.yaml |
Component | secretstores.kubernetes — reads namespace Secrets |
subscription.yaml |
Subscription v2alpha1 | Content-based routing: widget.v1 / gadget.v1 / default |
resiliency.yaml |
Resiliency | Exponential retries + timeout + circuit breaker on products target |
configuration.yaml |
Configuration | Zipkin endpoint, mTLS, per-namespace access control |
Multi-stage Alpine with BuildKit cache mounts. Post-initial build, make docker-build completes in ~0.2 s.
make kind-up (alias for kind-deploy) brings up the full stack; make kind-create provisions the cluster and installs the LoadBalancer controller + Dapr without deploying application manifests:
- cloud-provider-kind — kind-team-maintained host-side controller that watches
Serviceobjects of typeLoadBalancerand allocates IPs on thekindDocker network. Replaces MetalLB: no in-cluster DaemonSet, no IPAddressPool/L2Advertisement CRDs, works across all supportedkindest/nodeversions without release-track drift. - Dapr runtime via
dapr init -k --runtime-version $(DAPR_RUNTIME_VERSION)— Operator, Sentry, Placement, Scheduler, Sidecar Injector, pinned to the same version CI uses - Dapr Dashboard — cluster inspection UI
See Quick Start → Option A for the Kubernetes flow. For standalone binaries:
docker run --name postgres -e POSTGRES_PASSWORD=postgres -p 5432:5432 -d postgres:16
cat tables.sql | docker exec -i postgres psql -U postgres -d postgresStart services (three terminals):
# Terminal 1
make run-products
# Terminal 2 — pick a client mode
make run-custom-http # custom HTTP client
make run-custom-grpc # custom gRPC client
make run-sdk-http # Dapr Go SDK over HTTP (default via `make run`)
make run-sdk-grpc # Dapr Go SDK over gRPC
# Terminal 3 — publish events, then query
make send-all
make get-allEach run-* target maps to a different code path in cmd/inventory/main.go and exercises the same interfaces (state.Store, secrets.Store) through a different transport.
Run make help for the full list.
| Target | Description |
|---|---|
make build |
Compile both binaries (static, CGO disabled) |
make test |
Unit tests (go test -race ./...) |
make integration-test |
Integration tests (Postgres, Dapr-state stub, gRPC) via Testcontainers |
make format |
Auto-format Go source (gofmt + golangci-lint --fix) |
make clean |
Remove build artifacts |
make update |
go get -u ./... && go mod tidy |
| Target | Description |
|---|---|
make static-check |
Composite gate: lint-ci + lint + sec + vulncheck + secrets + trivy-fs + trivy-config + mermaid-lint + diagrams-check + deps-prune-check |
make lint |
golangci-lint (gocritic + gosec via .golangci.yml) |
make lint-ci |
GitHub Actions workflow linting (actionlint + shellcheck) |
make sec |
gosec SAST scan |
make vulncheck |
govulncheck ./... — known CVEs in dependencies |
make secrets |
gitleaks — hardcoded credentials scan |
make trivy-fs |
Trivy filesystem scan (vulns + secrets + misconfigs), CRITICAL+HIGH |
make trivy-config |
Trivy K8s manifest misconfig scan (KSV-*), CRITICAL+HIGH |
make mermaid-lint |
Validate Mermaid diagrams via minlag/mermaid-cli Docker image |
make diagrams |
Render PlantUML diagrams via pinned plantuml/plantuml image |
make diagrams-check |
CI drift gate: fail if committed PNGs differ from .puml sources |
make diagrams-clean |
Remove rendered diagram PNGs |
make deps-prune-check |
Fail if go.mod/go.sum would be modified by go mod tidy |
| Target | Description |
|---|---|
make ci |
Full local pipeline: deps → static-check → test → integration-test → build |
make ci-run |
Execute GitHub Actions workflow locally via act |
| Target | Description |
|---|---|
make run |
Inventory service with Dapr (default: SDK HTTP mode) |
make run-products |
Products gRPC service |
make run-custom-http |
Inventory with custom HTTP client |
make run-custom-grpc |
Inventory with custom gRPC client |
make run-sdk-http |
Inventory with Dapr Go SDK over HTTP |
make run-sdk-grpc |
Inventory with Dapr Go SDK over gRPC |
make dapr-test |
Dapr sidecar only (no app) — useful for API debugging |
| Target | Description |
|---|---|
make send-widget / send-gadget / send-thingamajig |
Publish individual CloudEvents |
make send-all |
Publish all three event types |
make get-widget / get-gadget / get-thingamajig |
GET individual items |
make get-all |
GET all three |
| Target | Description |
|---|---|
make docker-build |
Build both images (BuildKit + cache mounts) |
make docker-push |
Push to $(REGISTRY) |
make docker-lint |
Hadolint on Dockerfiles |
make kind-up |
Full stack: create cluster + cloud-provider-kind + Dapr + deploy manifests (alias for kind-deploy) |
make kind-down |
Full teardown (alias for kind-destroy) |
make kind-create |
Create cluster + install LoadBalancer controller + Dapr (no app deploy) |
make kind-deploy |
kind-create + k8s-deploy (full stack up) |
make kind-destroy |
Delete cluster + stop cloud-provider-kind container |
make k8s-deploy |
Build images, load into KinD, apply all manifests |
make k8s-undeploy |
Remove all manifests |
make k8s-status |
Pod/service status across namespaces |
make e2e |
Run tests/e2e.sh against deployed cluster (default: sdk-http mode) |
make e2e-all |
Run e2e across all 4 client modes (sdk-http, sdk-grpc, custom-http, custom-grpc) |
make e2e-setup / e2e-teardown |
Composite setup/teardown |
| Target | Description |
|---|---|
make deps |
Install pinned tools (mise, gosec, golangci-lint, govulncheck) |
make deps-act |
Install act — auto-invoked by make ci-run |
make deps-hadolint |
Install hadolint — auto-invoked by make docker-lint |
make deps-gitleaks |
Install gitleaks — auto-invoked by make secrets |
make deps-actionlint |
Install actionlint — auto-invoked by make lint-ci |
make deps-shellcheck |
Install shellcheck — auto-invoked by make lint-ci |
make deps-trivy |
Install Trivy — auto-invoked by make trivy-fs / make trivy-config |
make deps-check |
Show mise + Go status |
make deps-prune |
Run go mod tidy (shows any changes needed) |
make generate-env |
Regenerate .env from pkg/config defaults |
make proto-gen |
Regenerate gRPC code from .proto files (uses mise-pinned protoc, protoc-gen-go, protoc-gen-go-grpc) |
make release |
Tag and push a new semver release |
make renovate-bootstrap |
Install Node via mise — auto-invoked by make renovate-validate |
make renovate-validate |
Dry-run Renovate config |
GitHub Actions runs on every push to main, tags v*, pull requests, and workflow_call.
| Job | Depends on | Purpose |
|---|---|---|
static-check |
— | make static-check composite gate |
docker-lint |
— | Hadolint on Dockerfiles (parallel with static-check) |
build |
static-check | Compile both binaries |
test |
static-check | Unit tests |
integration-test |
static-check | Integration tests with Testcontainers PostgreSQL |
e2e |
build, test | Provision KinD + cloud-provider-kind + Dapr, deploy, run 11 end-to-end assertions across all 4 client modes (make e2e-all) |
ci-pass |
all above | Aggregate gate — single required status check for branch protection |
Renovate auto-merges minor/patch updates after CI passes (3-day minimum release age on majors). A single customManagers regex in renovate.json tracks every # renovate: annotated constant in the Makefile — no per-tool configuration.
A scheduled workflow (.github/workflows/cleanup-runs.yml) removes workflow runs older than 7 days weekly (retains last 5 regardless of age).
See docs/adr/ for the key non-obvious decisions behind this project:
- ADR-0001 — adopting Dapr as the integration runtime
- ADR-0002 — cloud-provider-kind over MetalLB for LoadBalancer support
- ADR-0003 — shipping four Dapr client implementations side-by-side
- Original pattern: pkedy/golang-dapr
- vladimirvivien/dapr-examples
- Article: Building Cloud-Native Services with Dapr, Go, and Kubernetes
- Implementation notes: docs/containerize-and-deploy.md
Contributions welcome — open a PR.


