Lightweight Go framework for building an API Composition / BFF layer on top of unary gRPC services.
This repository contains both the framework code and its product vision. For the current state of the implementation see the Implementation Status section.
A thin, opinionated Go layer between HTTP and unary gRPC that:
- cuts boilerplate from proxy endpoints through generics + typed callbacks (no runtime reflection, no magic strings)
- stays non-invasive: does not own DI, config, connection lifecycle, or auth
- splits concerns: compatibility is caught by
buf, observability comes from standard OTel middlewares, the framework only handles HTTP↔gRPC
Tagline: Composition owns routing and binding. Protobuf owns the wire. buf owns the compatibility. OTel middlewares own the tracing.
A microservice architecture commonly ends up with this layer:
Clients
↓
REST / BFF / API Composition
↓
Internal gRPC services
Existing approaches:
grpc-gateway
- requires REST annotations in
.proto - HTTP concerns leak into internal contracts
- hard to evolve the REST API independently
- aggregation / composition is awkward
Hand-rolled composition layer
- enormous amounts of boilerplate
- repetitive handlers / binding / error handling
- hard to keep consistent
The goal of grpc-composition is to provide a thin layer that removes the boilerplate without dragging in proto annotations or heavyweight framework infrastructure.
A flights BFF that fronts a single Statistics gRPC service — four straight proxies (path params, multi-param routes, enums) plus one aggregation that fans out two parallel gRPC calls into a single response:
conn, err := grpc.NewClient("localhost:8002",
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
log.Fatal(err)
}
defer conn.Close()
statsClient := v1.NewStatisticsClient(conn)
mux := http.NewServeMux()
// Simple proxy: single path param → request field.
mux.Handle("GET /flights/cheap/{start_city_code}", composition.Proxy(statsClient.GetCheap,
bind.PathString("start_city_code", func(req *v1.GetCheapRequest, v string) { req.StartCityCode = v }),
))
mux.Handle("GET /flights/popular/{start_city_code}", composition.Proxy(statsClient.GetPopular,
bind.PathString("start_city_code", func(req *v1.GetPopularRequest, v string) { req.StartCityCode = v }),
))
mux.Handle("GET /flights/cheap_by_airline/{airline_code}", composition.Proxy(statsClient.GetCheapByAirline,
bind.PathString("airline_code", func(req *v1.GetCheapByAirlineRequest, v string) { req.AirlineCode = v }),
))
// Multiple path params + an enum — one binder per field, in any order.
mux.Handle("GET /flights/calendar/{start_city_code}/{end_city_code}/{from_date}/{to_date}/{service_class}",
composition.Proxy(statsClient.GetCalendar,
bind.PathString("start_city_code", func(req *v1.GetCalendarRequest, v string) { req.StartCityCode = v }),
bind.PathString("end_city_code", func(req *v1.GetCalendarRequest, v string) { req.EndCityCode = v }),
bind.PathString("from_date", func(req *v1.GetCalendarRequest, v string) { req.FromDate = v }),
bind.PathString("to_date", func(req *v1.GetCalendarRequest, v string) { req.ToDate = v }),
bind.PathEnum ("service_class", func(req *v1.GetCalendarRequest, v v1.ServiceClass) { req.ServiceClass = v }),
),
)
// Aggregation: two parallel gRPC calls assembled into one HTTP response.
mux.Handle("GET /flights/cheap_and_popular/{start_city_code}", composition.Aggregate(
func(ctx context.Context, r *http.Request) (any, error) {
startCityCode := r.PathValue("start_city_code")
g, gctx := errgroup.WithContext(ctx)
var cheap *v1.GetCheapResponse
var popular *v1.GetPopularResponse
g.Go(func() error {
resp, err := statsClient.GetCheap(gctx, &v1.GetCheapRequest{StartCityCode: startCityCode})
if err != nil { return err }
cheap = resp
return nil
})
g.Go(func() error {
resp, err := statsClient.GetPopular(gctx, &v1.GetPopularRequest{StartCityCode: startCityCode})
if err != nil { return err }
popular = resp
return nil
})
if err := g.Wait(); err != nil {
return nil, err
}
return CheapAndPopular{Cheap: cheap, Popular: popular}, nil
},
))
log.Println("listening on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))Every gRPC error from statsClient.* flows through DefaultErrorMapper — NotFound → 404, InvalidArgument → 400, etc.; 5xx bodies are redacted (see Error mapping). For the cookbook on parallel / optional calls inside Aggregate, see Aggregation patterns.
- gRPC contract compatibility is an external concern. Use
buf breaking/protolock/ equivalent. The framework does not police proto compatibility. - The wire format is
protojson. It handlesoneof, well-known types (Timestamp,Duration,FieldMask), and presence semantics correctly. - Connection lifecycle is yours.
*grpc.ClientConnis created outside and passed into the generated clients. The framework does not wrap them. - Distributed tracing comes from standard middlewares.
otelhttp.NewHandler(...)on the outside +otelgrpc.UnaryClientInterceptor()on the gRPC client. The framework only propagatesr.Context()into the gRPC invocation — span propagation works on its own.
- Code-first, not config-first. Fluent Go API, not a YAML DSL.
- Type safety over conciseness. Magic strings → setter callbacks. Renaming a proto field becomes a compile error in the route.
- No per-request reflection. Binding resolves through generics and closures at startup; the runtime hot path has no descriptor walking.
- Proto-by-default. The REST shape matches the proto shape unless you explicitly opt out via
Map/ custom binders.Mapis for intentional API differentiation, not safety. - Incremental adoption. Endpoint-by-endpoint migration on top of an existing router.
r.Get("/users/{id}",
app.Proxy(userClient.GetUser,
bind.PathString("id", func(req *pb.GetUserRequest, v string) { req.Id = v }),
),
)For string fields, PathString / QueryString are infallible: assigning a string can't fail, so the setter doesn't need to return error. Use the generic bind.Path / bind.Query (with func(*Req, string) error) when you need to validate the value or parse it into a non-string field — parse errors automatically surface as HTTP 400.
r.Get("/users",
app.Proxy(userClient.ListUsers,
bind.QueryInt32("limit", func(req *pb.ListUsersRequest, v int32) { req.Limit = v }),
bind.QueryInt32("offset", func(req *pb.ListUsersRequest, v int32) { req.Offset = v }),
),
)For types outside the typed-sugar set, use generic bind.PathAs / bind.QueryAs with your own parser:
bind.PathAs("user_id", uuid.Parse, func(req *pb.Req, v uuid.UUID) {
req.UserId = v.String()
})Parse errors automatically bubble up as HTTP 400 with the parameter name as a prefix, e.g. {"error":"bind: limit: strconv.ParseInt: parsing \"oops\": invalid syntax"}.
Semantic difference: Path* treats the parameter as required (an empty value yields a 400); Query* is optional (an empty value leaves the field at zero, no error).
Available helpers:
- Path:
PathString,PathInt32,PathInt64,PathFloat64,PathBool,PathEnum,PathAs - Query:
QueryString,QueryInt32,QueryInt64,QueryFloat64,QueryBool,QueryEnum,QueryAs - Header:
HeaderString,HeaderInt32,HeaderInt64,HeaderFloat64,HeaderBool,HeaderEnum,HeaderAs(plus genericHeaderwith error)
UUID / time — through *As with a user-supplied parser.
r.Get("/users",
app.Proxy(userClient.ListUsers,
bind.QueryEnum("role", func(req *pb.ListUsersRequest, v pb.Role) { req.Role = v }),
),
)PathEnum / QueryEnum accept the canonical proto name (ROLE_ADMIN) or its numeric value (2). Matching is strict and case-sensitive to keep typo detection sharp and behavior aligned with protojson; ?role=admin returns 400. For looser semantics, build a custom parser with PathAs / QueryAs.
For enum fields in a JSON request body, no extra helper is needed — protojson.Unmarshal (used by bind.BodyJSON) already accepts both names and numbers.
The Body* family covers everything from "REST body matches the proto shape" (most common) to "REST body has a different shape than the proto" (intentional DTO layer) to "non-JSON formats" (escape hatch).
// REST body matches the proto shape — direct protojson:
bind.BodyJSON[pb.AddMemberRequest]()
// REST shape differs and mapping can fail (parsing, validation):
bind.BodyJSONInto(func(dto CreateUserDTO, req *pb.CreateUserRequest) error {
parts := strings.SplitN(dto.FullName, " ", 2)
if len(parts) < 2 {
return fmt.Errorf("full_name must contain first and last name")
}
req.GivenName, req.FamilyName, req.Email = parts[0], parts[1], dto.Email
return nil
})
// REST shape differs but mapping is a pure field-copy (no failure mode):
bind.BodyJSONMap(func(dto CreateUserDTO, req *pb.CreateUserRequest) {
req.Name = dto.FullName
req.Email = dto.Email
})
// Non-JSON formats (YAML, raw protobuf wire, form-encoded, ...):
bind.Body(func(data []byte, req *pb.Req) error {
return yaml.Unmarshal(data, req)
})| Helper | When |
|---|---|
bind.BodyJSON[Req]() |
REST body shape matches proto shape; uses protojson (handles oneof, well-known types, presence correctly) |
bind.BodyJSONInto[Req, DTO](apply ... error) |
REST shape differs; mapping can fail |
bind.BodyJSONMap[Req, DTO](apply) |
REST shape differs; mapping is infallible field-copy |
bind.Body[Req](parse) |
Not JSON; user-supplied parser handles everything |
Combine with other binders in one route — binders run in order:
r.Post("/orgs/{org_id}/members",
app.Proxy(orgClient.AddMember,
bind.BodyJSON[pb.AddMemberRequest](),
bind.PathString("org_id", func(req *pb.AddMemberRequest, v string) { req.OrgId = v }),
),
)If any binder returns an error, the gRPC call does not happen and the client receives HTTP 400 with the message.
app.Proxy(userClient.GetUser, bindGetUser).
Map(func(resp *pb.GetUserResponse) any {
return UserDTO{ID: resp.User.Id, DisplayName: resp.User.FullName}
})Without Map — straight protojson (proto-by-default). With Map — your shape, your responsibility.
app.Proxy(userClient.CreateUser, bindCreate).
OnSuccess(http.StatusCreated)When an endpoint needs to call multiple gRPC services and assemble a combined response, Proxy does not fit (there is no one-to-one HTTP↔gRPC mapping). Aggregate wraps a custom handler with the same RFC 7807 error mapping and response serialization that Proxy uses:
mux.Handle("GET /feed/{user_id}", composition.Aggregate(
func(ctx context.Context, r *http.Request) (any, error) {
uid := r.PathValue("user_id")
g, gctx := errgroup.WithContext(ctx)
var user *pb.User
var posts *pb.ListPostsResponse
g.Go(func() error {
u, err := users.GetUser(gctx, &pb.GetUserRequest{Id: uid})
if err != nil { return err }
user = u
return nil
})
g.Go(func() error {
p, err := postClient.ListPosts(gctx, &pb.ListPostsRequest{UserId: uid})
if err != nil { return err }
posts = p
return nil
})
if err := g.Wait(); err != nil {
return nil, err
}
return FeedResponse{User: user, Posts: posts.Posts}, nil
},
).OnSuccess(http.StatusOK))The handler's error path flows through DefaultErrorMapper — same RFC 7807 / 5xx redaction / per-route override (.WithErrorMapper(fn)) story as Proxy. On success, the returned value is serialized with protojson if it implements proto.Message, otherwise encoding/json.
No parallelism primitives are shipped. errgroup (stdlib), sourcegraph/conc, and samber/ro (ReactiveX) all cover that space well; pick what fits your codebase.
// App carries cross-cutting concerns (currently HTTP→gRPC metadata
// forwarding). Wrap the router with app.Handler to apply them globally.
app := composition.New(
composition.WithMetadataForward("authorization", "x-request-id", "accept-language"),
)
// Optional: customize the package-level error mapper (defaults to RFC 7807).
// Per-route override is also available: route.WithErrorMapper(fn).
composition.SetDefaultErrorMapper(myMapper)
r := http.NewServeMux()
userConn, _ := grpc.NewClient(addr,
grpc.WithStatsHandler(otelgrpc.NewClientHandler()),
)
userClient := pb.NewUserServiceClient(userConn)
r.Handle("GET /users/{id}", composition.Proxy(userClient.GetUser,
bind.PathString("id", func(req *pb.GetUserRequest, v string) { req.Id = v }),
))
// Wrap with app.Handler so the configured headers flow into outgoing
// gRPC metadata; combine with otelhttp on the outside for tracing.
handler := otelhttp.NewMiddleware("api")(app.Handler(r))
http.ListenAndServe(":8080", handler)composition.WithMetadataForward(headers...) declares an explicit allowlist of HTTP request headers to forward into outgoing gRPC metadata. There is no wildcard — listing what flows downstream is the safe default.
app := composition.New(
composition.WithMetadataForward("Authorization", "X-Request-ID"),
)
handler := app.Handler(mux)Header names are matched case-insensitively. Multi-value headers preserve all values. The forwarded headers reach any gRPC method called via Proxy because the framework hands r.Context() to the gRPC client, and app.Handler injected outgoing metadata into that context.
Use this for auth tokens, request-id correlation, locale (Accept-Language), tenant ids, etc.
Default gRPC Status → HTTP:
NotFound -> 404
InvalidArgument -> 400
AlreadyExists -> 409
PermissionDenied -> 403
Unauthenticated -> 401
FailedPrecondition -> 422
ResourceExhausted -> 429
DeadlineExceeded -> 504
Unavailable -> 503
Aborted -> 409
Canceled -> 499
Internal/Unknown -> 500
The default error body follows RFC 7807 with two gRPC-aware extensions, and the response is served as application/problem+json:
{
"type": "billing.example.com/RATE_LIMIT_EXCEEDED",
"title": "Too Many Requests",
"status": 429,
"detail": "too many requests",
"reason": "RATE_LIMIT_EXCEEDED",
"metadata": { "retry_after": "60" },
"errors": [
{ "field": "user.email", "description": "must be a valid email" }
]
}errors[]is populated fromgoogle.rpc.BadRequest.FieldViolationsattached to the gRPC status viastatus.WithDetails. This is whatprotovalidate-goproduces — field-level validation failures appear here.reason,type,metadataare populated fromgoogle.rpc.ErrorInfo.
5xx responses do not propagate the upstream gRPC status message or details — they return a generic "detail": "internal error" so server-side state never leaks to clients. Pair with a request-id middleware for log correlation.
- Per route:
route.WithErrorMapper(fn)overrides for one endpoint - Globally:
composition.SetDefaultErrorMapper(fn)replaces the default mapper at program start
A custom mapper can return any body shape; only composition.ProblemDetails triggers the application/problem+json Content-Type. Other shapes get application/json.
Aggregate plus stdlib golang.org/x/sync/errgroup covers the vast majority of "call N gRPC services and assemble a response" scenarios. This section is the recommended cookbook.
mux.Handle("GET /feed/{user_id}", composition.Aggregate(
func(ctx context.Context, r *http.Request) (any, error) {
uid := r.PathValue("user_id")
g, gctx := errgroup.WithContext(ctx)
var user *pb.User
var posts *pb.ListPostsResponse
g.Go(func() error {
u, err := userClient.GetUser(gctx, &pb.GetUserRequest{Id: uid})
if err != nil { return err }
user = u
return nil
})
g.Go(func() error {
p, err := postClient.ListPosts(gctx, &pb.ListPostsRequest{UserId: uid})
if err != nil { return err }
posts = p
return nil
})
if err := g.Wait(); err != nil {
return nil, err
}
return FeedResponse{User: user, Posts: posts.Posts}, nil
},
))Use gctx inside the goroutines, not ctx. errgroup.WithContext(ctx) returns a child context that is cancelled the moment any goroutine returns a non-nil error. Passing gctx into the gRPC call lets the sibling call see context.Canceled immediately when one of them fails — no wasted network work. If you accidentally use ctx, sibling calls run to completion even after the first failure.
When a call is not critical to the response — e.g. user preferences with sane defaults — swallow its error inside the goroutine and leave the destination at nil:
var user *pb.User
var prefs *pb.UserPrefs // optional — may be nil after Wait()
g.Go(func() error {
u, err := userClient.GetUser(gctx, &pb.GetUserRequest{Id: uid})
if err != nil { return err }
user = u
return nil
})
g.Go(func() error {
p, err := prefClient.GetPrefs(gctx, &pb.GetPrefsRequest{UserId: uid})
if err != nil {
log.Printf("prefs unavailable: %v", err) // optional: still log it
return nil // partial failure is OK here
}
prefs = p
return nil
})
if err := g.Wait(); err != nil {
return nil, err
}
if prefs == nil {
prefs = defaultPrefs()
}Returning nil from a g.Go closure is the "optional" semantic — no framework helper would make this clearer.
Same pattern, just more goroutines. The final shape is assembled after g.Wait():
var user *pb.User
var posts *pb.ListPostsResponse
var prefs *pb.UserPrefs
g.Go(/* user */)
g.Go(/* posts */)
g.Go(/* prefs */)
if err := g.Wait(); err != nil {
return nil, err
}
return FeedResponse{
User: toUserDTO(user),
Posts: toPostsDTO(posts.Posts),
Prefs: toPrefsDTO(prefs),
}, nilIf you need to return a proto.Message (instead of a custom DTO), do so — Aggregate detects it and serializes via protojson automatically.
| Mistake | Symptom | Fix |
|---|---|---|
Using ctx instead of gctx inside goroutines |
Sibling gRPC calls don't cancel on first failure | Pass gctx everywhere inside g.Go |
| Sharing a map / slice without lock across goroutines | Race detected by go test -race; intermittent panics |
One variable per call (as shown above), or guard with sync.Mutex |
Forgetting to check g.Wait() error |
Aggregation may "succeed" with partial data | Always if err := g.Wait(); err != nil { return nil, err } first |
| Setting destination and returning the error | Caller sees stale data when sibling failed first | Check err first, set destination only on success |
Using a chan T instead of variables |
Easy to deadlock / leak goroutines | Variables + indices are simpler for N ≤ ~10 calls |
A composition.Parallel / Call / .Optional() helper would save ~3 lines per call. The trade-off is too lopsided:
- One more API to learn, on top of
golang.org/x/sync/errgroupwhich every Go developer already knows. - Cancellation propagation still has to be modeled — wrapping
errgrouponly obscures where it comes from. - Heterogeneous return types force either pointer-to-pointer (
Call(&dst, ...)) or losing type safety withany. Closures-over-typed-variables stay clean.
For codebases that prefer different concurrency primitives, both work as-is inside Aggregate:
sourcegraph/conc— structured concurrency with panic safety; nearly identical API toerrgroupbut cleaner cancellation defaults.samber/ro— ReactiveX-style streams;Future + CombineLatestN + ro.Collect. Pays off for actual stream processing (continuous events, filters, throttling) — for one-shot parallel calls the lazy-Observable ceremony tends to outweigh the declarative win.
The framework is neutral. Pick the one that already fits your stack.
Legend: ✅ Done · 📋 Next · ⏳ Planned · ❌ Out of scope
| Functional Requirement | Status |
|---|---|
Proxy[Req, Resp] via generics, type-safe binding |
✅ Done |
bind.Path (via r.PathValue, stdlib 1.22+) |
✅ Done |
bind.Query |
✅ Done |
bind.BodyJSON (protojson + generic proto.Message constraint) — renamed from bind.JSON in v0.2 |
✅ Done |
Response mapping Map(func(*Resp) any) |
✅ Done |
HTTP success status override OnSuccess(int) |
✅ Done |
gRPC Status → HTTP code mapping + 5xx redaction |
✅ Done |
protojson serialization for proto.Message responses |
✅ Done |
chi compatibility via SetDefaultPathExtractor |
✅ Done |
Runnable example in examples/basic/ (real .proto + bufconn) |
✅ Done |
| Functional Requirement | Status |
|---|---|
Typed sugar binders (PathString, PathInt32, PathInt64, PathBool, PathEnum, PathAs, QueryString, QueryInt32, QueryInt64, QueryBool, QueryEnum, QueryAs) |
✅ Done |
Body family rename + DTO variants (bind.BodyJSON, bind.BodyJSONInto, bind.BodyJSONMap, bind.Body); renames bind.JSON → bind.BodyJSON (breaking) |
✅ Done |
bind.Header family (Header, HeaderString, HeaderInt32, HeaderInt64, HeaderFloat64, HeaderBool, HeaderAs) |
✅ Done |
Metadata forwarding (HTTP header → gRPC metadata, allowlist) via App.Handler |
✅ Done |
RFC 7807 error details (BadRequest field violations, ErrorInfo) + application/problem+json |
✅ Done |
WithErrorMapper per-route + SetDefaultErrorMapper package-level |
✅ Done |
Additional typed sugar for Float64 (Path/Query/Header) |
✅ Done |
Validation: use
protovalidate-goas a gRPC unary client interceptor. Violations arrive ascodes.InvalidArgumentwithstatus.WithDetails(*errdetails.BadRequest)and surface as HTTP 400 field errors via RFC 7807 error details (see above). No framework-level hook is needed.
| Functional Requirement | Status |
|---|---|
Aggregate(func) for custom handlers with framework error mapping & serialization |
✅ Done |
HeaderEnum typed binder (promoted from v0.4+) |
✅ Done |
Notes:
- Parallelism is deliberately not framework concern.
errgroup(stdlib),sourcegraph/conc, andsamber/ro(ReactiveXFuture+CombineLatestN) all cover that space well — they live one import away and the framework would only add boilerplate-shifted-elsewhere. See the Aggregation patterns cookbook for the recommendederrgrouprecipe.
| Functional Requirement | Status |
|---|---|
| OpenAPI generation from binder metadata | ⏳ Planned |
| gin / echo adapters | ⏳ Planned |
| Optional codegen for setters (if ergonomics turn out to be a real pain point) | ⏳ Planned |
Multipart / file upload (bind.Multipart) |
⏳ Planned |
Binder metadata refactor (struct with PathParam / QueryParam fields) + Mount helper that validates declared path params against the route pattern at startup, catching binder/pattern mismatches at boot rather than first request |
⏳ Planned |
Typed sugar for UUID, time.Time (need external dep + format-choice decisions) |
⏳ Planned |
| Feature | Where to look instead |
|---|---|
| ❌ Streaming / SSE / WebSockets | Connect-Go or a hand-rolled handler |
| ❌ Retries | gRPC client interceptors |
| ❌ Circuit breakers | gRPC interceptors / sony/gobreaker |
| ❌ Auth framework | HTTP middleware in front of Proxy |
| ❌ Caching | HTTP middleware |
| ❌ Field-level REST exposure (guard against leaking new proto fields) | Separate lint tool, not core |
HTTP Request
↓
Binders (typed closures, generic over Req)
↓
gRPC unary invocation (with r.Context() — span propagation via otelgrpc)
↓
Error mapper (Status → HTTP code) | Response mapper (optional Map)
↓
protojson marshal
↓
HTTP Response
All decisions resolve at route construction time; the runtime hot path runs only closures and standard-library calls.
/composition // App, Proxy, Route
/composition/bind // Path, Query, JSON
/composition/errors // default mapper, code → status table
/composition/chi // chi-specific adapter
Code size for v0.1 — on the order of 400–600 lines.
- Simple proxy endpoint — 4–6 lines, type-safe.
- Endpoint with response mapping — 8–10 lines.
- Renaming a proto field → compile error in the route, not at runtime.
- Tracing:
otelhttp.NewMiddleware(...)on the outside +otelgrpcon the client → working distributed tracing with no framework-specific config. - Coverage: CRUD on a single service (GET by id, list with query, POST, PUT, DELETE).
- Documentation: no more than 20 minutes from first endpoint to a running proxy.
- Setter verbosity — if this turns out to be a real pain point in practice, consider optional codegen from the proto descriptor (not runtime reflection).
Query / header type conversion sugar— resolved in v0.2: shippedPathInt32/64,PathBool,QueryInt32/64,QueryBoolplus genericPathAs/QueryAs. UUID / time / float — separate mini-increment.- Partial-response semantics in
Aggregate— per-call.Optional()or policy-based (MinSuccessful(N))? - Multipart / file upload — direction:
bind.Multipartstreaming into abytesfield of the proto. - Field-level REST exposure (protection against accidentally leaking newly-added internal proto fields into a public REST API) — separate lint/test tool, not core. Is it needed at all?
- Response-driven response headers —
LocationforPOST → 201,ETagfor conditional requests,X-Total-Countfor pagination, etc. A generic helper that derives outgoing HTTP headers from the gRPC response (func(*Resp) http.Header) would cover all of these in one shot. Worth a dedicated builder, or leave it toMap+ a custom outer handler?
Apache License 2.0 — see LICENSE.