# RPC — Connect-Go (default) + grpc-go (fallback) + protovalidate `connectrpc/connect-go` is the default. It is wire-compatible with gRPC, also speaks Connect protocol + gRPC-Web from browsers, and uses ordinary `net/http` so middleware (logging, auth, tracing) composes the same way as REST. Reach for raw `grpc-go` only when you need a gRPC-specific feature Connect lacks. --- ## When Connect vs grpc-go | Need | Use | |---|---| | Standard unary + server-streaming + client-streaming | **Connect** | | Browser client without `grpc-web` proxy | **Connect** (native gRPC-Web support) | | HTTP/1.1 fallback for hostile networks | **Connect** (gRPC requires HTTP/2 end-to-end) | | Server reflection for `grpcurl` | grpc-go (Connect has reflection too, but ecosystem smaller) | | Bidirectional streaming with frame-level control | grpc-go | | Strict gRPC environment (Envoy with gRPC filters, Istio strict mode) | grpc-go | **Default**: Connect. The default has been correct since 2024. --- ## Toolchain — Buf, not protoc ```bash go install github.com/bufbuild/buf/cmd/buf@latest go install google.golang.org/protobuf/cmd/protoc-gen-go@latest go install connectrpc.com/connect/cmd/protoc-gen-connect-go@latest go install github.com/bufbuild/protovalidate/cmd/protoc-gen-go-vtproto@latest ``` Buf replaces `protoc` for everything: linting, breaking-change detection, codegen, formatting. The `protoc` toolchain is dead-letter walking — every modern proto project uses Buf. --- ## Project layout ``` proto/ buf.yaml buf.gen.yaml buf.lock myservice/v1/ user.proto auth.proto gen/ myservice/v1/ user.pb.go # protoc-gen-go output auth.pb.go myservicev1connect/ # protoc-gen-connect-go output user.connect.go auth.connect.go ``` **`gen/` is committed.** Generated code is part of the API contract; CI proves it is up-to-date. --- ## `buf.yaml` ```yaml version: v2 modules: - path: proto lint: use: - STANDARD breaking: use: - FILE ``` ## `buf.gen.yaml` ```yaml version: v2 managed: enabled: true override: - file_option: go_package_prefix value: github.com/your-org/myservice/gen plugins: - remote: buf.build/protocolbuffers/go out: gen opt: - paths=source_relative - remote: buf.build/connectrpc/go out: gen opt: - paths=source_relative - remote: buf.build/bufbuild/validate-go out: gen opt: - paths=source_relative ``` The `buf.build/...` plugin URIs use Buf's hosted remote registry — no local plugin installation needed. ## Taskfile target ```yaml gen:proto: cmds: - buf lint - buf format -w - buf generate sources: - proto/**/*.proto - buf.yaml - buf.gen.yaml ``` Run `task gen:proto` after editing any `.proto`. CI runs `buf generate` then `git diff --exit-code` to catch stale generated code. --- ## A `.proto` with validation ```proto syntax = "proto3"; package myservice.v1; import "buf/validate/validate.proto"; option go_package = "github.com/your-org/myservice/gen/myservice/v1;myservicev1"; service UserService { rpc CreateUser(CreateUserRequest) returns (CreateUserResponse); rpc GetUser(GetUserRequest) returns (GetUserResponse); rpc StreamEvents(StreamEventsRequest) returns (stream Event); } message CreateUserRequest { string email = 1 [(buf.validate.field).string.email = true]; string username = 2 [ (buf.validate.field).string.min_len = 3, (buf.validate.field).string.max_len = 32, (buf.validate.field).string.pattern = "^[a-zA-Z0-9_]+$" ]; int32 age = 3 [ (buf.validate.field).int32.gte = 13, (buf.validate.field).int32.lte = 130 ]; } message CreateUserResponse { User user = 1; } message User { string id = 1; string email = 2; string username = 3; google.protobuf.Timestamp created_at = 4; } ``` `protovalidate` replaces the abandoned `protoc-gen-validate` — it is the official Buf-backed successor as of 2024, supported by Connect's interceptor pipeline. --- ## Server — Connect ```go package main import ( "context" "log/slog" "net/http" "connectrpc.com/connect" "buf.build/go/protovalidate" validateinterceptor "connectrpc.com/validate" "golang.org/x/net/http2" "golang.org/x/net/http2/h2c" myservicev1 "github.com/your-org/myservice/gen/myservice/v1" "github.com/your-org/myservice/gen/myservice/v1/myservicev1connect" ) type UserServer struct { svc *UserService } func (s *UserServer) CreateUser( ctx context.Context, req *connect.Request[myservicev1.CreateUserRequest], ) (*connect.Response[myservicev1.CreateUserResponse], error) { // protovalidate already ran via the interceptor below. // req.Msg is guaranteed to satisfy the .proto constraints. user, err := s.svc.Create(ctx, req.Msg.Email, req.Msg.Username, req.Msg.Age) if err != nil { return nil, mapError(err) } return connect.NewResponse(&myservicev1.CreateUserResponse{ User: userToProto(user), }), nil } func main() { validator, _ := protovalidate.New() interceptors := connect.WithInterceptors( loggingInterceptor(), validateinterceptor.NewInterceptor(validator), ) mux := http.NewServeMux() mux.Handle(myservicev1connect.NewUserServiceHandler( &UserServer{svc: newUserService()}, interceptors, )) // h2c lets the server speak HTTP/2 cleartext for gRPC clients. srv := &http.Server{ Addr: ":8080", Handler: h2c.NewHandler(mux, &http2.Server{}), } slog.Info("rpc server listening", slog.String("addr", srv.Addr)) if err := srv.ListenAndServe(); err != nil { slog.Error("rpc", slog.Any("err", err)) } } ``` The handler is **just an `http.Handler`** — mount it in the same `http.ServeMux` as your REST routes if you want one binary serving both. --- ## Error mapping — Connect codes ```go func mapError(err error) error { if err == nil { return nil } switch { case errors.Is(err, domain.ErrInvalidEmail), errors.Is(err, domain.ErrInvalidUsername): return connect.NewError(connect.CodeInvalidArgument, err) case errors.Is(err, ErrNotFound): return connect.NewError(connect.CodeNotFound, err) case errors.Is(err, ErrUnauthorized): return connect.NewError(connect.CodeUnauthenticated, err) case errors.Is(err, ErrConflict): return connect.NewError(connect.CodeAlreadyExists, err) default: slog.Error("unmapped rpc error", slog.Any("err", err)) return connect.NewError(connect.CodeInternal, errors.New("internal")) } } ``` Connect codes map 1:1 to gRPC codes. Clients see canonical error semantics. --- ## Logging interceptor ```go func loggingInterceptor() connect.UnaryInterceptorFunc { return func(next connect.UnaryFunc) connect.UnaryFunc { return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { start := time.Now() res, err := next(ctx, req) attrs := []slog.Attr{ slog.String("proc", req.Spec().Procedure), slog.Duration("elapsed", time.Since(start)), } if err != nil { attrs = append(attrs, slog.Any("err", err)) slog.LogAttrs(ctx, slog.LevelWarn, "rpc failed", attrs...) } else { slog.LogAttrs(ctx, slog.LevelInfo, "rpc ok", attrs...) } return res, err } } } ``` For streaming, implement the full `connect.Interceptor` (`WrapStreamingClient`, `WrapStreamingHandler`). Pattern is identical. --- ## Server streaming ```go func (s *UserServer) StreamEvents( ctx context.Context, req *connect.Request[myservicev1.StreamEventsRequest], stream *connect.ServerStream[myservicev1.Event], ) error { events, errs := s.svc.Subscribe(ctx, req.Msg.UserId) for { select { case <-ctx.Done(): return ctx.Err() case e, ok := <-events: if !ok { return nil } if err := stream.Send(eventToProto(e)); err != nil { return err } case err := <-errs: return connect.NewError(connect.CodeInternal, err) } } } ``` Same shape as SSE in `backend-stack.md`. Connect handles HTTP/2 framing. --- ## Client ```go client := myservicev1connect.NewUserServiceClient( http.DefaultClient, "https://api.example.com", // Use connect.WithGRPC() if the server is grpc-go and you want strict gRPC framing. // Default is Connect protocol — works with Connect or gRPC servers transparently. ) res, err := client.CreateUser(ctx, connect.NewRequest(&myservicev1.CreateUserRequest{ Email: "a@b.com", Username: "alice", Age: 30, })) if err != nil { var connectErr *connect.Error if errors.As(err, &connectErr) { slog.Error("rpc failed", slog.String("code", connectErr.Code().String()), slog.String("msg", connectErr.Message())) } return err } slog.Info("created", slog.String("id", res.Msg.User.Id)) ``` --- ## When you genuinely need raw grpc-go ```go import "google.golang.org/grpc" lis, _ := net.Listen("tcp", ":8080") srv := grpc.NewServer( grpc.UnaryInterceptor(loggingUnaryInterceptor), ) myservicev1.RegisterUserServiceServer(srv, &userServer{}) _ = srv.Serve(lis) ``` The codegen is from `protoc-gen-go-grpc` (different binary from `protoc-gen-connect-go`). You can codegen **both** in the same `buf.gen.yaml` and switch by importing the right package. Most teams pick one. --- ## When NOT to use RPC at all If your callers are all browsers, mobile apps, third-party developers, or the long tail of "things humans curl": **stay with REST + OpenAPI**. RPC's overhead is justified for service-to-service inside a single org. Outside that boundary, JSON over HTTP wins on debuggability. `oapi-codegen/oapi-codegen/v2` generates Go server stubs and clients from OpenAPI 3 — the REST equivalent of what Connect does for proto. Same parse-don't-validate boundary discipline, different wire format. --- ## Sources - Connect docs: https://connectrpc.com/docs/go/getting-started - Buf: https://buf.build/docs - protovalidate: https://github.com/bufbuild/protovalidate - "Why we replaced protoc with buf" (Buf blog): https://buf.build/blog - gRPC vs Connect comparison: https://connectrpc.com/docs/introduction