From Requirements to Go Service Specs
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.
Summary
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.
Recipe
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:
- Greenfield endpoints in chi, gin, or echo services
- gRPC methods before
.protoreview - Cross-team integrations needing written contracts
- Compliance features requiring audit trails in the spec
Working Example
Spec 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:
- Spec error codes match Go sentinels and HTTP status mapping
- Interfaces (
InvoiceReader,PDFRenderer) follow handler-service-repository layering - OpenAPI documents stable machine codes, not
err.Error()text - NFR idempotency is implied by read-only GET on immutable invoice revision
Deep Dive
How It Works
| 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
- Product drafts story with acceptance criteria.
- Tech lead adds API and data draft; flags conflicts.
- Consumers (mobile, web, data) comment on contracts.
- SRE reviews NFR feasibility and observability hooks.
- Spec marked approved; tickets reference spec version.
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.
SLO Table Template
| 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 |
Go Notes
// Prefer context on every I/O boundary - spec should say timeouts.
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()- Specs should name
contextdeadlines matching NFR latency budgets. - List which errors are retryable (500, 503) vs not (403, 404).
- Note
go generatefor oapi-codegen orbuf generatein the build section.
Gotchas
- Vague "real-time" - Product means seconds; engineering hears milliseconds. Fix: Write explicit p99 and refresh model in the spec.
- Missing idempotency - POST retries double-charge. Fix: Document idempotency keys or natural keys in the API section.
- Error text as contract - Clients parse
Error()strings. Fix: Spec requires stablecodefield; map in handler layer. - Scope hidden in footnotes - "We'll add auth later" becomes production debt. Fix: Out-of-scope header per story.
- Spec after code - Review becomes rubber stamp. Fix: No sprint start without approved spec link on epic.
- PII without classification - Compliance blocks late. Fix: Data section tags fields (PII, financial) and retention days.
Alternatives
| 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 |
FAQs
How long should a service spec be?
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.
Who approves the spec?
Product approves scope and acceptance criteria.
Engineering approves feasibility.
Consuming teams approve breaking changes.
Should specs include SQL schemas?
Include logical models and migration notes.
Full DDL can live in a linked migration file referenced by version.
How do I spec async jobs?
Document enqueue trigger, payload schema, retry policy, dead-letter behavior, and user-visible status API.
What about feature flags?
Name the flag key, default off/on per environment, and kill-switch owner in the NFR section.
Do spikes replace specs?
Spikes answer unknowns; they produce a revised spec section, not a substitute for NFRs.
How do I handle shared libraries?
Spec lists module path and minimum version; note breaking changes for downstream services.
Should SLOs be in the spec or ops doc?
Customer-facing SLOs in the spec.
Runbooks and alert thresholds link from the spec NFR section.
How often do specs version?
Bump spec_version on any contract change.
Consumers subscribe to changelog entries in release notes.
Can AI draft specs from stories?
Use drafts for speed, but human review for errors, authz, and data retention is mandatory.
Related
- Stakeholder Collaboration Basics - templates for stories and status
- Estimating Go Work: Modules, Risk & Unknowns - sizing from spec scope
- Handler-Service-Repository Layering - package layout for handlers
- Architecture Decision Records for Go Teams - when specs need ADR backing
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).