Stage 7 · Master
Phase 5 — API Gateway
Request Routing
Retire reverse-proxy's linear prefix scan for Gin route groups, introduce /v1 versioning at the gateway boundary, and give path rewriting a clear rule so backend services never have to know they're behind a gateway.
A Linear Scan Over Routes Is Fine for Three Entries, Fragile for Thirty
reverse-proxy's New function walks every configured Route in order and forwards to the first prefix match. With three services that is harmless, but it hides an ordering bug: /api/v1/users and a later, more specific path can both match. Gin's radix tree and explicit route groups resolve registered patterns structurally, while a group-level wildcard forwards all remaining path segments to one upstream without a hand-maintained prefix loop.
package proxy
import (
"net/http"
"net/url"
"strings"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog"
)
// BuildRouter mounts both the group root and its wildcard because Gin treats
// "/api/v1/auth" and "/api/v1/auth/*path" as distinct route patterns.
func BuildRouter(r *gin.Engine, authURL, userURL *url.URL, logger zerolog.Logger) {
mount := func(prefix string, target *url.URL) {
group := r.Group(prefix)
handler := gin.WrapH(newReverseProxy(target, logger))
group.Any("", handler)
group.Any("/*path", handler)
}
mount("/api/v1/auth", authURL)
mount("/api/v1/users", userURL)
mount("/api/v1/memberships", userURL)
}
// UpstreamRoute gives gateway middleware the same ownership view used by the
// route tree without exposing target URLs or reverse-proxy internals.
func UpstreamRoute(r *http.Request) string {
switch {
case r.URL.Path == "/api/v1/auth" || strings.HasPrefix(r.URL.Path, "/api/v1/auth/"):
return "auth-service"
case r.URL.Path == "/api/v1/users" || strings.HasPrefix(r.URL.Path, "/api/v1/users/"),
r.URL.Path == "/api/v1/memberships" || strings.HasPrefix(r.URL.Path, "/api/v1/memberships/"):
return "user-service"
default:
return "gateway"
}
}gin.WrapH adapts the standard-library ReverseProxy without replacing Gin as the routing framework. Registering the group root separately prevents a request to exactly /api/v1/auth from missing the wildcard route.
Why /v1 Belongs at the Gateway, Not Duplicated in Every Service
Every backend service's own router (established since user-service's bootstrap in the User module) already mounts its handlers under /api/v1/... internally. The gateway's job is not to invent a second, independent versioning scheme — it forwards the version prefix through unchanged, but centralizes the decision of which version is even exposed publicly. When a hypothetical /v2 of user-service's API is introduced later, the gateway is the single place that decides whether v1 clients still get routed anywhere at all, without every backend service needing gateway-awareness of its own.
This gateway's Director deliberately does not rewrite paths — /api/v1/users/{id} arrives at user-service as exactly /api/v1/users/{id}. Path rewriting is reserved for the rare case of fronting a legacy or third-party backend whose own path scheme cannot be changed; introducing rewriting by default would mean every backend service's own logs and route definitions stop matching what the gateway's logs show, a debugging tax paid on every single request for a feature most routes never need.
REST at the Public Edge, gRPC for an Internal Identity Read
Three services now exist, and auth-service needs one fact owned by user-service: the active user and membership behind a login identifier. Sending that call back through the public gateway would add an unnecessary hop and couple internal code to a public JSON representation. This is the first point where a typed internal RPC contract pays for itself, so gRPC is introduced here—not during project setup. Public clients still use REST through the gateway; service-to-service identity lookup uses the cluster network directly.
version: v2
modules:
- path: api/proto
lint:
use:
- STANDARD
breaking:
use:
- FILEversion: v2
plugins:
- local: protoc-gen-go
out: gen
opt:
- paths=source_relative
- local: protoc-gen-go-grpc
out: gen
opt:
- paths=source_relativesource_relative produces gen/identity/v1 from api/proto/identity/v1. Both services import the same generated package; neither copies generated types into its own internal tree.
syntax = "proto3";
package identity.v1;
option go_package = "github.com/hoa-platform/backend/gen/identity/v1;identityv1";
service IdentityDirectory {
rpc GetLoginIdentity(GetLoginIdentityRequest) returns (GetLoginIdentityResponse);
}
message GetLoginIdentityRequest {
string organization_id = 1;
string email = 2;
}
message GetLoginIdentityResponse {
string user_id = 1;
string role = 2;
bool active = 3;
}The organization id is part of the request contract rather than ambient metadata, making a tenant-less lookup impossible to express. No password hash crosses this boundary: auth-service resolves the returned user id against its own credential database, whose ownership was established in Phase 4.
func (s *IdentityServer) GetLoginIdentity(
ctx context.Context,
req *identityv1.GetLoginIdentityRequest,
) (*identityv1.GetLoginIdentityResponse, error) {
orgID, err := uuid.Parse(req.GetOrganizationId())
if err != nil || req.GetEmail() == "" {
return nil, status.Error(codes.InvalidArgument, "organization_id and email are required")
}
identity, err := s.directory.FindLoginIdentity(ctx, orgID, req.GetEmail())
if errors.Is(err, domain.ErrUserNotFound) {
return nil, status.Error(codes.NotFound, "identity not found")
}
if err != nil {
return nil, status.Error(codes.Internal, "identity lookup failed")
}
return &identityv1.GetLoginIdentityResponse{
UserId: identity.UserID.String(), Role: identity.Role, Active: identity.Active,
}, nil
}The transport maps domain errors to gRPC status codes just as REST handlers map them to HTTP statuses. user-service returns directory and membership facts only; auth-service remains the sole reader and writer of password hashes.
| Traffic | Protocol | Why |
|---|---|---|
| Browser/mobile → gateway | REST/JSON | Stable public resource API, easy client interoperability |
| auth-service → user-service | gRPC/Protobuf | Typed internal contract, generated client, deadline propagation, no public gateway hop |
| Events introduced later | Kafka | Asynchronous facts where the producer must not wait for a consumer |
+grpcListener, err := net.Listen("tcp", ":9082")
+if err != nil {
+ logger.Error().Err(err).Msg("gRPC listen failed")
+ os.Exit(1)
+}
+grpcServer := grpc.NewServer()
+identityv1.RegisterIdentityDirectoryServer(
+ grpcServer,
+ identitygrpc.NewIdentityServer(identityDirectory),
+)
+
+group.Go(func() error { return grpcServer.Serve(grpcListener) })
+group.Go(func() error {
+ <-ctx.Done()
+ grpcServer.GracefulStop()
+ return nil
+})
// Existing HTTP server and signal-aware shutdown remain in the same group.The RPC listener is internal and shares the process lifecycle: a signal stops both HTTP and gRPC, while an unexpected Serve failure cancels the group instead of leaving half a service running.
Adding a Fourth Service Without Touching Existing Routes
BuildRouter's structure is intentionally boring: each backend gets exactly one r.Route block. A future organization-service or notification-service (referenced in later platform phases) is added by appending one more block — no existing route's behavior changes, and no shared prefix-matching logic needs re-auditing for the new entry to be correct.
Confirming Route Specificity Resolves Correctly
Applied exercise
Design the gateway's behavior for an unreleased /v2 endpoint
user-service begins exposing a handful of /api/v2/users endpoints internally, ahead of a public v2 launch, while the gateway currently only routes /api/v1/*.
- State whether v2 endpoints should be automatically reachable through the gateway once they exist on the backend, or must be explicitly added to BuildRouter.
- Justify your answer by referencing the same 'explicit registration, not an accidental default' principle from the reverse-proxy lesson's routing-table discussion.
- Describe one operational risk of accidentally exposing an unreleased v2 API publicly before its intended launch date.
Deliverable
A short written decision requiring explicit route registration for any newly exposed API version.
Completion checks
- The decision explicitly rejects automatic exposure of new backend routes without a corresponding gateway change.
- The operational risk described is concrete (e.g., an unfinished or unauthenticated v2 endpoint becoming publicly reachable), not generic.
Why does replacing the linear prefix-scan loop with Gin route groups remove the original ordering bug?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.