Day 56 — gRPC or deepen REST (A)
Day 56 — gRPC or deepen REST (A)
Stage VI · ~3h · elective branch
Goal: Choose Track gRPC or Track REST-deep—implement a meaningful slice today; Day 57 continues the same branch on Go 1.26.
Pick one track for Days 56–57. Switching mid-stream wastes the elective. Record the choice in README on day one.
Why this day exists
Not every service needs gRPC. Not every REST API is “done” after CRUD. Experts choose deliberately:
- Multi-language internal mesh, streaming, strict contracts → gRPC often wins
- Public HTTP/JSON, browser clients, simple edge caching → REST/JSON often wins
Both are valid. Cargo-culting either is not. Today you implement a real slice of one track so Day 57 can polish (interceptors/streaming lite or gateway/docs polish)—not start from zero.
Theory 1 — Track selection guide
| Signal | Lean gRPC | Lean REST-deep |
|---|---|---|
| Browser first-class | ✓ | |
| Mobile/web public API | ✓ | |
| Internal service-to-service | ✓ | ✓ |
| Bi-di streaming needs | ✓ | SSE/WS (Day 59) |
| Team already JSON/HTTP | ✓ | |
| Strong schema + codegen culture | ✓ | OpenAPI |
| Load balancers / HTTP-only edge | ✓ (or grpc-gateway) | |
| Strict binary contracts + versions | ✓ | possible with OpenAPI |
Decision record (required)
## Elective track (Days 56–57)
Choice: gRPC | REST-deep
Why (3 bullets):
1.
2.
3.
Out of scope this elective:Paste into README or ELECTIVE-TRACK.md.
Track G — gRPC unary intro
Theory G1 — Stack overview
.proto
→ protoc
→ protoc-gen-go (messages)
→ protoc-gen-go-grpc (service interfaces)
→ generated stubs in module
server implements FooServer interface
client dials (transport creds) and calls RPC methods
| Piece | Role |
|---|---|
| Transport | HTTP/2 |
| Payload | Protobuf (binary) |
| Stubs | Typed Go interfaces + structs |
| Errors | google.golang.org/grpc/status + codes |
Toolchain (record versions)
protoc --version
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
which protoc-gen-go protoc-gen-go-grpcKeep plugin versions compatible with your module’s google.golang.org/grpc and protobuf requires. Pin in notes if CI must reproduce.
Theory G2 — Minimal proto
syntax = "proto3";
package items.v1;
option go_package = "example.com/day56/gen/itemsv1;itemsv1";
service ItemService {
rpc GetItem(GetItemRequest) returns (GetItemResponse);
rpc CreateItem(CreateItemRequest) returns (CreateItemResponse);
}
message GetItemRequest { string id = 1; }
message GetItemResponse {
string id = 1;
string name = 2;
}
message CreateItemRequest { string name = 1; }
message CreateItemResponse {
string id = 1;
string name = 2;
}Generate (adjust paths to your layout):
mkdir -p gen/itemsv1
protoc \
--go_out=gen --go_opt=paths=source_relative \
--go-grpc_out=gen --go-grpc_opt=paths=source_relative \
api/proto/items/v1/items.protoDocument whether generated code is committed or produced in CI—pick one policy and stick to it.
Theory G3 — Server implementation
Embed Unimplemented*Server for forward compatibility:
type Server struct {
itemsv1.UnimplementedItemServiceServer
repo ItemRepo // your Stage VI store interface
}
func (s *Server) GetItem(ctx context.Context, req *itemsv1.GetItemRequest) (*itemsv1.GetItemResponse, error) {
if req.GetId() == "" {
return nil, status.Error(codes.InvalidArgument, "id required")
}
it, err := s.repo.Get(ctx, req.GetId())
if errors.Is(err, store.ErrNotFound) {
return nil, status.Error(codes.NotFound, "item not found")
}
if err != nil {
return nil, status.Error(codes.Internal, "internal")
}
return &itemsv1.GetItemResponse{Id: it.ID, Name: it.Name}, nil
}
func (s *Server) CreateItem(ctx context.Context, req *itemsv1.CreateItemRequest) (*itemsv1.CreateItemResponse, error) {
name := strings.TrimSpace(req.GetName())
if name == "" {
return nil, status.Error(codes.InvalidArgument, "name required")
}
it, err := s.repo.Create(ctx, name)
if err != nil {
return nil, status.Error(codes.Internal, "internal")
}
return &itemsv1.CreateItemResponse{Id: it.ID, Name: it.Name}, nil
}Listen and serve
lis, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatal(err)
}
gs := grpc.NewServer()
itemsv1.RegisterItemServiceServer(gs, &Server{repo: repo})
log.Info("grpc listen", "addr", lis.Addr().String())
if err := gs.Serve(lis); err != nil {
log.Fatal(err)
}Graceful stop (wire to signal later):
go func() {
<-ctx.Done()
gs.GracefulStop()
}()Theory G4 — Status codes map
| Situation | codes.* |
|---|---|
| Bad client input | InvalidArgument |
| Missing entity | NotFound |
| Duplicate | AlreadyExists |
| No/invalid auth | Unauthenticated |
| Authenticated but forbidden | PermissionDenied |
| Unexpected server bug | Internal |
| Deadline exceeded | DeadlineExceeded |
Avoid overusing Unknown. Never panic for “not found.”
Theory G5 — Client smoke + tests
conn, err := grpc.NewClient("127.0.0.1:50051",
grpc.WithTransportCredentials(insecure.NewCredentials()))
client := itemsv1.NewItemServiceClient(conn)
_, err = client.GetItem(ctx, &itemsv1.GetItemRequest{Id: "missing"})
st, _ := status.FromError(err)
// expect codes.NotFoundManual: grpcurl -plaintext localhost:50051 list and invoke GetItem/CreateItem. Prefer bufconn in-process for unit tests; network smoke is fine for the lab.
Lab G — multi-step
- Install
protoc+ plugins; record versions in README.
- Author proto; generate stubs; document commit-vs-CI policy.
- Implement Get + Create unary against your existing repo/store.
- Map validation →
InvalidArgument, missing →NotFound.
- Smoke with
grpcurlor Go client test (go test ./... -count=1).
- Write run instructions (port, generate command, insecure local-only note).
- Plan Day 57: interceptors (logging/auth) and/or server streaming lite.
Track R — deepen REST
Theory R2 — Bulk / batch endpoints
POST /v1/items:batchGet
{"ids":["1","2","3"]}
Rules:
- Hard max (e.g. 100)
- Partial success policy documented (all-or-nothing vs per-id errors)
- Auth still required if single-get is protected
const maxBatch = 100
type batchGetReq struct {
IDs []string `json:"ids"`
}
func (h *Handler) BatchGet(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
var req batchGetReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeErr(w, errInvalid)
return
}
if len(req.IDs) == 0 || len(req.IDs) > maxBatch {
h.writeErr(w, errInvalid)
return
}
// fetch; return {"items":[...]}
}Theory R3 — Partial representation
Options:
| Approach | Example | Tradeoff |
|---|---|---|
| Query fields | ?fields=id,name |
Flexible; parser cost |
| Separate DTOs | list DTO vs detail DTO | Clear; more types |
| Sparse fieldsets later | JSON:API-style | Heavy for this volume |
For the lab, list DTO vs detail DTO is enough:
type ItemListDTO struct {
ID string `json:"id"`
Name string `json:"name"`
}
type ItemDetailDTO struct {
ID string `json:"id"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
// owner, etc.
}Theory R4 — OpenAPI sketch + Idempotency-Key
Author a honest openapi.yaml for core v1 routes (paths, schemas, bearerAuth). Generating clients is optional; authoring discipline is the skill—do not document routes that 404.
Clients retry POSTs; without idempotency keys, retries create duplicates:
key := r.Header.Get("Idempotency-Key")
if key != "" {
if resp, ok := h.idem.Get(key); ok {
writeCached(w, resp)
return
}
}
// create ... then h.idem.Put(key, status, body) // lab: in-memory + TTL noteLab R — multi-step
- Add ETag +
If-None-MatchonGET /v1/items/{id}; httptest 304.
- Add batch get with hard limit; test over-limit → 400.
- Draft
openapi.yamlfor core routes (auth scheme included).
- Idempotency-Key on create (in-memory map OK); test replay returns same body/id.
- Plan Day 57: middleware polish, docs publish, tiny client SDK snippet.
Hour-by-hour suggestion
| Time | Focus |
|---|---|
| 0:00–0:20 | Choose track; write decision record |
| 0:20–1:40 | Core implementation (proto+server or ETag+batch) |
| 1:40–2:20 | Error mapping + tests |
| 2:20–2:45 | Docs (generate policy / openapi) |
| 2:45–3:00 | Day 57 plan bullets |
Common gotchas
| Track | Gotcha | Fix |
|---|---|---|
| G | Checking in generated code inconsistently | Document generate or commit deliberately |
| G | Using panic for not found | status.Error |
| G | Insecure dial in prod notes | TLS Day 60 awareness; local-only disclaimer |
| G | Forgetting Unimplemented*Server embed |
Embed for forward compat |
| R | Unbounded batch | Max N |
| R | Weak ETag over mutable unprotected data | Auth + ownership |
| R | OpenAPI drift | Only document implemented routes |
| R | Idempotency store unbounded growth | TTL + max entries in lab notes |
| Both | New store instead of reusing | Wire existing repo |
Checkpoint
- Track chosen and documented
- End-to-end happy path works
- Error mapping tested (at least one non-happy path)
- Reuses existing persistence/auth where applicable
- Plan written for Day 57 continuation
- README run instructions cold-tested
Commit
git add .
git commit -m "day56: elective track A (grpc or rest-deep)"Write three personal gotchas before continuing.
Tomorrow
Day 57 — continue branch B: gRPC interceptors/streaming lite or REST gateway polish (middleware, docs, client SDK snippet). Same track—do not switch.