Dapr — Patterns#
Concurrency patterns#
Structured concurrency via RunnerCloserManager#
- Usage: Every binary (daprd, placement, sentry, scheduler, operator, injector) uses
concurrency.NewRunnerManagerorconcurrency.NewRunnerCloserManagerfromdapr/kitto compose long-running subsystems. - Example:
cmd/daprd/app/app.go:161— runtime, HTTP server, gRPC servers all registered asfunc(ctx context.Context) errorrunners;pkg/actors/actors.go:268— actor subsystems similarly composed - Assessment: Sophisticated. This is the project’s most distinctive concurrency discipline. All goroutines are structured: they receive a context, return an error, and closers execute in reverse order. Prevents goroutine leaks and makes shutdown sequencing explicit. Far superior to ad-hoc
go func()for long-running services.
Goroutine spawning (368 occurrences)#
- Usage: Widespread anonymous goroutine spawning for one-off background work. 368
go funccalls outside vendor. - Example:
pkg/runtime/processor/— component init fanout;pkg/resiliency/policy.go:122— background timeout goroutine withmake(chan doneCh[T], 1)for result handoff - Assessment: Idiomatic and appropriate given Dapr’s event-driven nature. The higher-risk long-lived goroutines are managed via
RunnerCloserManager; the short-lived fire-and-forward ones are typical for callback and event delivery.
Channel-based signalling (552 select {} occurrences, ~10 make(chan patterns)#
- Usage: Channels are used for lifecycle coordination (ready signals, shutdown signals, error propagation) rather than data streaming.
- Examples:
cmd/placement/app/app.go:82—placeReady := make(chan struct{})for readiness handoffcmd/sentry/app/app.go:66—issuerEvent = make(chan struct{})for cert renewal coordinationpkg/resiliency/policy.go:122—make(chan doneCh[T], 1)for returning generic result from timeout-wrapped goroutine
- Assessment: Correct. Channels used for signal/rendezvous, not for throughput. Unbuffered or capacity-1 channels dominate.
errgroup for parallel fan-out#
- Usage: Used selectively for structured parallel operations.
- Example:
pkg/runtime/pubsub/default_bulkpub.go:49— parallel bulk publish to multiple topics usingerrgroup.Group; integration testtests/integration/suite/daprd/workflow/listener/multi.go:82 - Assessment: Used appropriately and sparingly — only where coordinated parallel work with error aggregation is genuinely needed.
sync.Pool for object reuse#
- Usage: Object pooling in hot paths — scheduler stream event processing and actor workflow factories.
- Examples:
pkg/scheduler/server/internal/pool/loops/stream/stream.go:34— pools event stream structs to avoid per-event allocationpkg/actors/targets/workflow/orchestrator/factory.go:36,activity/factory.go:32— pools workflow actor execution objectspkg/actors/internal/placement/loops/disseminator/inflight/inflight.go:35— pools in-flight placement dissemination records
- Assessment: Targeted and justified — applied in tight event-processing loops, not speculatively.
Context propagation (4702 context.Context references)#
- Usage: Pervasive. Every operation, component call, gRPC invocation, and actor interaction passes a
context.Context. Context cancellation drives the entire shutdown sequence. - Example:
pkg/resiliency/policy.go:119—context.WithTimeout(ctx, def.t)wraps each retried operation;context.WithCancelCauseused in scheduler pool for structured error cause propagation - Assessment: Exemplary. Context is the backbone of all cancellation.
context.WithCancelCause(Go 1.20) appears in newer subsystems, allowing richer shutdown diagnostics.
Graceful shutdown#
- Usage:
SIGHUPtriggers in-process restart (not full process exit) in daprd;SIGTERMtriggers RunnerCloserManager teardown.DaprGracefulShutdownSecondsis a configurable flag. - Example:
cmd/daprd/app/app.go:123— comment distinguishes SIGHUP (restart) from SIGTERM (exit);cmd/daprd/options/options.go:161— configurabledapr-block-shutdown-duration - Assessment: Production-grade. SIGHUP restart without pod restart is a Kubernetes optimization that avoids connection disruption during rolling updates.
Worker pool for scheduler#
- Usage: Scheduler service uses a configurable number of workers (
--workers, default 2048) to process job events in parallel. - Example:
cmd/scheduler/options/options.go:136— detailed flag description explaining tuning tradeoffs - Assessment: Explicit and well-documented. Tunable to I/O vs CPU bottleneck.
Error handling#
- Style: Mixed — sentinel errors for conditions,
fmt.Errorfwith%wfor wrapping, custom struct types for rich API errors. Overall pragmatic rather than dogmatic. - Error types defined:
pkg/resiliency/retry.go:39—CodeErrorstruct for gRPC status code propagation through retry policiespkg/api/errors/pubsub.go:27–37—PubSubError,PubSubMetadataError,PubSubTopicErrorwith component name and topic contextpkg/api/errors/state.go:28—StateStoreErrorfor state operation failurespkg/actors/errors/errors.go:32—ActorErrorfor actor-specific failures with actor type/ID metadatapkg/api/http/errors.go:23—ErrorResponsestruct for HTTP JSON error body serialization
- Wrapping approach:
fmt.Errorf("...: %w", err)throughout. Nopkg/errorsdependency. Stdliberrors.Is/errors.Asused for unwrapping. - Examples:
cmd/scheduler/options/options.go:162—errors.New("kubeconfig flag is only valid in --mode=kubernetes")— validation at boundarypkg/api/errors/pubsub.go:221— structuredPubSubError.Build()that produces a gRPCstatus.Statuswith metadata — errors are protocol-awarepkg/resiliency/policy.go:211—retry.NotifyRecoverWithDatacallback on retry state transitions for observability
Configuration pattern#
- Approach: Options struct per binary, populated via stdlib
flagpackage. No functional options at the top level, no Viper, no YAML config file for the binary itself. - Example:
cmd/daprd/options/options.go—Optionsstruct with ~40 fields bound toflag.FlagSet.cmd/sentry/options/options.go— nested sub-option structs (X509Options,JWTOptions,OIDCOptions). - Internal DI: Subsystem constructors accept explicit
Optionsstructs with their dependencies. Example:pkg/scheduler/server/internal/pool/pool.go:32—type Options struct { ... }with all dependencies injected. - Assessment: Consistent. The choice to avoid Viper keeps the configuration observable and testable — no magic lookup, no global state. Nested Options structs for complex subsystems (sentry OIDC options) keep concerns separated.
Dependency injection#
- Approach: Manual wiring via Options structs. No DI framework (no wire, dig, or fx).
- Evidence:
cmd/daprd/app/app.go— composition root that wires all subsystems by constructing each with its dependencies.pkg/runtime/runtime.go—DaprRuntimestruct with all subsystem fields populated innewDaprRuntime(). - Pattern: Constructor functions accept
Optionsstructs; nested structs carry transitive deps. Every package exposes aNew(opts Options) *TorNew(opts Options) (Interface, error)constructor. - Assessment: Explicit and testable. The lack of a framework means the wiring is straightforward Go and always greppable. Downside: composition root (
newDaprRuntime) is large — hundreds of lines of wiring code. Acceptable at this scale.
Other notable patterns#
Generics for protocol-agnostic handlers (pkg/api/http/universal.go:30)#
One of Dapr’s most architecturally interesting generic uses: UniversalHTTPHandler[T proto.Message, U proto.Message] wraps any Universal handler function into an http.HandlerFunc. The generic adapter handles JSON/protobuf decoding, InModifier and OutModifier hooks, and status code selection — eliminating per-endpoint boilerplate. This is a generics-as-adapter pattern: the type parameters enforce proto-compatibility while the runtime uses reflection to allocate zero values.
func UniversalHTTPHandler[T proto.Message, U proto.Message](
handler func(ctx context.Context, in T) (U, error),
opts UniversalHTTPHandlerOpts[T, U],
) http.HandlerFuncGeneric resiliency Runner (pkg/resiliency/policy.go:34)#
Runner[T any] / Operation[T any] express retry/circuit-breaker/timeout policies generically. A single NewRunner[T](ctx, def) call returns a closure that applies all policies to any operation returning (T, error). The RunnerOpts[T] struct carries a Disposer func(T) — called on timeout-leaked results — and an Accumulator func(T) for batched intermediate results. Pre-generics this would have required interface{} casts or per-type codegen.
Registry + blank-import init() for component loading#
Each building-block type has its own typed Registry with a DefaultRegistry singleton (pkg/components/pubsub/registry.go:32). Component implementations register themselves via init() functions, triggered by blank imports in cmd/daprd/components/. Build tags select between allcomponents.go and stablecomponents.go to produce different binary flavors without conditional code in core paths.
This is one of the cleanest examples of the “self-registering plugin” pattern in Go — zero interface overhead, zero config file, fully determined at link time.
Type switches for protobuf oneofs#
Protobuf oneof fields surface as Go interface values. Type switches are used idiomatically throughout the scheduler pool and actor placement:
// pkg/scheduler/server/internal/pool/loops/connections/connections.go:162
switch t := meta.GetTarget(); t.GetType().(type) {
case *schedulerv1pb.JobTargetMetadata_Actor:
...
case *schedulerv1pb.JobTargetMetadata_Job:
...
}Assessment: correct and necessary when consuming generated protobuf. Dapr uses this consistently and safely (no unhandled default in critical dispatch paths).
Interface segregation with dual implementations (disk vs. operator)#
The hotreload.Reloader interface has two implementations selected at startup: DiskReloader (watches filesystem via inotify for standalone mode) and OperatorReloader (consumes gRPC streaming events from Kubernetes operator). Similarly, channel.AppChannel has http.Channel and grpc.Channel implementations. These are textbook examples of Go interface segregation — the interface captures exactly the operation the runtime needs, not the full feature set of either implementation.
gRPC interceptor chains for cross-cutting concerns#
Both unary and streaming gRPC servers use grpc.ChainUnaryInterceptor / grpc.ChainStreamInterceptor for composable middleware:
// pkg/api/grpc/server.go:328-329
grpcGo.UnaryInterceptor(grpcMiddleware.ChainUnaryServer(intr...)),
grpcGo.StreamInterceptor(grpcMiddleware.ChainStreamServer(intrStream...)),Interceptors handle OTel tracing, metrics, metadata injection, and auth. This is the gRPC idiomatic equivalent of HTTP middleware stacks.
Table-driven tests (247 occurrences)#
Heavy use of table-driven tests throughout, using anonymous structs with descriptive names. Standard Go style, nothing unusual — but the volume signals a culture of parameterized testing over individual test functions.
Broadcaster (event fan-out) in scheduler#
The scheduler uses dapr/kit/events/broadcaster.Broadcaster to fan out host-list updates to all watching subscribers:
// pkg/scheduler/server/internal/cron/leadership.go:29
hostBroadcaster *broadcaster.Broadcaster[[]*schedulerv1pb.Host]This is an observer/pub-sub pattern scoped to internal service topology changes — not message-queue fan-out.