Cross-Project API Design Comparison#

Summary#

Across 51 Go projects, API design follows clear gravitational lines determined by project category: infrastructure services expose 3–5 simultaneous API surfaces; developer tools choose CLI-primary or library-only architectures; framework libraries expose zero network surfaces at all. The most striking finding is that no single pattern dominates. Cobra leads for CLI frameworks but multiple credible alternatives exist; REST over custom muxes wins for HTTP servers but gorilla/mux and chi have largely displaced stdlib for anything beyond trivial routing; gRPC is universal for internal inter-service communication but used as a public API only in a minority of projects. The corpus reveals three distinct evolutionary pressures: the migration from manual HTTP wiring toward declaration-first approaches (grpc-gateway, connect-rpc, oapi-codegen), the rise of Unix-socket IPC as a first-class API transport, and the convergence on SSE rather than WebSockets for unidirectional server-push.


Taxonomy: Approaches to API Design#

1. Multi-Surface Infrastructure APIs#

The infrastructure tier (kubernetes, etcd, consul, vault, nomad, dapr, cockroach, grafana, temporal, gitea, drone/gitness) exposes 3–5 simultaneous API surfaces as a matter of necessity:

  • Public REST + gRPC + CLI + Library is the canonical infrastructure stack
  • The REST surface is frequently auto-generated from proto annotations via grpc-gateway rather than hand-authored
  • A separate CLI binary that talks to the same gRPC socket is the standard operator interface
  • An embedded library API (functional options constructor) serves programmatic embedding

The typical decomposition:

SurfaceConsumerAuth
gRPC (public)SDKs, clientsmTLS / API key
REST bridgecurl, humansSame via gateway
gRPC (internal)Sister servicesnone (network boundary)
CLIOperatorsSocket peer credentials or API key
LibraryEmbeddersN/A

Representative example: temporal — Three-tier gRPC (WorkflowService → HistoryService/MatchingService → AdminService), REST via grpc-gateway with inline client connection (no extra network hop), Nexus HTTP for async task protocol, urfave/cli for operators.

2. REST-Primary Services#

Projects where REST is the public API and gRPC (if present) is internal-only: prometheus, traefik, caddy, minio, k3s, syncthing, frp, pocketbase, gitea, gogs, argo-cd (grpc-gateway), headscale.

Router taxonomy:

RouterPrimary usersCharacteristic use
gorilla/muxmoby, traefik, frp, cockroach (via gateway), temporal (outer)Path vars, subrouters, method routing
chi v5dapr, headscale, drone/gitness, buildkite (internal), rclone (rcd)Middleware composability, clean subrouter API
stdlib ServeMux (Go 1.22+)consul, vault, nomad, caddy, nats-server, crushMethod-prefixed patterns (GET /path)
httproutersyncthing, prometheusPerformance; explicit over-implicit
fasthttp (fiber)fiberZero-allocation; breaks net/http compat
custom forksminio (gorilla fork)Protocol-fidelity requirements
pocketbase custompocketbaseHook integration

A clear split exists between projects that migrated to stdlib 1.22 mux (consul, vault, nomad) and those that remain on gorilla/mux for legacy reasons. New projects in 2024–2026 (dapr, crush, drone) uniformly chose chi or stdlib 1.22.

3. gRPC-Primary APIs#

Projects where gRPC is the external, public-facing protocol: etcd, temporal, headscale, argo-cd, istio.

Common patterns:

  • grpc-gateway bridges to REST for human/curl consumption in all of these except argo-cd (which uses it extensively)
  • Swagger/OpenAPI is auto-generated from proto annotations and committed as generated code
  • Dual-socket gRPC (Unix for local, TCP for remote) appears in headscale, giving different auth models per transport
  • gRPC reflection registered on all servers to enable grpcurl introspection without out-of-band schema sharing

4. Custom Wire Protocol APIs#

Several projects define their own binary or text protocols rather than REST or gRPC:

ProjectProtocolTransportReason
nats-serverNATS (line-oriented text)TCPUnified data+management plane; JetStream API via subjects
wireguard-goUAPI (key=value text)Unix socketSpec-defined cross-platform WireGuard standard
syncthingBEP (length-prefixed protobuf, not gRPC)TLS/QUICCustom mTLS device identity, no PKI
prometheusProtobuf-over-HTTP (not gRPC, no streaming)HTTPSimpler than gRPC, self-describing via OpenAPI
delveJSON-RPC 2.0 + DAPTCPDAP is an IDE standard; JSON-RPC predates gRPC in the project

The custom-protocol projects make the same tradeoff: control over framing/auth/evolution, at the cost of tooling ecosystem.

5. CLI-Primary APIs#

Projects where the CLI is the sole or primary external surface: hugo, restic, fzf, rclone, air, gh, delve (interactive REPL), helm.

CLI framework distribution across all 51 projects:

FrameworkCountPrimary users
Cobra (spf13/cobra)~25kubernetes, caddy, etcd, cockroach, restic, rclone, delve, frp, headscale, pocketbase, helm, argo-cd, crush, gh, dapr, k3s, pop (soda)
hashicorp/cli (mitchellh)4consul, vault, terraform, nomad
urfave/cli (v1/v2/v3)5gitea, gogs, minio, buildkite-agent, temporal
simplecobra1hugo
peterbourgon/ff (ffcli)1tailscale
kong (alecthomas)1syncthing
kingpin (v2)1drone/gitness
stdlib flag3nats-server, air, wireguard-go
custom framework1go toolchain

Cobra is dominant (≈49% of projects) but the HashiCorp ecosystem is exclusively mitchellh/cli, an intentional internal consistency decision. No new project in this corpus adopted urfave/cli for a primary binary after ~2021.

Notable CLI design choices:

  • Testable run functions: gh’s NewCmdList(f, runF func(*ListOptions) error) pattern injects the run function, making CLI commands fully testable without invoking Cobra. Widely imitated.
  • Persistent global flags as config carriers: restic passes *global.Options through PersistentPreRunE rather than globals.
  • Reflection-driven flag generation: air auto-registers all Config struct fields as CLI flags via struct tag reflection — zero-maintenance sync between config and flags.
  • Flag normalization: frp normalizes _/- separators via SetGlobalNormalizationFunc.
  • Structured exit codes: gh defines typed exit codes (0=OK, 1=error, 2=cancel, 4=auth, 8=pending) as a scripting API.

6. Library-Only APIs#

Projects that expose no CLI binary and no HTTP server — pure Go libraries: gin, echo, fiber, buffalo, beego, gorm, sqlc (library), viper, cobra, pop, wireguard-go (primarily).

API style patterns across libraries:

StyleExamplesCharacteristic
Fluent builder / method chaininggin (RouterGroup), echo (Group), gorm (*DB), pop (*Query)Chainable configuration; common for HTTP routers and ORMs
Struct literal constructioncobra (*Command), fiber (App)Named fields; self-documenting; no builder needed
Functional optionsviper (Option interface), temporal (ServerOption), syncthing (Options struct)Nil-safe defaults; extensible without API breaks
Interface injectiongin (HandlerFunc), echo (Router/Binder), fiber (CustomCtx), gorm (Plugin), caddy (modules)Compile-time extension
Global + instance dual surfaceviper (global + *Viper), gorm (global DB + opened *DB)Convenience for simple cases; isolation for complex

The HTTP framework libraries (gin, echo, fiber, buffalo, beego) universally converge on HandlerFunc-style middleware unification: middleware and route handlers share the same signature, eliminating a separate Middleware type.


1. grpc-gateway as the standard REST bridge#

Projects that chose gRPC as their primary protocol almost universally add a grpc-gateway REST bridge rather than maintaining two separate API implementations. etcd, temporal, headscale, argo-cd, and cockroach all use this pattern. The REST API becomes a free by-product of the proto HTTP annotations. The “inline client connection” pattern (temporal) avoids a network round-trip: the gateway dials an in-process gRPC client, so REST requests traverse the full interceptor chain identically to gRPC requests.

2. connect-rpc displacing native gRPC for client-facing protocols#

gitea (Actions runners) and buildkite-agent both chose connectrpc.com/connect over native gRPC. connect-rpc tunnels gRPC over standard HTTP/1.1 or HTTP/2, eliminating the need for gRPC-aware proxies and load balancers. This is a 2023–2024 trend visible in projects with complex network topologies (customers’ firewalls, k8s ingresses).

3. SSE over WebSockets for server-push#

Projects needing real-time server-push (crush, syncthing, pocketbase, drone/gitness, fzf’s --listen, buildkite, air) uniformly chose Server-Sent Events over WebSockets. SSE is HTTP/1.1 compatible, has automatic reconnect in browsers, requires no protocol upgrade, and is sufficient for unidirectional push. WebSockets appear only where bidirectional is genuinely needed (argo-cd terminal sessions).

4. Unix-socket IPC as a first-class transport#

Multiple projects deploy a daemon with a Unix-socket-served HTTP API as the primary local IPC mechanism: tailscale (localapi v0), crush, buildkite-agent (Job API + Agent API), syncthing (optional), dapr. This pattern provides OS-level access control (socket permissions replace auth), avoids port conflicts, and is zero-configuration for local clients.

5. Go 1.22+ stdlib mux adoption#

New or recently-refactored projects (nomad, consul v2, crush, pocketbase) now use stdlib net/http.ServeMux with Go 1.22 method+path patterns (GET /api/v1/resource/{id}). This eliminates gorilla/mux as a dependency for many projects. Projects maintaining gorilla/mux are largely doing so for backwards compatibility rather than active preference.

6. OpenAPI-from-code vs. code-from-OpenAPI split#

Two philosophies coexist in the corpus:

  • Code-first (swagger annotations in godoc): gitea, caddy, crush, rclone — the spec is generated at build time from handler annotations.
  • Contract-first (oapi-codegen / proto-first): drone/gitness (registry module), temporal (external proto module), dapr — the handler interface is generated from the spec.

The split often follows team topology: projects with many contributors prefer contract-first for API governance; single-team projects prefer code-first for velocity.

7. WASM as a plugin runtime#

Three projects (helm v4, sqlc, traefik) use WASM (Extism or direct WASI) as a cross-language plugin runtime. This replaces the need for gRPC subprocess plugins in contexts where extension security and portability matter more than performance. Traefik’s Yaegi (interpreted Go) + WASM dual-plugin approach allows both performance (interpreted Go for trusted plugins) and isolation (WASM for third-party plugins).


Best Practices#

1. Separate public and internal gRPC services at the module boundary#

Temporal places its public gRPC API (WorkflowService, OperatorService) in an external Go module (go.temporal.io/api) that is published and versioned independently. Internal services (HistoryService, MatchingService) are defined in proto/internal/ within the server repo. This enforces the boundary: no external client can accidentally import an internal service definition. The server implements the public interface it does not own.

2. Auth split by API surface, not by endpoint#

Headscale demonstrates clean authentication separation: the Tailscale-facing API uses Noise cryptographic machine identity (zero passwords); the admin API uses opaque API keys. The same pattern appears in buildkite-agent (three tiers: SaaS REST uses Bearer tokens; Job API uses per-server random token; Agent API relies on socket permissions). Design principle: each API surface has a trust model appropriate to its consumer, and trust models are not mixed.

3. The run-function injection pattern for testable CLIs#

gh makes every Cobra command constructor accept runF func(*Options) error. In production, runF is nil, and the command calls its real implementation. In tests, runF is injected, bypassing flag parsing entirely. This means CLI command logic is testable with a simple function call. restic achieves the same effect by passing *global.Options by pointer through PersistentPreRunE rather than using globals.

4. Single registry, multiple access modes#

Rclone exposes its rc.Calls registry through three surfaces simultaneously: CLI (rclone rc method), HTTP daemon (POST /operations/method), and C FFI (RcloneRPC("operations/method", ...)). All three call the same registered function. The same pattern appears in nats-server (JetStream management subjects go through the same NATS protocol as data) and syncthing (syncthing cli is a REST client — there is no separate admin protocol).

5. Not-implemented stubs rather than 404s for compatibility surfaces#

Headscale registers eleven /machine/* endpoints as NotImplementedHandler (501) rather than omitting them. This preserves compatibility with Tailscale clients that call new endpoints: 501 degrades gracefully and is discoverable via logs, while 404 causes hard failures in some client versions. The stubs also serve as living documentation of the intended protocol surface.

6. Per-route middleware as named functions with priority#

PocketBase and dapr attach middleware as named, priority-ordered hook handlers rather than anonymous closures. In PocketBase, pbActivityLogger (priority -40), pbPanicRecover (-30), and pbLoadAuthToken (-20) can be reasoned about, overridden, and audited by name. This contrasts with Go frameworks that compose middleware as anonymous function wraps, where insertion order is the only documentation.

7. /rest/noauth/ prefix pattern for unauthenticated routes#

Syncthing places all unauthenticated endpoints under /rest/noauth/ rather than maintaining an exclusion list. The security boundary is explicit and auditable — any route outside /rest/noauth/ requires auth. Compare to the common anti-pattern of per-route opt-out (DisableAuthCheck(cmd) in gh, which requires correct annotation on every new command).

8. Structured JSON output as a machine API for CLI tools#

Restic (--json global flag), gh (--json/--jq/--template), rclone (lsjson), and crush (--json on session subcommands) treat machine-readable output as a first-class API surface. Restic’s message_type discriminator on all JSON output creates a typed event stream. Gh’s cmdutil.AddJSONFlags applies the same pattern uniformly across 50+ commands. The implication: CLI tools are increasingly consumed by other programs and agents, not just humans.


Anti-Patterns#

1. Middleware as an anonymous closure stack with no names or priorities#

Most HTTP frameworks in this corpus (gorilla/mux, chi) compose middleware as a chain of closures: r.Use(authMiddleware, loggingMiddleware). This works but makes it impossible to inspect, override, or instrument individual middleware layers by name. When debugging, the stack is opaque. PocketBase’s named hook approach and Dapr’s middleware registration by string ID both solve this, but the pattern has not spread to the broader framework ecosystem.

2. Flag/config duplication across surfaces#

Multiple projects (consul, vault, nomad) maintain environment variables, config file keys, and CLI flags as three separate surfaces with hand-maintained sync. Air’s reflection-driven approach (ParseConfigFlag walking the Config struct) and caddy’s single configuration document (one JSON tree that maps to the config file, the REST API, and the environment) are better solutions, but require up-front architectural commitment.

3. Shallow REST bridges over internal-only gRPC#

Consul’s /api/v1/ REST API uses stdlib ServeMux with init()-registered routes that touch internal state directly, while also having /api/v2/ Kubernetes-style routes backed by a different model. The dual-REST-version-over-different-backends pattern creates maintenance burden. Compare to etcd, temporal, and headscale, which have a single gRPC implementation with REST as a thin generated bridge — one implementation, two access modes.

4. Large public interfaces with too many methods#

PocketBase’s core.App interface has ~150 methods. The godoc comment explicitly notes it is “not intended to be implemented by third parties” — because implementing 150 methods for testing is infeasible. This is a recognized anti-pattern, but projects with both a public library API and an internal implementation surface face a genuine tension: if App is an interface, tests can stub it; if it’s a concrete struct, tests require a real database. The better solution (used by dapr, temporal) is to expose narrow sub-interfaces at each call site.

5. Global state for route registration#

Several projects use init() functions for route/command registration: rclone (func init() { cmd.AddCommand(...) }), consul (func init() { api.registerRoute(...) }), pprof (init() in net/http/pprof). This prevents instantiation of multiple servers in the same process (violates test isolation) and makes the registration order dependent on import order. Caddy’s explicit init() module registration and pocketbase’s OnServe().BindFunc() are marginally better but still rely on side effects. Temporal’s WithChainedFrontendGrpcInterceptors and restic’s backend.Registry are constructor-time registrations that avoid global state entirely.

6. Version negotiation in query parameters rather than paths or headers#

Gogs uses ?version= for webhook plugin protocol version negotiation (frp’s webhook plugin POSTs ?version=0.1.0&op=Login). Prometheus uses ?match[] for federated queries. Syncthing uses ?since= for event polling. Query-parameter API versioning makes caching impossible (Cache-Control is per-URL, not per-query-param) and is invisible to routers. Path versioning (/v1/, /v2/) or header negotiation (Accept: application/vnd.example.v2+json) are both preferable.


Exemplars#

Most architecturally sophisticated: Temporal#

Three-tier proto separation (public/internal/admin), grpc-gateway with inline client connection for zero-cost REST transcoding, 18-interceptor middleware chain with named layers, Nexus HTTP for a third protocol on the same port, functional options embedding API, and CHASM for a pluggable execution model. Every design choice has a documented rationale in the code.

Cleanest REST surface: Gitea#

Five isolated API surfaces in one binary (REST /api/v1, 20+ package registries each speaking their native protocol, Connect-RPC for Actions runners, private IPC, web UI), each with its own router, middleware, context type, and auth model. Swagger spec generated from godoc annotations at build time. OCI Distribution Spec implemented faithfully (Docker/containerd can point at Gitea unmodified). The isolation level is architecturally clean and auditable.

Most elegant CLI: gh (GitHub CLI)#

The NewCmdXxx(f *Factory, runF func(*Opts) error) pattern makes every command testable without invoking Cobra. cmdutil.AddJSONFlags applies machine-readable output uniformly. The extension model (git-style gh-* subprocess executables injected into the root command tree) makes gh a platform. Typed exit codes (0/1/2/4/8) form a scripting API.

Most principled minimalism: wireguard-go#

~20 public methods on *Device. All extensibility via two interfaces (tun.Device, conn.Bind) injected at construction. UAPI text protocol as the lingua franca for both Unix socket access and in-process IpcSet() calls. No net/http dependency. No gRPC. No plugin registry. The design constraint is the spec itself (the WireGuard xplatform configuration protocol), and the code follows it faithfully.

Most creative unification: NATS Server#

The JetStream control plane is exposed entirely through NATS subjects ($JS.API.STREAM.CREATE.<name>) rather than a separate REST or gRPC API. This means TLS, account isolation, and authorization from the core NATS protocol automatically apply to JetStream management — no separate auth layer. Advisory events ($JS.EVENT.ADVISORY.*) form a pub/sub changelog of cluster state. The management plane is not a second protocol bolted on; it is the first protocol applied to itself.

Most unusual optimization: fzf#

A hand-rolled HTTP/1.1 parser (2.7 KB of code) instead of net/http, motivated by binary size: net/http adds 2.9 MB to the binary. The parser handles the full request/response cycle for two endpoints. The action DSL (chainable ~200 action types with +) works identically in --bind flags and HTTP POST bodies — one language, two interfaces. A principled, measurable tradeoff.

Best extension model for non-Go authors: rclone + PocketBase#

Rclone: the C FFI (RcloneRPC) exposes the same function registry as the HTTP daemon and CLI, with Python/PHP/mobile bindings. Language-agnostic extension without subprocess overhead.

PocketBase: hook-based extension (app.OnRecordCreate().BindFunc(...)) works identically in Go and JavaScript (goja JSVM), with TypeScript type definitions pre-generated for IDE autocomplete. Users choose the language, not the architecture.


Notes on Specific Projects#

fyne#

Fyne is a GUI toolkit library with no HTTP server, no gRPC, and a deprecated CLI tool. Its API surface is entirely library-based: five interface-based extension points (fyne.Widget, fyne.Layout, fyne.Theme, fyne.App, fyne.Canvas). It is the only purely GUI-oriented project in the corpus and its API design reflects that — the extension points are graphics primitives rather than network protocols. Its presence in cross-network-API comparisons is purely categorical; the meaningful comparisons are with other library-only projects (gin, echo, gorm).

crush#

Crush is an AI coding assistant (the newest project in the corpus, analyzed from first-principles code). Its API design reflects the AI-agent use case explicitly: the --json flag on session subcommands acknowledges agent-as-caller, SSE events carry PermissionRequest and AgentEvent types for programmatic permission granting, and the REST API mirrors the internal Workspace interface 1:1 (mechanically derived, not designed independently). The MCP (Model Context Protocol) integration is the most forward-looking extension point in the corpus — it treats tool-use as a protocol-level concern rather than a compile-time registration.

The Unix-socket daemon pattern (same as tailscale, buildkite) provides security without authentication overhead. The HTTP/2 on Unix socket enables SSE multiplexing alongside REST calls on a single connection — a detail that most projects get wrong (using HTTP/1.1 for SSE, which requires a separate connection per stream).