From 2181e67e796deecee37924d5f933b24cd8bb9da6 Mon Sep 17 00:00:00 2001 From: Hasnae Date: Fri, 6 Mar 2026 21:49:16 +1100 Subject: [PATCH 01/25] Add Connect RPC (Go) benchmark implementation for content-api Implements all ContentService RPCs using connectrpc.com/connect with in-memory storage, h2c for gRPC compatibility, and a multi-stage Dockerfile with buf-based proto code generation. Co-Authored-By: Claude Opus 4.6 --- benchmark.config.json | 32 +++- projects/content-api/connect-rpc/Dockerfile | 32 ++++ projects/content-api/connect-rpc/buf.gen.yaml | 12 ++ .../connect-rpc/cmd/server/main.go | 42 +++++ .../connect-rpc/docker-compose.yml | 17 ++ projects/content-api/connect-rpc/go.mod | 12 ++ projects/content-api/connect-rpc/go.sum | 5 + .../connect-rpc/internal/service/content.go | 150 ++++++++++++++++++ 8 files changed, 301 insertions(+), 1 deletion(-) create mode 100644 projects/content-api/connect-rpc/Dockerfile create mode 100644 projects/content-api/connect-rpc/buf.gen.yaml create mode 100644 projects/content-api/connect-rpc/cmd/server/main.go create mode 100644 projects/content-api/connect-rpc/docker-compose.yml create mode 100644 projects/content-api/connect-rpc/go.mod create mode 100644 projects/content-api/connect-rpc/go.sum create mode 100644 projects/content-api/connect-rpc/internal/service/content.go diff --git a/benchmark.config.json b/benchmark.config.json index b843820..72aa3db 100644 --- a/benchmark.config.json +++ b/benchmark.config.json @@ -1,5 +1,35 @@ { - "targets": {}, + "targets": { + "content-api/connect-rpc": { + "path": "./projects/content-api/connect-rpc", + "composeFile": "docker-compose.yml", + "service": "api", + "port": 50051, + "protocol": "grpc", + "readinessProbe": { + "httpGet": { + "path": "/health", + "port": 8080, + "expectedStatus": 200 + } + }, + "k6": { + "script": "./projects/content-api/_shared/protobuf/k6/content-api.js", + "vus": 50, + "duration": "30s", + "env": { + "GRPC_HOST": "localhost:50051", + "PROTO_PATH": "./projects/content-api/_shared/protobuf/content.proto" + } + }, + "tags": { + "project": "content-api", + "language": "go", + "framework": "connect-rpc", + "api-style": "protobuf" + } + } + }, "defaults": { "k6": { "vus": 50, diff --git a/projects/content-api/connect-rpc/Dockerfile b/projects/content-api/connect-rpc/Dockerfile new file mode 100644 index 0000000..681b976 --- /dev/null +++ b/projects/content-api/connect-rpc/Dockerfile @@ -0,0 +1,32 @@ +# Stage 1: Generate protobuf + Connect RPC code +FROM golang:1.23-alpine AS generate + +RUN apk add --no-cache git +RUN go install github.com/bufbuild/buf/cmd/buf@latest +RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@latest +RUN go install connectrpc.com/connect/cmd/protoc-gen-connect-go@latest + +WORKDIR /build +COPY connect-rpc/buf.gen.yaml ./ +RUN mkdir -p proto/content/v1 +COPY _shared/protobuf/content.proto proto/content/v1/content.proto +RUN echo 'version: v1' > proto/buf.yaml +RUN buf generate proto + +# Stage 2: Build Go binary +FROM golang:1.23-alpine AS builder + +WORKDIR /app +COPY connect-rpc/ . +COPY --from=generate /build/gen ./gen +RUN go mod tidy +RUN CGO_ENABLED=0 go build -o /server ./cmd/server + +# Stage 3: Minimal runtime +FROM alpine:3.20 + +RUN apk add --no-cache curl +COPY --from=builder /server /server + +EXPOSE 8080 50051 +CMD ["/server"] diff --git a/projects/content-api/connect-rpc/buf.gen.yaml b/projects/content-api/connect-rpc/buf.gen.yaml new file mode 100644 index 0000000..6321bb2 --- /dev/null +++ b/projects/content-api/connect-rpc/buf.gen.yaml @@ -0,0 +1,12 @@ +version: v1 +managed: + enabled: true + go_package_prefix: + default: content-api-connect-rpc/gen +plugins: + - name: go + out: gen + opt: paths=source_relative + - name: connect-go + out: gen + opt: paths=source_relative diff --git a/projects/content-api/connect-rpc/cmd/server/main.go b/projects/content-api/connect-rpc/cmd/server/main.go new file mode 100644 index 0000000..ab7ea5c --- /dev/null +++ b/projects/content-api/connect-rpc/cmd/server/main.go @@ -0,0 +1,42 @@ +package main + +import ( + "log" + "net/http" + + "golang.org/x/net/http2" + "golang.org/x/net/http2/h2c" + + "content-api-connect-rpc/gen/content/v1/contentv1connect" + "content-api-connect-rpc/internal/service" +) + +func main() { + srv := service.NewContentServer() + + // gRPC / Connect handler on port 50051 + grpcMux := http.NewServeMux() + path, handler := contentv1connect.NewContentServiceHandler(srv) + grpcMux.Handle(path, handler) + + // Health check on port 8080 + healthMux := http.NewServeMux() + healthMux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":"up"}`)) + }) + + go func() { + log.Println("Health check listening on :8080") + if err := http.ListenAndServe(":8080", healthMux); err != nil { + log.Fatal(err) + } + }() + + grpcServer := &http.Server{ + Addr: ":50051", + Handler: h2c.NewHandler(grpcMux, &http2.Server{}), + } + log.Println("gRPC server listening on :50051") + log.Fatal(grpcServer.ListenAndServe()) +} diff --git a/projects/content-api/connect-rpc/docker-compose.yml b/projects/content-api/connect-rpc/docker-compose.yml new file mode 100644 index 0000000..630f4ce --- /dev/null +++ b/projects/content-api/connect-rpc/docker-compose.yml @@ -0,0 +1,17 @@ +services: + api: + build: + context: .. + dockerfile: connect-rpc/Dockerfile + ports: + - "8080:8080" + - "50051:50051" + environment: + - HEALTH_PORT=8080 + - GRPC_PORT=50051 + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 10s diff --git a/projects/content-api/connect-rpc/go.mod b/projects/content-api/connect-rpc/go.mod new file mode 100644 index 0000000..c59789d --- /dev/null +++ b/projects/content-api/connect-rpc/go.mod @@ -0,0 +1,12 @@ +module content-api-connect-rpc + +go 1.23.0 + +require ( + connectrpc.com/connect v1.16.2 + github.com/google/uuid v1.6.0 + golang.org/x/net v0.25.0 + google.golang.org/protobuf v1.34.1 +) + +require golang.org/x/text v0.15.0 // indirect diff --git a/projects/content-api/connect-rpc/go.sum b/projects/content-api/connect-rpc/go.sum new file mode 100644 index 0000000..6bb7755 --- /dev/null +++ b/projects/content-api/connect-rpc/go.sum @@ -0,0 +1,5 @@ +connectrpc.com/connect v1.16.2/go.mod h1:n2kgwskMHXC+lVqb18wngEpF95ldBHXjZYJussz5FRc= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= diff --git a/projects/content-api/connect-rpc/internal/service/content.go b/projects/content-api/connect-rpc/internal/service/content.go new file mode 100644 index 0000000..f0e7110 --- /dev/null +++ b/projects/content-api/connect-rpc/internal/service/content.go @@ -0,0 +1,150 @@ +package service + +import ( + "context" + "fmt" + "sort" + "sync" + "time" + + "connectrpc.com/connect" + "github.com/google/uuid" + "google.golang.org/protobuf/proto" + + contentv1 "content-api-connect-rpc/gen/content/v1" +) + +type ContentServer struct { + mu sync.RWMutex + store map[string]*contentv1.Content +} + +func NewContentServer() *ContentServer { + return &ContentServer{ + store: make(map[string]*contentv1.Content), + } +} + +func (s *ContentServer) CheckHealth( + _ context.Context, + _ *connect.Request[contentv1.HealthRequest], +) (*connect.Response[contentv1.HealthResponse], error) { + return connect.NewResponse(&contentv1.HealthResponse{ + Status: "up", + }), nil +} + +func (s *ContentServer) ListContent( + _ context.Context, + req *connect.Request[contentv1.ListContentRequest], +) (*connect.Response[contentv1.ListContentResponse], error) { + s.mu.RLock() + defer s.mu.RUnlock() + + all := make([]*contentv1.Content, 0, len(s.store)) + for _, item := range s.store { + all = append(all, proto.Clone(item).(*contentv1.Content)) + } + sort.Slice(all, func(i, j int) bool { + return all[i].CreatedAt < all[j].CreatedAt + }) + + total := int32(len(all)) + limit := req.Msg.Limit + offset := req.Msg.Offset + if limit <= 0 { + limit = 10 + } + start := int(offset) + if start > len(all) { + start = len(all) + } + end := start + int(limit) + if end > len(all) { + end = len(all) + } + + return connect.NewResponse(&contentv1.ListContentResponse{ + Items: all[start:end], + Total: total, + Limit: limit, + Offset: offset, + }), nil +} + +func (s *ContentServer) GetContent( + _ context.Context, + req *connect.Request[contentv1.GetContentRequest], +) (*connect.Response[contentv1.Content], error) { + s.mu.RLock() + defer s.mu.RUnlock() + + item, ok := s.store[req.Msg.Id] + if !ok { + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("content %s not found", req.Msg.Id)) + } + return connect.NewResponse(proto.Clone(item).(*contentv1.Content)), nil +} + +func (s *ContentServer) CreateContent( + _ context.Context, + req *connect.Request[contentv1.CreateContentRequest], +) (*connect.Response[contentv1.Content], error) { + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now().UTC().Format(time.RFC3339) + item := &contentv1.Content{ + Id: uuid.New().String(), + Title: req.Msg.Title, + Body: req.Msg.Body, + Status: req.Msg.Status, + CreatedAt: now, + UpdatedAt: now, + } + s.store[item.Id] = item + return connect.NewResponse(proto.Clone(item).(*contentv1.Content)), nil +} + +func (s *ContentServer) UpdateContent( + _ context.Context, + req *connect.Request[contentv1.UpdateContentRequest], +) (*connect.Response[contentv1.Content], error) { + s.mu.Lock() + defer s.mu.Unlock() + + item, ok := s.store[req.Msg.Id] + if !ok { + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("content %s not found", req.Msg.Id)) + } + + if req.Msg.Title != nil { + item.Title = *req.Msg.Title + } + if req.Msg.Body != nil { + item.Body = *req.Msg.Body + } + if req.Msg.Status != nil { + item.Status = *req.Msg.Status + } + item.UpdatedAt = time.Now().UTC().Format(time.RFC3339) + + return connect.NewResponse(proto.Clone(item).(*contentv1.Content)), nil +} + +func (s *ContentServer) DeleteContent( + _ context.Context, + req *connect.Request[contentv1.DeleteContentRequest], +) (*connect.Response[contentv1.DeleteContentResponse], error) { + s.mu.Lock() + defer s.mu.Unlock() + + if _, ok := s.store[req.Msg.Id]; !ok { + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("content %s not found", req.Msg.Id)) + } + delete(s.store, req.Msg.Id) + + return connect.NewResponse(&contentv1.DeleteContentResponse{ + Success: true, + }), nil +} From 78c423cbeaae3165e40493455612bddb457c7f27 Mon Sep 17 00:00:00 2001 From: Hasnae Date: Fri, 6 Mar 2026 21:53:44 +1100 Subject: [PATCH 02/25] update golang version --- projects/content-api/connect-rpc/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/projects/content-api/connect-rpc/Dockerfile b/projects/content-api/connect-rpc/Dockerfile index 681b976..4fb6269 100644 --- a/projects/content-api/connect-rpc/Dockerfile +++ b/projects/content-api/connect-rpc/Dockerfile @@ -1,5 +1,5 @@ # Stage 1: Generate protobuf + Connect RPC code -FROM golang:1.23-alpine AS generate +FROM golang:1.25.6-alpine AS generate RUN apk add --no-cache git RUN go install github.com/bufbuild/buf/cmd/buf@latest @@ -14,7 +14,7 @@ RUN echo 'version: v1' > proto/buf.yaml RUN buf generate proto # Stage 2: Build Go binary -FROM golang:1.23-alpine AS builder +FROM golang:1.25.6-alpine AS builder WORKDIR /app COPY connect-rpc/ . From 95acc08131e80860e88242b3312bdef6592affd4 Mon Sep 17 00:00:00 2001 From: Hasnae Date: Sat, 7 Mar 2026 17:02:38 +1100 Subject: [PATCH 03/25] Pin dependency versions for repeatable builds Co-Authored-By: Claude Opus 4.6 --- projects/content-api/connect-rpc/Dockerfile | 6 +++--- projects/content-api/connect-rpc/go.mod | 8 +++----- projects/content-api/connect-rpc/go.sum | 2 ++ 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/projects/content-api/connect-rpc/Dockerfile b/projects/content-api/connect-rpc/Dockerfile index 4fb6269..6a12c47 100644 --- a/projects/content-api/connect-rpc/Dockerfile +++ b/projects/content-api/connect-rpc/Dockerfile @@ -2,9 +2,9 @@ FROM golang:1.25.6-alpine AS generate RUN apk add --no-cache git -RUN go install github.com/bufbuild/buf/cmd/buf@latest -RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@latest -RUN go install connectrpc.com/connect/cmd/protoc-gen-connect-go@latest +RUN go install github.com/bufbuild/buf/cmd/buf@v1.66.0 +RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.11 +RUN go install connectrpc.com/connect/cmd/protoc-gen-connect-go@v1.19.1 WORKDIR /build COPY connect-rpc/buf.gen.yaml ./ diff --git a/projects/content-api/connect-rpc/go.mod b/projects/content-api/connect-rpc/go.mod index c59789d..317f0e9 100644 --- a/projects/content-api/connect-rpc/go.mod +++ b/projects/content-api/connect-rpc/go.mod @@ -1,12 +1,10 @@ module content-api-connect-rpc -go 1.23.0 +go 1.25.6 require ( - connectrpc.com/connect v1.16.2 + connectrpc.com/connect v1.19.1 github.com/google/uuid v1.6.0 golang.org/x/net v0.25.0 - google.golang.org/protobuf v1.34.1 + google.golang.org/protobuf v1.36.11 ) - -require golang.org/x/text v0.15.0 // indirect diff --git a/projects/content-api/connect-rpc/go.sum b/projects/content-api/connect-rpc/go.sum index 6bb7755..23146cd 100644 --- a/projects/content-api/connect-rpc/go.sum +++ b/projects/content-api/connect-rpc/go.sum @@ -1,5 +1,7 @@ connectrpc.com/connect v1.16.2/go.mod h1:n2kgwskMHXC+lVqb18wngEpF95ldBHXjZYJussz5FRc= +connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= From 5b46307a7515cf2e3b774ba49e910ccf707e7068 Mon Sep 17 00:00:00 2001 From: Hasnae Date: Sat, 7 Mar 2026 20:15:58 +1100 Subject: [PATCH 04/25] Add connect-rpc-go architecture agent and upgrade to buf v2 - Create .claude/agents/connect-rpc-go.md with layered architecture guide (APP/API/DOMAIN/OUTBOX) for scaffold-implementation delegation - Add agent delegation step to scaffold-implementation command - Upgrade buf.gen.yaml to v2 format with protoc_builtin/remote plugins - Update Dockerfile to use buf v2 config Co-Authored-By: Claude Opus 4.6 --- .claude/agents/connect-rpc-go.md | 1098 +++++++++++++++++ .claude/commands/scaffold-implementation.md | 8 + projects/content-api/connect-rpc/Dockerfile | 2 +- projects/content-api/connect-rpc/buf.gen.yaml | 15 +- 4 files changed, 1115 insertions(+), 8 deletions(-) create mode 100644 .claude/agents/connect-rpc-go.md diff --git a/.claude/agents/connect-rpc-go.md b/.claude/agents/connect-rpc-go.md new file mode 100644 index 0000000..63f765d --- /dev/null +++ b/.claude/agents/connect-rpc-go.md @@ -0,0 +1,1098 @@ +# Connect RPC Go — Architecture Agent + +Generate a layered Go + Connect RPC implementation following the platform backend architecture. + +Read the API spec and k6 script from `projects//_shared//` to understand the contract, then generate ALL files listed below. + +## Directory Structure + +``` +/ +├── cmd/server/ +│ ├── main.go +│ ├── setup_connections.go +│ ├── setup_domains.go +│ └── setup_gateway.go +├── internal/ +│ ├── api// +│ │ ├── handler.go +│ │ ├── mapper.go +│ │ ├── route_check_health.go +│ │ ├── route_create_.go +│ │ ├── route_get_.go +│ │ ├── route_list_.go +│ │ ├── route_update_.go +│ │ └── route_delete_.go +│ ├── domain// +│ │ ├── errors.go +│ │ ├── service.go +│ │ ├── op_create.go +│ │ ├── op_get.go +│ │ ├── op_list.go +│ │ ├── op_update.go +│ │ └── op_delete.go +│ └── outbox/ +│ ├── river.go # River implementation of pkg/outbox.Outbox +│ └── / +│ ├── event_created.go # River worker + job args +│ ├── event_updated.go +│ └── event_deleted.go +├── pkg/ +│ ├── config/config.go # Env-based config loaded via godotenv +│ ├── connectapp/app.go +│ ├── connectutil/errors.go +│ ├── connectutil/interceptors.go +│ ├── cache/cache.go +│ ├── outbox/outbox.go +│ └── migrate/migrate.go +├── gen/ +│ ├── proto/ # buf-generated (proto + connect) +│ └── sqlc// # sqlc-generated (per-domain) +├── sql/ +│ ├── migrations/001_create_.sql +│ └── queries//.sql +├── sqlc.yaml +├── buf.gen.yaml +├── go.mod +├── Dockerfile +└── docker-compose.yml +``` + +## Conventions + +- **Interface-first**: every package exposes an interface as its public API. Structs are unexported (lowercase). Constructors return the interface type (e.g., `func New(deps Dependencies) Service`). +- **Dependencies struct**: each layer defines an exported `Dependencies` struct listing its injected dependencies. Constructors take `Dependencies` as the single parameter. The private struct inlines the dependencies directly (not `deps Dependencies`) — the `Dependencies` struct is only for the public constructor signature. +- **File prefixes**: `route_.go` in API, `op_.go` in domain, `event_.go` in outbox. +- **Single server**: one h2c server on `:8080` serves `/health` (no interceptors) and Connect RPC paths (with per-handler interceptors via `connect.WithInterceptors`). + +## Layer Rules + +- `pkg/` depends on nothing — purely generic, extractable as a shared module +- `domain/` depends on `gen/sqlc/` + `pkg/` — business logic, orchestrates cache/store/outbox +- `outbox/` depends on `gen/sqlc/` + `pkg/outbox` + river — implements outbox, defines workers +- `api/` depends on `domain/`, `gen/proto/`, `gen/sqlc/`, `pkg/` +- `cmd/` wires all layers together + +## pkg/ — Generic Reusable Packages + +### pkg/config/config.go + +Env-based configuration loaded via godotenv. Consolidates all environment variables into a typed struct. Loaded once at startup in `main.go`. + +```go +package config + +import ( + "os" + + "github.com/joho/godotenv" +) + +type Config struct { + DatabaseURL string + OpenSearchURL string + ServerAddr string +} + +func Load() (*Config, error) { + _ = godotenv.Load() // .env file is optional, env vars take precedence + return &Config{ + DatabaseURL: getEnv("DATABASE_URL", "postgres://benchmark:benchmark@localhost:5432/benchmark?sslmode=disable"), + OpenSearchURL: getEnv("OPENSEARCH_URL", "http://localhost:9200"), + ServerAddr: getEnv("SERVER_ADDR", ":8080"), + }, nil +} + +func getEnv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} +``` + +### pkg/connectapp/app.go + +Reusable Connect RPC application lifecycle. Single server with h2c, path-based routing, graceful shutdown. Health and API handlers served from different paths on the same port — enabling different interceptor chains per path via Connect's `WithInterceptors`. + +```go +package connectapp + +import ( + "context" + "net/http" + + "github.com/rs/zerolog/log" + "golang.org/x/net/http2" + "golang.org/x/net/http2/h2c" +) + +// App is the public interface for the Connect RPC application. +type App interface { + Handle(path string, handler http.Handler) + Run(ctx context.Context) error +} + +type Option func(*app) + +func WithAddr(addr string) Option { return func(a *app) { a.addr = addr } } + +func New(opts ...Option) App { + a := &app{addr: ":8080", mux: http.NewServeMux()} + for _, o := range opts { + o(a) + } + // /health is always registered — no interceptors, plain HTTP + a.mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":"up"}`)) + }) + return a +} + +type app struct { + addr string + mux *http.ServeMux +} + +func (a *app) Handle(path string, handler http.Handler) { + a.mux.Handle(path, handler) +} + +func (a *app) Run(ctx context.Context) error { + server := &http.Server{ + Addr: a.addr, + Handler: h2c.NewHandler(a.mux, &http2.Server{}), + } + + log.Info().Str("addr", a.addr).Msg("server started") + + errCh := make(chan error, 1) + go func() { errCh <- server.ListenAndServe() }() + + select { + case <-ctx.Done(): + return server.Close() + case err := <-errCh: + return err + } +} +``` + +Single port serves both `/health` (plain HTTP, no auth) and Connect RPC paths (e.g., `/content.v1.ContentService/*`). Different interceptor chains are configured per handler via `connect.WithInterceptors()` when calling `NewContentServiceHandler`. + +### pkg/connectutil/errors.go + +Map domain sentinel errors to connect error codes. + +```go +package connectutil + +import ( + "errors" + + "connectrpc.com/connect" +) + +func NewErrorFrom(err error, mappings map[error]connect.Code) *connect.Error { + for sentinel, code := range mappings { + if errors.Is(err, sentinel) { + return connect.NewError(code, err) + } + } + return connect.NewError(connect.CodeInternal, err) +} +``` + +### pkg/connectutil/interceptors.go + +Shared interceptors: recovery, logging, buf validate. + +```go +package connectutil + +import ( + "context" + "fmt" + "time" + + "connectrpc.com/connect" + "connectrpc.com/validate" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +func NewInterceptors() []connect.Interceptor { + validateInterceptor, _ := validate.NewInterceptor() + return []connect.Interceptor{ + NewRecoveryInterceptor(), + NewLoggingInterceptor(), + validateInterceptor, + } +} + +func NewRecoveryInterceptor() connect.UnaryInterceptorFunc { + return func(next connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (resp connect.AnyResponse, err error) { + defer func() { + if r := recover(); r != nil { + err = connect.NewError(connect.CodeInternal, fmt.Errorf("panic: %v", r)) + } + }() + return next(ctx, req) + } + } +} + +func NewLoggingInterceptor() connect.UnaryInterceptorFunc { + return func(next connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + start := time.Now() + resp, err := next(ctx, req) + evt := log.Info() + if err != nil { + evt = log.Error().Err(err) + } + evt. + Str("procedure", req.Spec().Procedure). + Dur("duration", time.Since(start)). + Msg("rpc") + return resp, err + } + } +} +``` + +### pkg/cache/cache.go + +Generic cache interface. Struct is private; constructor returns the interface. + +```go +package cache + +import ( + "sync" + "time" +) + +// Cache is the public interface. Implementations are private. +type Cache[K comparable, V any] interface { + Get(key K) (V, bool) + Set(key K, value V, ttl time.Duration) + Delete(key K) +} + +// NewInMemory returns a Cache backed by a sync.RWMutex map. +func NewInMemory[K comparable, V any]() Cache[K, V] { + return &inMemory[K, V]{items: make(map[K]entry[V])} +} + +type inMemory[K comparable, V any] struct { + mu sync.RWMutex + items map[K]entry[V] +} + +type entry[V any] struct { + value V + expiresAt time.Time +} + +func (c *inMemory[K, V]) Get(key K) (V, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + e, ok := c.items[key] + if !ok || (!e.expiresAt.IsZero() && time.Now().After(e.expiresAt)) { + var zero V + return zero, false + } + return e.value, true +} + +func (c *inMemory[K, V]) Set(key K, value V, ttl time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + var exp time.Time + if ttl > 0 { + exp = time.Now().Add(ttl) + } + c.items[key] = entry[V]{value: value, expiresAt: exp} +} + +func (c *inMemory[K, V]) Delete(key K) { + c.mu.Lock() + defer c.mu.Unlock() + delete(c.items, key) +} +``` + +### pkg/outbox/outbox.go + +Outbox interface for emitting domain events. The domain doesn't know about jobs or queues — it just emits events. The implementation decides what to do (e.g., insert river jobs for indexing, auditing, analytics). + +```go +package outbox + +import "context" + +// Event represents a domain event to be processed asynchronously. +type Event struct { + Type string + Payload any +} + +// Outbox emits domain events within a transaction. +// Generic over the transaction type to avoid unsafe casts while keeping pkg dependency-free. +type Outbox[T any] interface { + Emit(ctx context.Context, tx T, events ...Event) error +} +``` + +### pkg/migrate/migrate.go + +Goose wrapper with embedded migrations. + +```go +package migrate + +import ( + "database/sql" + "embed" + + "github.com/pressly/goose/v3" +) + +func Run(db *sql.DB, migrations embed.FS, dir string) error { + goose.SetBaseFS(migrations) + if err := goose.SetDialect("postgres"); err != nil { + return err + } + return goose.Up(db, dir) +} +``` + +## internal/api/ — API Layer + +### handler.go + +Private struct implementing the generated Connect service interface. Constructor returns the Connect-generated interface type. + +```go +package content + +import ( + "internal/domain/content" + contentv1connect "gen/proto/content/v1/contentv1connect" +) + +// Dependencies defines the dependencies for the content API handler. +type Dependencies struct { + Service content.Service +} + +// New returns the Connect-generated interface. Struct is private. +func New(deps Dependencies) contentv1connect.ContentServiceHandler { + return &handler{service: deps.Service} +} + +type handler struct { + service content.Service +} +``` + +### mapper.go + +Mapping functions between proto types (`gen/proto/`) and sqlc models (`gen/sqlc/`). + +```go +package content + +// toProto maps a sqlc model to a proto response message +// fromProtoCreate maps a proto create request to sqlc create params +// fromProtoUpdate maps a proto update request to sqlc update params +// statusToProto / statusFromProto for enum mapping +``` + +### route_*.go — One file per RPC + +Each file contains a single method on the Handler struct: + +```go +// route_create_content.go +func (h *handler) CreateContent( + ctx context.Context, + req *connect.Request[contentv1.CreateContentRequest], +) (*connect.Response[contentv1.Content], error) { + result, err := h.service.Create(ctx, fromProtoCreate(req.Msg)) + if err != nil { + return nil, connectutil.NewErrorFrom(err, errorMappings) + } + return connect.NewResponse(toProto(result)), nil +} +``` + +Error mappings defined as a package-level var: + +```go +var errorMappings = map[error]connect.Code{ + content.ErrNotFound: connect.CodeNotFound, + content.ErrAlreadyExists: connect.CodeAlreadyExists, +} +``` + +## internal/domain/ — Domain Layer + +### errors.go + +Sentinel errors used across the domain. + +```go +package content + +import "errors" + +var ( + ErrNotFound = errors.New("content not found") + ErrAlreadyExists = errors.New("content already exists") +) +``` + +### service.go + +Service interface is public; struct is private. No store abstraction — sqlc IS the store. + +```go +package content + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/jackc/pgx/v5" + + "pkg/cache" + "pkg/outbox" + sqlccontent "gen/sqlc/content" +) + +// Service is the public interface for the content domain. +type Service interface { + Create(ctx context.Context, params sqlccontent.CreateContentParams) (*sqlccontent.Content, error) + Get(ctx context.Context, id uuid.UUID) (*sqlccontent.Content, error) + List(ctx context.Context, limit, offset int32) ([]sqlccontent.Content, int64, error) + Update(ctx context.Context, id uuid.UUID, params sqlccontent.UpdateContentParams) (*sqlccontent.Content, error) + Delete(ctx context.Context, id uuid.UUID) error +} + +// Dependencies defines the dependencies for the content domain service. +type Dependencies struct { + Pool *pgxpool.Pool + Queries *sqlccontent.Queries + Cache cache.Cache[uuid.UUID, *sqlccontent.Content] + Outbox outbox.Outbox[pgx.Tx] +} + +func New(deps Dependencies) Service { + return &service{ + pool: deps.Pool, + queries: deps.Queries, + cache: deps.Cache, + outbox: deps.Outbox, + } +} + +type service struct { + pool *pgxpool.Pool + queries *sqlccontent.Queries + cache cache.Cache[uuid.UUID, *sqlccontent.Content] + outbox outbox.Outbox[pgx.Tx] +} +``` + +### op_*.go — One file per operation + +Each operation runs within a DB transaction for transactional outbox: + +```go +// op_create.go +func (s *service) Create(ctx context.Context, params sqlccontent.CreateContentParams) (*sqlccontent.Content, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + item, err := s.queries.WithTx(tx).CreateContent(ctx, params) + if err != nil { + return nil, err + } + + if err := s.outbox.Emit(ctx, tx, outbox.Event{Type: "content.created", Payload: item}); err != nil { + return nil, err + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + s.cache.Set(item.ID, &item, 0) + return &item, nil +} +``` + +For reads, check cache first: + +```go +// op_get.go +func (s *service) Get(ctx context.Context, id uuid.UUID) (*sqlccontent.Content, error) { + if cached, ok := s.cache.Get(id); ok { + return cached, nil + } + item, err := s.queries.GetContent(ctx, id) + if err != nil { + return nil, ErrNotFound + } + s.cache.Set(id, &item, 0) + return &item, nil +} +``` + +## internal/outbox/ — Outbox Layer + +### river.go — River implementation of pkg/outbox.Outbox + +Maps domain events to river jobs. Each event type can fan out to multiple jobs (index, audit, analytics). + +```go +package outbox + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/riverqueue/river" + + "pkg/outbox" + contentevents "/internal/outbox/content" +) + +func NewRiverOutbox(client *river.Client[pgx.Tx]) outbox.Outbox[pgx.Tx] { + return &riverOutbox{client: client} +} + +type riverOutbox struct { + client *river.Client[pgx.Tx] +} + +func (o *riverOutbox) Emit(ctx context.Context, tx pgx.Tx, events ...outbox.Event) error { + for _, event := range events { + jobs, err := o.mapEvent(event) + if err != nil { + return err + } + for _, args := range jobs { + if _, err := o.client.InsertTx(ctx, tx, args, nil); err != nil { + return err + } + } + } + return nil +} + +// mapEvent fans out a domain event into one or more river jobs. +func (o *riverOutbox) mapEvent(event outbox.Event) ([]river.JobArgs, error) { + switch event.Type { + case "content.created": + return []river.JobArgs{ + contentevents.NewIndexArgs(event), + contentevents.NewAuditArgs(event), + }, nil + case "content.updated": + return []river.JobArgs{ + contentevents.NewIndexArgs(event), + }, nil + case "content.deleted": + return []river.JobArgs{ + contentevents.NewIndexArgs(event), + contentevents.NewAuditArgs(event), + }, nil + default: + return nil, fmt.Errorf("unknown event type: %s", event.Type) + } +} +``` + +### event_*.go — One file per event type + +Each file contains river `JobArgs` + `Worker` for a specific concern (index, audit, analytics). + +```go +// event_created.go — indexing concern +package content + +import ( + "context" + "github.com/riverqueue/river" + "pkg/outbox" +) + +type IndexArgs struct { + ID string `json:"id"` + Type string `json:"type"` +} + +func (IndexArgs) Kind() string { return "content.index" } + +func NewIndexArgs(event outbox.Event) *IndexArgs { + // extract ID from event payload + return &IndexArgs{ID: "...", Type: event.Type} +} + +type IndexWorker struct { + river.WorkerDefaults[IndexArgs] + // opensearch client injected here +} + +func (w *IndexWorker) Work(ctx context.Context, job *river.Job[IndexArgs]) error { + // index/update/delete content in OpenSearch based on job.Args.Type + return nil +} +``` + +```go +// event_audit.go — auditing concern +package content + +type AuditArgs struct { + ID string `json:"id"` + Action string `json:"action"` +} + +func (AuditArgs) Kind() string { return "content.audit" } + +func NewAuditArgs(event outbox.Event) *AuditArgs { ... } + +type AuditWorker struct { + river.WorkerDefaults[AuditArgs] +} + +func (w *AuditWorker) Work(ctx context.Context, job *river.Job[AuditArgs]) error { + // write audit trail + return nil +} +``` + +## SQL — Migrations & Queries + +### sql/migrations/001_create_content.sql + +```sql +-- +goose Up +CREATE TABLE content ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title TEXT NOT NULL, + body TEXT NOT NULL, + status INT NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- +goose Down +DROP TABLE IF EXISTS content; +``` + +### sql/queries/content/content.sql + +sqlc annotated queries — one query per operation. + +```sql +-- name: GetContent :one +SELECT * FROM content WHERE id = sqlc.arg('id'); + +-- name: ListContent :many +SELECT * FROM content ORDER BY created_at LIMIT sqlc.arg('limit') OFFSET sqlc.arg('offset'); + +-- name: CountContent :one +SELECT count(*) FROM content; + +-- name: CreateContent :one +INSERT INTO content (title, body, status) +VALUES (sqlc.arg('title'), sqlc.arg('body'), sqlc.arg('status')) +RETURNING *; + +-- name: UpdateContent :one +UPDATE content +SET title = COALESCE(sqlc.narg('title'), title), + body = COALESCE(sqlc.narg('body'), body), + status = COALESCE(sqlc.narg('status'), status), + updated_at = now() +WHERE id = sqlc.arg('id') +RETURNING *; + +-- name: DeleteContent :exec +DELETE FROM content WHERE id = sqlc.arg('id'); +``` + +### sqlc.yaml + +One `sql` block per domain. Generated code isolated under `gen/sqlc//`. + +```yaml +version: "2" +sql: + - engine: "postgresql" + queries: "sql/queries/content/" + schema: "sql/migrations/" + gen: + go: + package: "content" + out: "gen/sqlc/content" + sql_package: "pgx/v5" + overrides: + - db_type: "uuid" + go_type: + import: "github.com/google/uuid" + type: "UUID" +``` + +## buf.gen.yaml + +Already uses buf v2 config. The existing `buf.gen.yaml` in the project handles proto + connect code generation to `gen/proto/`. No changes needed — just ensure the module's `go_package_prefix` value matches the Go module name. + +## cmd/server/ — App Wiring + +Split into focused setup files so bootstrapping is easy to reason about: + +``` +cmd/server/ +├── main.go +├── setup_connections.go +├── setup_domains.go +└── setup_gateway.go +``` + +### cmd/server/main.go + +```go +package main + +import ( + "context" + "log" + "os/signal" + "syscall" + + "pkg/config" + "pkg/connectapp" +) + +func main() { + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + cfg, _ := config.Load() + connections := setupConnections(ctx, cfg) + defer connections.Close(ctx) + + domains := setupDomains(connections) + application := setupGateway(cfg, domains) + + log.Fatal(application.Run(ctx)) +} +``` + +### cmd/server/setup_connections.go + +Establishes infrastructure connections: database pool, migrations, river client. + +```go +package main + +import ( + "context" + "database/sql" + "embed" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/riverqueue/river" + "github.com/riverqueue/river/riverdriver/riverpgxv5" + "github.com/riverqueue/river/rivermigrate" + + "pkg/config" + "pkg/migrate" + outboxcontent "/internal/outbox/content" +) + +//go:embed sql/migrations/*.sql +var migrations embed.FS + +type Connections struct { + Pool *pgxpool.Pool + RiverClient *river.Client[pgx.Tx] +} + +func (c *Connections) Close(ctx context.Context) { + c.RiverClient.Stop(ctx) + c.Pool.Close() +} + +func setupConnections(ctx context.Context, cfg *config.Config) *Connections { + pool, _ := pgxpool.New(ctx, cfg.DatabaseURL) + + // River migrations (independent) + riverMigrator, _ := rivermigrate.New(riverpgxv5.New(pool), nil) + riverMigrator.Migrate(ctx, rivermigrate.DirectionUp, nil) + + // Domain migrations (goose) + stdDB, _ := sql.Open("pgx", cfg.DatabaseURL) + migrate.Run(stdDB, migrations, "sql/migrations") + stdDB.Close() + + // River client + workers + workers := river.NewWorkers() + river.AddWorker(workers, &outboxcontent.IndexWorker{}) + river.AddWorker(workers, &outboxcontent.AuditWorker{}) + riverClient, _ := river.NewClient(riverpgxv5.New(pool), &river.Config{ + Queues: map[string]river.QueueConfig{river.QueueDefault: {MaxWorkers: 100}}, + Workers: workers, + }) + riverClient.Start(ctx) + + return &Connections{Pool: pool, RiverClient: riverClient} +} +``` + +### cmd/server/setup_domains.go + +Wires domain services with their dependencies: sqlc queries, cache, outbox. + +```go +package main + +import ( + "github.com/google/uuid" + + "pkg/cache" + contentdomain "/internal/domain/content" + internaloutbox "/internal/outbox" + sqlccontent "/gen/sqlc/content" +) + +type Domains struct { + Content contentdomain.Service +} + +func setupDomains(conn *Connections) *Domains { + queries := sqlccontent.New(conn.Pool) + contentCache := cache.NewInMemory[uuid.UUID, *sqlccontent.Content]() + contentOutbox := internaloutbox.NewRiverOutbox(conn.RiverClient) + + contentService := contentdomain.New(contentdomain.Dependencies{ + Pool: conn.Pool, + Queries: queries, + Cache: contentCache, + Outbox: contentOutbox, + }) + + return &Domains{Content: contentService} +} +``` + +### cmd/server/setup_gateway.go + +Registers Connect RPC handlers with interceptors on the app. + +```go +package main + +import ( + "connectrpc.com/connect" + + "pkg/config" + "pkg/connectapp" + "pkg/connectutil" + contentapi "/internal/api/content" + contentv1connect "/gen/proto/content/v1/contentv1connect" +) + +func setupGateway(cfg *config.Config, domains *Domains) connectapp.App { + application := connectapp.New(connectapp.WithAddr(cfg.ServerAddr)) + interceptors := connectutil.NewInterceptors() + + contentHandler := contentapi.New(contentapi.Dependencies{Service: domains.Content}) + path, h := contentv1connect.NewContentServiceHandler( + contentHandler, + connect.WithInterceptors(interceptors...), + ) + application.Handle(path, h) + + return application +} +``` + +## Dockerfile + +Multi-stage build: generate (buf + sqlc) → build → runtime. + +```dockerfile +# Stage 1: Generate proto + sqlc code +FROM golang:1.25.6-alpine AS generate + +RUN apk add --no-cache git +RUN go install github.com/bufbuild/buf/cmd/buf@v1.66.0 +RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.11 +RUN go install connectrpc.com/connect/cmd/protoc-gen-connect-go@v1.19.1 +RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@v1.30.0 + +WORKDIR /build + +# buf generate +COPY /buf.gen.yaml ./ +RUN mkdir -p proto/content/v1 +COPY _shared/protobuf/content.proto proto/content/v1/content.proto +RUN echo 'version: v2' > proto/buf.yaml +RUN buf generate proto + +# sqlc generate +COPY /sqlc.yaml ./ +COPY /sql ./sql +RUN sqlc generate + +# Stage 2: Build +FROM golang:1.25.6-alpine AS builder + +WORKDIR /app +COPY / . +COPY --from=generate /build/gen ./gen +RUN go mod tidy +RUN CGO_ENABLED=0 go build -o /server ./cmd/server + +# Stage 3: Runtime +FROM alpine:3.20 + +RUN apk add --no-cache curl +COPY --from=builder /server /server + +EXPOSE 8080 +CMD ["/server"] +``` + +Replace `` with the actual directory name (e.g., `connect-rpc`). + +## docker-compose.yml + +```yaml +services: + postgres: + image: postgres:17-alpine + environment: + POSTGRES_DB: benchmark + POSTGRES_USER: benchmark + POSTGRES_PASSWORD: benchmark + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U benchmark"] + interval: 2s + timeout: 2s + retries: 10 + + opensearch: + image: opensearchproject/opensearch:3.5.0 + environment: + - discovery.type=single-node + - DISABLE_SECURITY_PLUGIN=true + ports: + - "9200:9200" + + opensearch-dashboards: + image: opensearchproject/opensearch-dashboards:3.5.0 + environment: + - OPENSEARCH_HOSTS=["http://opensearch:9200"] + - DISABLE_SECURITY_DASHBOARDS_PLUGIN=true + ports: + - "5601:5601" + depends_on: + - opensearch + + codegen: + build: + context: .. + dockerfile: /Dockerfile + target: generate + volumes: + - ./gen:/out/gen + entrypoint: ["cp", "-r", "/build/gen/.", "/out/gen/"] + profiles: + - codegen + + api: + build: + context: .. + dockerfile: /Dockerfile + ports: + - "8080:8080" + environment: + - DATABASE_URL=postgres://benchmark:benchmark@postgres:5432/benchmark?sslmode=disable + - OPENSEARCH_URL=http://opensearch:9200 + depends_on: + postgres: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 10s +``` + +## Error Handling + +| Domain Error | Connect Code | HTTP | +|-------------------|--------------------------|------| +| ErrNotFound | CodeNotFound | 404 | +| ErrAlreadyExists | CodeAlreadyExists | 409 | +| ErrInvalidArgument| CodeInvalidArgument | 400 | +| ErrPrecondition | CodeFailedPrecondition | 412 | +| ErrPermission | CodePermissionDenied | 403 | +| ErrUnauthenticated| CodeUnauthenticated | 401 | +| (default) | CodeInternal | 500 | + +## Pinned Versions + +| Dependency | Version | +|---|---| +| Go | 1.25.6 | +| connectrpc.com/connect | v1.19.1 | +| connectrpc.com/validate | latest stable | +| google.golang.org/protobuf | v1.36.11 | +| github.com/jackc/pgx/v5 | latest stable | +| github.com/riverqueue/river | latest stable | +| github.com/pressly/goose/v3 | latest stable | +| github.com/rs/zerolog | latest stable | +| github.com/joho/godotenv | latest stable | +| github.com/google/uuid | v1.6.0 | +| golang.org/x/net | v0.25.0 | +| buf | v1.66.0 | +| protoc-gen-go | v1.36.11 | +| protoc-gen-connect-go | v1.19.1 | +| sqlc | v1.30.0 | + +## Checklist + +Before finishing generation, verify: +- [ ] All RPCs from the proto file are implemented as `route_*.go` files +- [ ] Each domain operation has its own `op_*.go` file +- [ ] Each outbox event has its own `event_*.go` file +- [ ] sqlc queries cover all CRUD operations +- [ ] Migration creates the correct table schema +- [ ] Dockerfile builds successfully with both buf and sqlc generation +- [ ] docker-compose includes postgres, opensearch, and the api service +- [ ] Single server on :8080 via h2c — `/health` (plain HTTP) and Connect RPC paths (with interceptors) +- [ ] All env vars consolidated in `pkg/config` with godotenv loading +- [ ] sqlc uses `sql_package: pgx/v5` and `uuid.UUID` override +- [ ] Every package exposes interfaces; structs are private implementation details +- [ ] Different interceptor chains possible per handler via `connect.WithInterceptors()` +- [ ] The k6 test script expectations are met (set `GRPC_HOST=localhost:8080`) diff --git a/.claude/commands/scaffold-implementation.md b/.claude/commands/scaffold-implementation.md index 131a507..223484c 100644 --- a/.claude/commands/scaffold-implementation.md +++ b/.claude/commands/scaffold-implementation.md @@ -24,6 +24,14 @@ These are used in benchmark tags and guide code generation. ## Step 5 — Generate the implementation +### Agent delegation + +Check if a specialized agent prompt exists at `.claude/agents/-.md` (e.g., `.claude/agents/connect-rpc-go.md`). If found, read that file and follow its architecture guide to generate the implementation under `projects///`. Then skip directly to Step 6. + +If no agent prompt exists, fall through to the generic generation instructions below. + +### Generic generation (fallback) + Read the API spec and k6 script from `projects//_shared//` to understand the contract. Generate the following files under `projects///`: diff --git a/projects/content-api/connect-rpc/Dockerfile b/projects/content-api/connect-rpc/Dockerfile index 6a12c47..ab8e822 100644 --- a/projects/content-api/connect-rpc/Dockerfile +++ b/projects/content-api/connect-rpc/Dockerfile @@ -10,7 +10,7 @@ WORKDIR /build COPY connect-rpc/buf.gen.yaml ./ RUN mkdir -p proto/content/v1 COPY _shared/protobuf/content.proto proto/content/v1/content.proto -RUN echo 'version: v1' > proto/buf.yaml +RUN echo 'version: v2' > proto/buf.yaml RUN buf generate proto # Stage 2: Build Go binary diff --git a/projects/content-api/connect-rpc/buf.gen.yaml b/projects/content-api/connect-rpc/buf.gen.yaml index 6321bb2..f1bf18f 100644 --- a/projects/content-api/connect-rpc/buf.gen.yaml +++ b/projects/content-api/connect-rpc/buf.gen.yaml @@ -1,12 +1,13 @@ -version: v1 +version: v2 managed: enabled: true - go_package_prefix: - default: content-api-connect-rpc/gen + override: + - file_option: go_package_prefix + value: content-api-connect-rpc/gen/proto plugins: - - name: go - out: gen + - protoc_builtin: go + out: gen/proto opt: paths=source_relative - - name: connect-go - out: gen + - remote: connectrpc.com/connect + out: gen/proto opt: paths=source_relative From 3bf022358ce89e148202c20c2231dbd27ac7819d Mon Sep 17 00:00:00 2001 From: Hasnae Date: Sat, 7 Mar 2026 20:27:47 +1100 Subject: [PATCH 05/25] Remove connect-rpc implementation to test scaffold from scratch Co-Authored-By: Claude Opus 4.6 --- benchmark.config.json | 32 +--- projects/content-api/connect-rpc/Dockerfile | 32 ---- projects/content-api/connect-rpc/buf.gen.yaml | 13 -- .../connect-rpc/cmd/server/main.go | 42 ----- .../connect-rpc/docker-compose.yml | 17 -- projects/content-api/connect-rpc/go.mod | 10 -- projects/content-api/connect-rpc/go.sum | 7 - .../connect-rpc/internal/service/content.go | 150 ------------------ 8 files changed, 1 insertion(+), 302 deletions(-) delete mode 100644 projects/content-api/connect-rpc/Dockerfile delete mode 100644 projects/content-api/connect-rpc/buf.gen.yaml delete mode 100644 projects/content-api/connect-rpc/cmd/server/main.go delete mode 100644 projects/content-api/connect-rpc/docker-compose.yml delete mode 100644 projects/content-api/connect-rpc/go.mod delete mode 100644 projects/content-api/connect-rpc/go.sum delete mode 100644 projects/content-api/connect-rpc/internal/service/content.go diff --git a/benchmark.config.json b/benchmark.config.json index 72aa3db..b843820 100644 --- a/benchmark.config.json +++ b/benchmark.config.json @@ -1,35 +1,5 @@ { - "targets": { - "content-api/connect-rpc": { - "path": "./projects/content-api/connect-rpc", - "composeFile": "docker-compose.yml", - "service": "api", - "port": 50051, - "protocol": "grpc", - "readinessProbe": { - "httpGet": { - "path": "/health", - "port": 8080, - "expectedStatus": 200 - } - }, - "k6": { - "script": "./projects/content-api/_shared/protobuf/k6/content-api.js", - "vus": 50, - "duration": "30s", - "env": { - "GRPC_HOST": "localhost:50051", - "PROTO_PATH": "./projects/content-api/_shared/protobuf/content.proto" - } - }, - "tags": { - "project": "content-api", - "language": "go", - "framework": "connect-rpc", - "api-style": "protobuf" - } - } - }, + "targets": {}, "defaults": { "k6": { "vus": 50, diff --git a/projects/content-api/connect-rpc/Dockerfile b/projects/content-api/connect-rpc/Dockerfile deleted file mode 100644 index ab8e822..0000000 --- a/projects/content-api/connect-rpc/Dockerfile +++ /dev/null @@ -1,32 +0,0 @@ -# Stage 1: Generate protobuf + Connect RPC code -FROM golang:1.25.6-alpine AS generate - -RUN apk add --no-cache git -RUN go install github.com/bufbuild/buf/cmd/buf@v1.66.0 -RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.11 -RUN go install connectrpc.com/connect/cmd/protoc-gen-connect-go@v1.19.1 - -WORKDIR /build -COPY connect-rpc/buf.gen.yaml ./ -RUN mkdir -p proto/content/v1 -COPY _shared/protobuf/content.proto proto/content/v1/content.proto -RUN echo 'version: v2' > proto/buf.yaml -RUN buf generate proto - -# Stage 2: Build Go binary -FROM golang:1.25.6-alpine AS builder - -WORKDIR /app -COPY connect-rpc/ . -COPY --from=generate /build/gen ./gen -RUN go mod tidy -RUN CGO_ENABLED=0 go build -o /server ./cmd/server - -# Stage 3: Minimal runtime -FROM alpine:3.20 - -RUN apk add --no-cache curl -COPY --from=builder /server /server - -EXPOSE 8080 50051 -CMD ["/server"] diff --git a/projects/content-api/connect-rpc/buf.gen.yaml b/projects/content-api/connect-rpc/buf.gen.yaml deleted file mode 100644 index f1bf18f..0000000 --- a/projects/content-api/connect-rpc/buf.gen.yaml +++ /dev/null @@ -1,13 +0,0 @@ -version: v2 -managed: - enabled: true - override: - - file_option: go_package_prefix - value: content-api-connect-rpc/gen/proto -plugins: - - protoc_builtin: go - out: gen/proto - opt: paths=source_relative - - remote: connectrpc.com/connect - out: gen/proto - opt: paths=source_relative diff --git a/projects/content-api/connect-rpc/cmd/server/main.go b/projects/content-api/connect-rpc/cmd/server/main.go deleted file mode 100644 index ab7ea5c..0000000 --- a/projects/content-api/connect-rpc/cmd/server/main.go +++ /dev/null @@ -1,42 +0,0 @@ -package main - -import ( - "log" - "net/http" - - "golang.org/x/net/http2" - "golang.org/x/net/http2/h2c" - - "content-api-connect-rpc/gen/content/v1/contentv1connect" - "content-api-connect-rpc/internal/service" -) - -func main() { - srv := service.NewContentServer() - - // gRPC / Connect handler on port 50051 - grpcMux := http.NewServeMux() - path, handler := contentv1connect.NewContentServiceHandler(srv) - grpcMux.Handle(path, handler) - - // Health check on port 8080 - healthMux := http.NewServeMux() - healthMux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.Write([]byte(`{"status":"up"}`)) - }) - - go func() { - log.Println("Health check listening on :8080") - if err := http.ListenAndServe(":8080", healthMux); err != nil { - log.Fatal(err) - } - }() - - grpcServer := &http.Server{ - Addr: ":50051", - Handler: h2c.NewHandler(grpcMux, &http2.Server{}), - } - log.Println("gRPC server listening on :50051") - log.Fatal(grpcServer.ListenAndServe()) -} diff --git a/projects/content-api/connect-rpc/docker-compose.yml b/projects/content-api/connect-rpc/docker-compose.yml deleted file mode 100644 index 630f4ce..0000000 --- a/projects/content-api/connect-rpc/docker-compose.yml +++ /dev/null @@ -1,17 +0,0 @@ -services: - api: - build: - context: .. - dockerfile: connect-rpc/Dockerfile - ports: - - "8080:8080" - - "50051:50051" - environment: - - HEALTH_PORT=8080 - - GRPC_PORT=50051 - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/health"] - interval: 5s - timeout: 3s - retries: 10 - start_period: 10s diff --git a/projects/content-api/connect-rpc/go.mod b/projects/content-api/connect-rpc/go.mod deleted file mode 100644 index 317f0e9..0000000 --- a/projects/content-api/connect-rpc/go.mod +++ /dev/null @@ -1,10 +0,0 @@ -module content-api-connect-rpc - -go 1.25.6 - -require ( - connectrpc.com/connect v1.19.1 - github.com/google/uuid v1.6.0 - golang.org/x/net v0.25.0 - google.golang.org/protobuf v1.36.11 -) diff --git a/projects/content-api/connect-rpc/go.sum b/projects/content-api/connect-rpc/go.sum deleted file mode 100644 index 23146cd..0000000 --- a/projects/content-api/connect-rpc/go.sum +++ /dev/null @@ -1,7 +0,0 @@ -connectrpc.com/connect v1.16.2/go.mod h1:n2kgwskMHXC+lVqb18wngEpF95ldBHXjZYJussz5FRc= -connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/projects/content-api/connect-rpc/internal/service/content.go b/projects/content-api/connect-rpc/internal/service/content.go deleted file mode 100644 index f0e7110..0000000 --- a/projects/content-api/connect-rpc/internal/service/content.go +++ /dev/null @@ -1,150 +0,0 @@ -package service - -import ( - "context" - "fmt" - "sort" - "sync" - "time" - - "connectrpc.com/connect" - "github.com/google/uuid" - "google.golang.org/protobuf/proto" - - contentv1 "content-api-connect-rpc/gen/content/v1" -) - -type ContentServer struct { - mu sync.RWMutex - store map[string]*contentv1.Content -} - -func NewContentServer() *ContentServer { - return &ContentServer{ - store: make(map[string]*contentv1.Content), - } -} - -func (s *ContentServer) CheckHealth( - _ context.Context, - _ *connect.Request[contentv1.HealthRequest], -) (*connect.Response[contentv1.HealthResponse], error) { - return connect.NewResponse(&contentv1.HealthResponse{ - Status: "up", - }), nil -} - -func (s *ContentServer) ListContent( - _ context.Context, - req *connect.Request[contentv1.ListContentRequest], -) (*connect.Response[contentv1.ListContentResponse], error) { - s.mu.RLock() - defer s.mu.RUnlock() - - all := make([]*contentv1.Content, 0, len(s.store)) - for _, item := range s.store { - all = append(all, proto.Clone(item).(*contentv1.Content)) - } - sort.Slice(all, func(i, j int) bool { - return all[i].CreatedAt < all[j].CreatedAt - }) - - total := int32(len(all)) - limit := req.Msg.Limit - offset := req.Msg.Offset - if limit <= 0 { - limit = 10 - } - start := int(offset) - if start > len(all) { - start = len(all) - } - end := start + int(limit) - if end > len(all) { - end = len(all) - } - - return connect.NewResponse(&contentv1.ListContentResponse{ - Items: all[start:end], - Total: total, - Limit: limit, - Offset: offset, - }), nil -} - -func (s *ContentServer) GetContent( - _ context.Context, - req *connect.Request[contentv1.GetContentRequest], -) (*connect.Response[contentv1.Content], error) { - s.mu.RLock() - defer s.mu.RUnlock() - - item, ok := s.store[req.Msg.Id] - if !ok { - return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("content %s not found", req.Msg.Id)) - } - return connect.NewResponse(proto.Clone(item).(*contentv1.Content)), nil -} - -func (s *ContentServer) CreateContent( - _ context.Context, - req *connect.Request[contentv1.CreateContentRequest], -) (*connect.Response[contentv1.Content], error) { - s.mu.Lock() - defer s.mu.Unlock() - - now := time.Now().UTC().Format(time.RFC3339) - item := &contentv1.Content{ - Id: uuid.New().String(), - Title: req.Msg.Title, - Body: req.Msg.Body, - Status: req.Msg.Status, - CreatedAt: now, - UpdatedAt: now, - } - s.store[item.Id] = item - return connect.NewResponse(proto.Clone(item).(*contentv1.Content)), nil -} - -func (s *ContentServer) UpdateContent( - _ context.Context, - req *connect.Request[contentv1.UpdateContentRequest], -) (*connect.Response[contentv1.Content], error) { - s.mu.Lock() - defer s.mu.Unlock() - - item, ok := s.store[req.Msg.Id] - if !ok { - return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("content %s not found", req.Msg.Id)) - } - - if req.Msg.Title != nil { - item.Title = *req.Msg.Title - } - if req.Msg.Body != nil { - item.Body = *req.Msg.Body - } - if req.Msg.Status != nil { - item.Status = *req.Msg.Status - } - item.UpdatedAt = time.Now().UTC().Format(time.RFC3339) - - return connect.NewResponse(proto.Clone(item).(*contentv1.Content)), nil -} - -func (s *ContentServer) DeleteContent( - _ context.Context, - req *connect.Request[contentv1.DeleteContentRequest], -) (*connect.Response[contentv1.DeleteContentResponse], error) { - s.mu.Lock() - defer s.mu.Unlock() - - if _, ok := s.store[req.Msg.Id]; !ok { - return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("content %s not found", req.Msg.Id)) - } - delete(s.store, req.Msg.Id) - - return connect.NewResponse(&contentv1.DeleteContentResponse{ - Success: true, - }), nil -} From 5181220cec609532847956d02de19675ebb283ef Mon Sep 17 00:00:00 2001 From: Hasnae Date: Sat, 7 Mar 2026 20:45:09 +1100 Subject: [PATCH 06/25] Update README with end-to-end quick start workflow Cover the full loop: define APIs, scaffold implementations with Claude Code, run benchmarks, and compare results. Co-Authored-By: Claude Opus 4.6 --- README.md | 98 ++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 87 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 294eef7..0776489 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,14 @@ # benchmark -API benchmark platform for comparing backend service implementations. Measures build time, deploy time, and load test performance across different tech stacks, then publishes results to Grafana Cloud for comparison. +API benchmark platform for comparing backend service implementations. Define your APIs, generate implementations across different tech stacks using Claude Code, then measure and compare their performance. ## Prerequisites - [Node.js](https://nodejs.org/) >= 22 - [Docker](https://docs.docker.com/get-docker/) with Compose v2 - [k6](https://grafana.com/docs/k6/latest/set-up/install-k6/) for load testing -- A [Grafana Cloud](https://grafana.com/products/cloud/) account (for publishing results) +- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) for scaffolding implementations +- A [Grafana Cloud](https://grafana.com/products/cloud/) account (optional, for publishing results) ## Setup @@ -17,18 +18,55 @@ npm install ## Quick start +### 1. Define an API + +Create a project under `projects/` with a shared API spec. Each project supports multiple API styles (OpenAPI, GraphQL, Protocol Buffers) and includes k6 load test scripts. + +``` +projects/content-api/ + _shared/ + openapi/ + content-api.yaml # OpenAPI spec + k6/content-api.js # k6 load test script + protobuf/ + content.proto # Protocol Buffers spec + k6/content-api.js # k6 load test script +``` + +### 2. Generate an implementation with Claude Code + +Use the `/scaffold-implementation` slash command in Claude Code to generate a complete implementation for any tech stack: + +``` +> /scaffold-implementation + +# Claude will walk you through: +# 1. Pick a project → content-api +# 2. Pick an API style → protobuf +# 3. Name it → connect-rpc +# 4. Language + framework → go + connect-rpc +# 5. Generate everything → Dockerfile, docker-compose, source code, build files +# 6. Register the target → benchmark.config.json updated +``` + +Repeat for as many stacks as you want to compare (e.g., `spring-boot` in Java, `express` in TypeScript, `ktor` in Kotlin). + +### 3. Run benchmarks + ```bash -# list configured targets +# list all configured targets npm run benchmark -- list -# run a full benchmark (build + deploy + loadtest + collect) -npm run benchmark -- run my-api +# run a full benchmark (build → deploy → loadtest → collect → cleanup) +npm run benchmark -- run content-api/connect-rpc -# run and publish results to Grafana Cloud -npm run benchmark -- run my-api --publish +# benchmark multiple implementations and compare +npm run benchmark -- run content-api/connect-rpc +npm run benchmark -- run content-api/spring-boot +npm run benchmark -- compare results/content-api-connect-rpc-*.json results/content-api-spring-boot-*.json -# compare results from multiple runs -npm run benchmark -- compare results/go-api-2026-03-04T12-00-00-000Z.json results/java-api-2026-03-04T12-00-00-000Z.json +# publish results to Grafana Cloud +npm run benchmark -- run content-api/connect-rpc --publish ``` ## Configuration @@ -259,12 +297,44 @@ Custom scripts receive environment variables defined in `k6.env`. The bundled sc ## Adding a target project -1. Create your project under `examples/` (or anywhere) with a `docker-compose.yml` +The recommended way is to use Claude Code's `/scaffold-implementation` command, which handles everything end-to-end. + +To add one manually: + +1. Create your project under `projects///` with a `docker-compose.yml` 2. Make sure the service has a health endpoint 3. Add a target entry to `benchmark.config.json` 4. Run `npm run benchmark -- list` to verify 5. Run `npm run benchmark -- run ` +## Claude Code commands + +This repo ships with Claude Code slash commands and architecture agents for scaffolding benchmark implementations. + +### `/scaffold-implementation` + +Interactive command that walks you through creating a new benchmark implementation: + +1. Pick a project (e.g., `content-api`) +2. Pick an API style (`openapi`, `graphql`, `protobuf`) +3. Name the implementation in kebab-case (e.g., `connect-rpc`, `spring-boot`) +4. Specify language and framework +5. Generate the full implementation (Dockerfile, docker-compose, source code, build files) +6. Register the target in `benchmark.config.json` +7. Verify with `npm run benchmark -- list` + +If a specialized architecture agent exists at `.claude/agents/-.md`, the command delegates to it for code generation. Otherwise it falls back to generic scaffolding. + +### Architecture agents + +Architecture agents live under `.claude/agents/` and define opinionated, layered code generation guides for specific tech stacks. + +| Agent | Stack | Description | +| --- | --- | --- | +| `connect-rpc-go.md` | Go + Connect RPC | Layered architecture (APP/API/DOMAIN/OUTBOX) with sqlc, River queue, goose migrations, zerolog, godotenv, and buf validate | + +To add a new agent, create `.claude/agents/-.md` following the same conventions. The scaffold command will automatically delegate to it. + ## Project structure ``` @@ -279,7 +349,13 @@ toolkit/ # the benchmark CLI toolkit publish/ # OTLP formatter, Grafana Cloud client report/ # comparison logic, terminal table, JSON writer k6/scripts/ # bundled k6 test scripts -examples/ # target projects for benchmarking +projects/ # benchmark target projects + / + _shared/ # shared API specs and k6 scripts per style + / # individual implementations to benchmark +.claude/ + commands/ # Claude Code slash commands + agents/ # architecture agents for code generation results/ # benchmark output (gitignored) ``` From b5683b8bff901a0f0a5b4a79eebb7afe5161c255 Mon Sep 17 00:00:00 2001 From: Hasnae Date: Sat, 7 Mar 2026 21:22:04 +1100 Subject: [PATCH 07/25] Refine API specs, add define-api command, unify single-port architecture - Add /define-api Claude Code command for interactive API project creation - Split proto into _service, _model, _refs files under package-matching paths - Add buf validate annotations on all request fields - Use google.protobuf.Timestamp instead of string for timestamps - Use google.protobuf.FieldMask for partial updates - Use page_size/page_token cursor pagination instead of limit/offset - Remove health endpoints from all API specs (implementation convention) - Unify gRPC and health on single port 8080 across k6 scripts - Update PROTO_PATH to PROTO_DIR for multi-file proto loading Co-Authored-By: Claude Opus 4.6 --- .claude/agents/connect-rpc-go.md | 6 +- .claude/commands/define-api.md | 138 ++++++++++++++++++ .claude/commands/scaffold-implementation.md | 14 +- README.md | 38 ++++- .../_shared/graphql/schema.graphql | 5 - .../content-api/_shared/openapi/api-spec.yaml | 20 --- .../content-api/_shared/protobuf/buf.yaml | 3 + .../_shared/protobuf/content.proto | 74 ---------- .../protobuf/content/v1/content_model.proto | 26 ++++ .../protobuf/content/v1/content_refs.proto | 14 ++ .../protobuf/content/v1/content_service.proto | 76 ++++++++++ .../_shared/protobuf/k6/content-api.js | 36 +++-- toolkit/k6/scripts/default-grpc.js | 12 +- 13 files changed, 325 insertions(+), 137 deletions(-) create mode 100644 .claude/commands/define-api.md create mode 100644 projects/content-api/_shared/protobuf/buf.yaml delete mode 100644 projects/content-api/_shared/protobuf/content.proto create mode 100644 projects/content-api/_shared/protobuf/content/v1/content_model.proto create mode 100644 projects/content-api/_shared/protobuf/content/v1/content_refs.proto create mode 100644 projects/content-api/_shared/protobuf/content/v1/content_service.proto diff --git a/.claude/agents/connect-rpc-go.md b/.claude/agents/connect-rpc-go.md index 63f765d..9e3b0bf 100644 --- a/.claude/agents/connect-rpc-go.md +++ b/.claude/agents/connect-rpc-go.md @@ -17,7 +17,6 @@ Read the API spec and k6 script from `projects//_shared//` t │ ├── api// │ │ ├── handler.go │ │ ├── mapper.go -│ │ ├── route_check_health.go │ │ ├── route_create_.go │ │ ├── route_get_.go │ │ ├── route_list_.go @@ -950,9 +949,8 @@ WORKDIR /build # buf generate COPY /buf.gen.yaml ./ -RUN mkdir -p proto/content/v1 -COPY _shared/protobuf/content.proto proto/content/v1/content.proto -RUN echo 'version: v2' > proto/buf.yaml +COPY _shared/protobuf/ proto/ +RUN buf dep update proto RUN buf generate proto # sqlc generate diff --git a/.claude/commands/define-api.md b/.claude/commands/define-api.md new file mode 100644 index 0000000..7a45678 --- /dev/null +++ b/.claude/commands/define-api.md @@ -0,0 +1,138 @@ +# Define a new API project + +Guide the user interactively through creating a new API project with shared specs and k6 load test scripts. + +## Step 1 — Name the project + +Ask the user for a project name in kebab-case (e.g., `content-api`, `user-api`, `order-api`). This becomes the directory under `projects/`. + +## Step 2 — Define the domain model + +Ask the user to describe the domain: +- The primary entity name (e.g., `content`, `user`, `order`) +- The fields on the entity, with types (e.g., `title: string`, `price: number`, `status: enum(draft, published, archived)`) +- Which fields are required on create vs optional on update +- Whether the entity uses UUID or auto-increment IDs (default: UUID) + +## Step 3 — Choose API styles + +Ask which API styles to generate specs for. The user can pick one or more: +- `openapi` — REST API with OpenAPI 3.1 spec +- `graphql` — GraphQL schema +- `protobuf` — Protocol Buffers / gRPC service definition + +All selected styles will define the same logical operations over the same domain model, so implementations across styles are directly comparable in benchmarks. + +## Step 4 — Generate the shared specs + +Create the `projects//_shared/` directory with a subdirectory per chosen style. + +Health checks (`GET /health` returning `{"status": "up"}`) are an implementation convention — they are NOT part of the API spec. Do not include health endpoints in any spec. + +Every style must define these standard operations: +1. **Create** — create a new entity (all required fields) +2. **Get** — retrieve a single entity by ID (not found if missing) +3. **List** — paginated list with `limit` and `offset` +4. **Update** — partial update by ID (not found if missing) +5. **Delete** — delete by ID (not found if missing) + +Use the existing `projects/content-api/_shared/` as the reference for conventions and structure. + +### openapi — `_shared/openapi/api-spec.yaml` + +Generate an OpenAPI 3.1.0 spec following the content-api conventions: +- `GET /api/v1/` with `limit` and `offset` query params, returns paginated list with `items`, `total`, `limit`, `offset` +- `POST /api/v1/` with request body, returns 201 +- `GET /api/v1//{id}` returns single item, 404 if not found +- `PUT /api/v1//{id}` partial update, 404 if not found +- `DELETE /api/v1//{id}` returns 204, 404 if not found +- All entities include `id` (UUID), `createdAt`, `updatedAt` (ISO 8601 timestamps) +- Use camelCase for JSON fields + +### graphql — `_shared/graphql/schema.graphql` + +Generate a GraphQL schema following the content-api conventions: +- `Query.(id: ID!)` returning the entity type +- `Query.(limit: Int = 20, offset: Int = 0)` returning a list type with `items`, `total`, `limit`, `offset` +- `Mutation.create(input: CreateInput!)` returning the entity +- `Mutation.update(id: ID!, input: UpdateInput!)` returning the entity +- `Mutation.delete(id: ID!)` returning `Boolean` +- Enum types where applicable +- Timestamps as `DateTime` scalars (ISO 8601 strings) + +### protobuf — `_shared/protobuf//v1/` + +The proto file path must match the package path. For example, package `content.v1` lives at `_shared/protobuf/content/v1/`. + +Split the proto definition into three files: + +- **`_refs.proto`** — Reference types that wrap an ID field with `(buf.validate.field).string.uuid = true`. Refs are for **cross-package** use — when another package needs to reference this entity (e.g., a `comment` service referencing `ContentRef`). Within the same package, request messages use plain `string id` fields directly. +- **`_model.proto`** — The entity model message and any enum types. This is the core domain type. +- **`_service.proto`** — The service definition with all RPCs, plus request/response messages. Imports `_model.proto`. Uses plain `string id` fields in requests within the same package. + +Also generate a `buf.yaml` at `_shared/protobuf/buf.yaml`: +```yaml +version: v2 +deps: + - buf.build/bufbuild/protovalidate +``` + +Proto conventions: +- Package: `.v1` +- Language options for multi-stack compatibility: + - `option go_package = "/v1;v1";` + - `option java_multiple_files = true;` + - `option java_package = "com.labset.benchmark..v1";` + - `option csharp_namespace = "Labset.Benchmark..V1";` +- Service with RPCs: `Create`, `Get`, `List`, `Update`, `Delete` +- Consistent naming: every RPC has a `Request` and `Response` message pair +- Response messages that return an entity wrap it in a named field (e.g., `CreateContentResponse { Content content = 1; }`) +- Within the same package, requests use plain `string id` fields (e.g., `GetContentRequest { string id = 1; }`) +- Ref types in `_refs.proto` are for cross-package references and validate the ID with `(buf.validate.field).string.uuid = true` +- Enum types with `UNSPECIFIED = 0` sentinel +- Update requests use `google.protobuf.FieldMask` for partial updates — send the entity and a mask of fields to update (import `google/protobuf/field_mask.proto`) +- List requests use `page_size` / `page_token` cursor-based pagination; responses return `repeated items` and `next_page_token` +- List response items field is always named `items` for consistency across all entities +- Timestamps as `google.protobuf.Timestamp` (import `google/protobuf/timestamp.proto`) +- **buf validate annotations** on all request fields: + - ID fields: `(buf.validate.field).string.uuid = true` + - String fields: `(buf.validate.field).string = {min_len: N, max_len: N}` as appropriate + - Enum fields: `(buf.validate.field).enum = {defined_only: true, not_in: [0]}` to reject UNSPECIFIED + - Pagination: `page_size` with `{gte: 1, lte: 100}` + - Update: `content` and `update_mask` fields with `required = true` + - Import `buf/validate/validate.proto` in the service proto + +## Step 5 — Generate k6 load test scripts + +For each chosen style, generate a k6 script at `_shared/