From Requirements to Go Service Specs
Product hands you user stories.
Search across all documentation pages
Product hands you user stories.
Engineering needs handler contracts, storage shapes, and measurable SLOs before anyone opens main.go.
This workflow turns ambiguous requirements into a Go service spec stakeholders can approve and ICs can implement without rework.
A complete Go service spec names HTTP or gRPC surfaces, persistence, non-functional requirements, and error contracts.
Stories supply customer voice; the spec supplies testable truth.
Review the spec with product and consumers before sprint commitment.
Iterate in the shared doc; generate code from OpenAPI or protobuf only after signatures stabilize.
Minimal spec skeleton for a new chi HTTP endpoint.
## Feature: Invoice PDF export
### API
- `GET /v1/invoices/{id}/pdf` → 200 `application/pdf`
- Errors: 403 `invoice_unpaid`, 404 `invoice_not_found`, 500 `render_failed`
### Data
- Read `invoices` table (Postgres); no new columns
- Ephemeral PDF bytes; optional S3 cache key `pdf/{invoice_id}`
### NFRs
- p99 latency 2s @ 100 RPS (staging)
- Idempotent: repeat GET returns same bytes for same invoice revision
- Auth: JWT scope `billing:read`
### Out of scope
- Email delivery, batch exportWhen to reach for this:
.proto reviewSpec excerpt plus matching Go handler signature and error mapping.
// internal/api/export.go
package api
import (
"context"
"errors"
"net/http"
"github.com/go-chi/chi/v5"
)
var (
ErrInvoiceUnpaid = errors.New("invoice_unpaid")
ErrInvoiceNotFound = errors.New("invoice_not_found")
ErrRenderFailed = errors.New("render_failed")
)
type InvoiceReader interface {
GetInvoice(ctx context.Context, id string) (Invoice, error)
}
type PDFRenderer interface {
RenderInvoicePDF(ctx context.Context, inv Invoice) ([]byte, error)
}
func MountExport(r chi.Router, inv InvoiceReader, pdf PDFRenderer) {
r.Get("/v1/invoices/{id}/pdf", func(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
inv, err := inv.GetInvoice(r.Context(), id)
if err != nil {
writeErr(w, mapErr(err))
return
}
if inv.Status != "paid" {
writeErr(w, http.StatusForbidden, ErrInvoiceUnpaid)
return
}
bytes, err := pdf.RenderInvoicePDF(r.Context(), inv)
if err != nil {
writeErr(w, http.StatusInternalServerError, ErrRenderFailed)
return
}
w.Header().Set("Content-Type", "application/pdf")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(bytes)
})
}# api/openapi.yaml (excerpt)
paths:
/v1/invoices/{id}/pdf:
get:
operationId: getInvoicePdf
responses:
"200":
content:
application/pdf:
schema: { type: string, format: binary }
"403":
content:
application/json:
schema:
properties:
code: { enum: [invoice_unpaid] }What this demonstrates:
InvoiceReader, PDFRenderer) follow handler-service-repository layeringerr.Error() text| Spec section | Product input | Engineering output |
|---|---|---|
| API | User journeys, screens | Paths, methods, payloads, status codes |
| Data | Entities named in stories | Tables, events, retention, PII class |
| NFRs | "Fast", "secure", "reliable" | p99, RPS, authz, RTO/RPO, audit |
| Errors | Edge cases in UX | Stable codes, retry policy, support macros |
| Scope | MVP vs later | Explicit out-of-scope list |
Story refinement loop
gRPC variant
Replace OpenAPI paths with .proto RPCs, message fields, and google.rpc.Status details.
Keep the same NFR and data sections.
Generated Go stubs land in gen/ or a versioned module.
| SLI | Target | Measurement |
|---|---|---|
| Availability | 99.9% monthly | 5xx rate excluding client errors |
| Latency | p99 ≤ 2s | http_server_duration histogram |
| Correctness | 0 mis-billed exports | reconciliation job diff |
// Prefer context on every I/O boundary - spec should say timeouts.
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()context deadlines matching NFR latency budgets.go generate for oapi-codegen or buf generate in the build section.Error() strings. Fix: Spec requires stable code field; map in handler layer.| Alternative | Use When | Don't Use When |
|---|---|---|
| OpenAPI-first | HTTP services, multiple consumers | Internal-only gRPC with buf already standard |
| Protobuf-first | gRPC, strong codegen pipeline | Simple CRUD with one web client |
| Example-driven spec | Small CLI or library | Multi-team HTTP with legal review |
| ADR-only | Single-team fork with no external API | Customer-facing contracts needing UX alignment |
Two to four pages per epic.
Enough for API tables, data diffs, NFRs, and errors.
Deep implementation belongs in design docs linked from the spec.
Product approves scope and acceptance criteria.
Engineering approves feasibility.
Consuming teams approve breaking changes.
Include logical models and migration notes.
Full DDL can live in a linked migration file referenced by version.
Document enqueue trigger, payload schema, retry policy, dead-letter behavior, and user-visible status API.
Name the flag key, default off/on per environment, and kill-switch owner in the NFR section.
Spikes answer unknowns; they produce a revised spec section, not a substitute for NFRs.
Spec lists module path and minimum version; note breaking changes for downstream services.
Customer-facing SLOs in the spec.
Runbooks and alert thresholds link from the spec NFR section.
Bump spec_version on any contract change.
Consumers subscribe to changelog entries in release notes.
Use drafts for speed, but human review for errors, authz, and data retention is mandatory.
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).
Reviewed by Chris St. John·Last updated Jul 16, 2026