One Go binary. One port. Three protocols (Connect, gRPC, gRPC-Web). A canonical, didactic template for any new Elven Works / Sentinel v2 microservice that needs RPC reachable from browsers, mobile clients, and other Go services.
This repository is a minimum-yet-complete reference showing how to expose one RPC service over the Connect, gRPC, and gRPC-Web protocols from a single Go binary on a single port. It is intentionally small: every file teaches one idea. The whole project is readable in an afternoon. Copy its structure into a new service and replace the domain.
It is not a production microservice — no business domain, no persistence, no multi-tenant auth, no Dockerfile, no Kubernetes. The point is the wiring: where the protocols plug in, where interceptors fit, how the toolchain produces code from the proto.
A traditional Go gRPC service that needs to be reachable from browsers normally runs two
stacks: grpc-go for service-to-service traffic and grpc-gateway to expose REST/JSON over
HTTP/1.1. Two code paths, two routers, two surfaces that drift apart.
Connect collapses that to one stack. A connectrpc.com/connect handler natively serves:
- the Connect protocol (own framing, JSON or binary, HTTP/1.1 or HTTP/2),
- gRPC (binary, HTTP/2 only),
- gRPC-Web (binary, HTTP/1.1 or HTTP/2).
…off the same http.Handler. One mux, one port, three wire formats. No proxy, no duplicated
code. See https://connectrpc.com for the project's philosophy.
| Protocol | Transport | Codec | When to use |
|---|---|---|---|
| Connect | HTTP/1.1 or HTTP/2 | JSON or binary | Browser, curl, debugging, edge — anything that wants plain HTTP/JSON. |
| gRPC | HTTP/2 only | binary | Internal service-to-service traffic where binary framing wins. |
| gRPC-Web | HTTP/1.1 or HTTP/2 | binary | Browser code talking to a gRPC backend without a translation proxy. |
Caveat: gRPC-Web does not support client-streaming or full bidi streaming (HTTP/1.1 limitation). Use Connect or gRPC for those — see the integration test for proof.
# 1. Install pinned codegen plugins and the linter (one-time, idempotent).
make tools
# 2. Resolve BSR dependencies and regenerate Go code from proto.
make deps
make gen
# 3. Tests, including the matrix integration test.
make test
# 4. In one terminal:
make run-server
# 5. In another terminal:
make demo # nine-step end-to-end demo across all three protocolsRun a single CLI call:
EXAMPLE_AUTH_TOKEN=dev-token-please-change-me \
go run ./cmd/client connect greet --name=Leo --lang=pt-BRproto/elven/example/v1/greeter.proto
The schema. One service, four RPCs, protovalidate annotations.
Conforms to buf's STANDARD lint preset — every RPC owns its request and response type.
buf.yaml Buf v2 workspace: module, lint, breaking, BSR deps.
buf.gen.yaml Code-gen: managed mode + local plugins, output to gen/go.
buf.lock BSR digests (committed; regenerate with `make deps`).
cmd/server/main.go The server. Reads top to bottom: config → logger → otel
→ newHandler → http.Server with H2C → graceful shutdown.
cmd/server/integration_test.go
The proof — every RPC × every protocol against an httptest.Server.
cmd/client/main.go The star of the show. One `client.Greet(...)` call site,
three transports. Read its header comment first.
internal/greeter/ The business logic. Four RPC handlers + a salutation table.
internal/interceptors/ Recovery, logging, and the authn HTTP middleware. README inside
explains the chain order and the middleware-vs-interceptor split.
internal/otel/ Minimal OTel bootstrap: OTLP/gRPC exporters with noop fallback.
scripts/demo.sh The nine-step demo run by `make demo`.
.github/workflows/ci.yaml
Buf lint+breaking and Go lint+test+build, all SHA-pinned.
| RPC kind | Wire shape | Example |
|---|---|---|
| Unary | one request → one response | client connect greet --name=Leo |
| Server-stream | one request → many responses | client connect stream --name=Leo --count=5 |
| Client-stream | many requests → one response | client connect batch --names=Leo,Hugo,Ana |
| Bidi-stream | many in, many out, interleaved | client connect chat --names=Leo,Hugo,Ana |
The generated Go interfaces give each kind a distinct handler signature —
*connect.ServerStream[T], *connect.ClientStream[T], *connect.BidiStream[Req,Res], etc.
See internal/greeter/greeter.go for the matching method bodies, and cmd/client/main.go
for the matching client-side patterns.
This is the most important conceptual section. Connect distinguishes two wrap layers:
HTTP request
└─ authn.Middleware (transport-level auth — HTTP middleware)
└─ http.ServeMux (dispatch by path)
└─ NewGreeterServiceHandler(svc,
connect.WithInterceptors(
recovery, (outermost RPC interceptor)
logging,
otelconnect,
validate, (innermost — closest to handler)
))
└─ greeter.Service.Greet/Stream/Batch/Chat
The rationale for the order — and crucially, why authn is HTTP middleware rather than a
connect.Interceptor — is laid out in
internal/interceptors/README.md.
- Clone (or use as a GitHub template repo).
- Rename the Go module:
Update imports in
go mod edit -module github.com/your-org/your-service
cmd/,internal/, and thego_package_prefixinbuf.gen.yaml. - Replace
proto/elven/example/v1/greeter.protowith your real schema. Keep one request/response message pair per RPC to stay STANDARD-lint clean. - Replace
internal/greeterwith your real domain logic. Keep the interceptor and OTel wiring as-is — they are domain-independent. - Replace the static bearer token in
internal/interceptors/auth.gowith real auth (OIDC, mTLS, an identity service). The middleware shape stays the same; only theAuthFuncchanges. - Extend the server config in
cmd/server/main.gofor any extra env vars your service needs; the helpers (envOr,envDurationOr) are easy to copy. - Run
make gen test demoto confirm the wiring still holds together.
| Missing | Why |
|---|---|
| Dockerfile / Helm / deploy | Belongs in your real service repo. Adding it here would dilute the wiring focus. |
| Persistence (Postgres, Redis) | The greeter is in-memory on purpose. Plug your real store into internal/greeter (or a new package next to it). |
| Multi-tenant / RBAC | authn is intentionally minimal so you can see the seam between transport-auth and RPC. |
| OIDC, mTLS, real identity | Static bearer token is a stand-in — swap the AuthFunc for real validation when you copy this. |
| Metrics dashboards, SLO docs | Belong in your real service. The OTel scaffolding here makes them straightforward to add later. |
- Connect homepage: https://connectrpc.com
connectrpc/connect-go: https://github.com/connectrpc/connect-go- Buf CLI and config v2: https://buf.build/docs/configuration/v2/buf-yaml/
- protovalidate: https://protovalidate.com
- OpenTelemetry Go: https://github.com/open-telemetry/opentelemetry-go
- Connect deployment & H2C: https://connectrpc.com/docs/go/deployment/