Skill: create-go-app
Scaffold a production-ready Go application that wires go-lib.
Orchestration note: This skill is a full-scaffold orchestrator. It runs the steps of
/create-go-repository→/create-go-service→/create-go-handlerin sequence, then generatescmd/,config/,go.mod,go.sum. For adding a single layer to an existing project, use the focused skills directly.
Parameters
| Parameter | Required | Default | Values |
|---|---|---|---|
type |
yes | — | api · consumer · publisher |
name |
yes | — | kebab-case string |
http_framework |
no | gin |
nethttp · gin · chi · mux · echo |
broker |
no | kafka |
kafka · rabbitmq · redis |
messaging |
no | pubsub |
pubsub · queue (asked only for consumer / publisher) |
database |
no | gorm-postgres |
gorm-postgres · gorm-mysql · bun-postgres · bun-mysql · none |
cache |
no | none |
none · redis · memory · couchbase (forced to none when database is bun-*) |
Preflight — run before collecting any inputs
One prerequisite check gates the scaffold. It MUST run before Step 1. Read ${CLAUDE_SKILL_DIR}/references/preflight.md for the exact bash and remediation copy — follow it verbatim.
go-lib is a public Go module (github.com/labspangaea/go-lib). Generated services import github.com/labspangaea/go-lib/... and go mod tidy fetches it from the public proxy — no token, no GOPRIVATE, no replace, no sync step. Preflight only confirms:
- Go toolchain 1.26+ on
PATH(go version). - Network + public proxy reachable so
go mod tidycan downloadgo-lib(go env GOPROXY— the defaulthttps://proxy.golang.org,directis fine; fail if offline /GOPROXY=off). mcp__go-lsp__go_diagnoseregistered for post-write diagnostics.
Collecting Prerequisites — ask one question at a time
Before generating any files, collect missing inputs sequentially. Ask the next question only after receiving the answer to the current one. Skip any question whose answer was already provided as a parameter.
Each enumerated-choice step uses AskUserQuestion so the user sees a structured option list rather than reading parameter values out of a plain prompt. Free-text inputs (service name, entity schema) stay as plain prompts because AskUserQuestion is choice-based. The recommended default is always listed first with (Recommended) appended so the team's preferred shape is one keystroke away.
Step 1 — type (if not supplied):
AskUserQuestion({
question: "What type of service are we scaffolding?",
header: "Service type",
options: [
{ label: "API (Recommended)", description: "HTTP service with huma handlers" },
{ label: "Consumer", description: "Message-broker consumer" },
{ label: "Publisher", description: "Message-broker publisher" }
]
})
Step 2 — name (if not supplied) — free-text, plain prompt:
What is the service name? (kebab-case, e.g.
order-service)
Step 3 — http_framework (if type=api and not supplied):
AskUserQuestion({
question: "Which HTTP framework should the API use?",
header: "HTTP framework",
options: [
{ label: "gin (Recommended)", description: "github.com/gin-gonic/gin — the team's most common choice" },
{ label: "nethttp", description: "Standard library net/http, zero deps" },
{ label: "chi", description: "github.com/go-chi/chi/v5" },
{ label: "echo", description: "github.com/labstack/echo/v4" }
]
})
mux (github.com/gorilla/mux) is still a valid value — it's reachable via the auto-added "Other" entry that accepts free-text. Only four options fit on screen, so the four most common are surfaced and the long-tail option falls through to free-text.
Step 4 — broker (if type is consumer or publisher and not supplied):
AskUserQuestion({
question: "Which message broker should the service use?",
header: "Broker",
options: [
{ label: "Kafka (Recommended)", description: "segmentio/kafka-go — durable, at-least-once, consumer groups share the load" },
{ label: "RabbitMQ", description: "amqp091-go — durable, at-least-once, failed messages requeue" },
{ label: "Redis", description: "Pub/Sub — fire-and-forget fan-out; no persistence, no consumer groups" }
]
})
Step 4b — messaging (if type is consumer or publisher and not supplied):
AskUserQuestion({
question: "Should this service compete for messages or receive every one?",
header: "Messaging",
options: [
{ label: "Queue (Recommended)", description: "queue — each message handled by exactly one replica; failures are redelivered" },
{ label: "Pub/Sub", description: "pubsub — every replica receives every message; use for broadcasts, not work" }
]
})
Map to the messaging parameter (queue / pubsub).
Which one, and why it is not cosmetic
The question is whether adding a replica divides the work or duplicates it.
queue— competing consumers. Each message goes to exactly one member of the group, and a handler that returns an error gets the message again. This is what almost all background work wants: orders to fulfil, emails to send, invoices to generate.pubsub— fan-out. Every subscriber sees every message. Right for facts many listeners care about: cache invalidations, dashboard updates.
Getting it wrong is quiet rather than loud. A queue workload on pubsub with
two replicas processes everything twice — double charges, double emails — and
nothing errors. A pubsub workload on queue means only one replica ever hears
about it, so the others hold stale caches.
Default to queue unless the service genuinely broadcasts. pubsub remains
the default parameter value only for backwards compatibility with existing
scaffolds.
Note the pairing with broker: on pubsub the redis backend is
PUBLISH/SUBSCRIBE and drops the consumer group entirely, whereas on queue
it is Redis Streams, which has real groups, a durable backlog and redelivery.
broker=redis therefore means something quite different under each.
What the broker choice actually decides
Not the library — the delivery guarantee. Kafka and RabbitMQ are durable work
queues: a consumer group shares the load across replicas, and a message whose
handler returns an error is redelivered (kafka leaves the offset uncommitted,
rabbitmq requeues). Redis is PUBLISH/SUBSCRIBE, which is neither.
Two consequences of picking Redis that surprise people, both worth saying out loud before the choice is made:
- The consumer group is ignored.
KAFKA_CONSUMER_GROUPis read from config and logged at startup, but go-lib's redis adapter discards it — every replica receives every message, so scaling out duplicates work instead of sharing it. - Messages published while nothing is subscribed are gone. There is no backlog, so a rolling deploy drops whatever arrives during the gap, and a handler error has nothing to redeliver from.
Redis is the right choice for live notifications, cache invalidation, and
dashboard fan-out. It is the wrong choice if losing a message matters. See
go-lib pubsub/README.md for the full comparison.
Step 5 — database (if type is api or consumer and not supplied):
AskUserQuestion({
question: "Which database backend?",
header: "Database",
options: [
{ label: "PostgreSQL via GORM (Recommended)", description: "gorm-postgres — default for new services" },
{ label: "MySQL via GORM", description: "gorm-mysql" },
{ label: "PostgreSQL via bun", description: "bun-postgres — SQL-first query builder, no GORM in the binary" },
{ label: "MySQL via bun", description: "bun-mysql" }
]
})
none (no persistence layer) is still a valid value — it reaches the parameter
through the auto-added "Other" free-text entry. Only four options fit on screen, so
the four persistence-backed choices are surfaced.
Choosing GORM or bun
Both produce the same port interface, the same service layer, and the same handler — the choice is confined to the repository adapter and the composition root. What differs:
gorm-* |
bun-* |
|
|---|---|---|
| Library | go-lib/db/repo |
go-lib/db/bunrepo |
| Cache wrapper | redis · memory · couchbase | not available |
| Query style | GORM's chainable API | SQL-first builder |
| PostgreSQL DSN | host=… port=… user=… |
postgres://… URL form |
Default to GORM unless the user asks for bun by name, wants a lightweight dependency graph, or wants explicit SQL. Say so plainly if they pick bun and also want a cache — that combination does not exist yet (see Step 6).
Step 6 — cache (if database is gorm-* and not supplied):
Skip this question entirely when database is bun-* or none, and set
cache=none. For none there is nothing to cache. For bun-* the cache
decorator (repo.CachedRepo in go-lib/db/repo) is generic over a repository
exposing DB(ctx) *gorm.DB, so it cannot wrap a bun repository — offering the
question would collect an answer that cannot be honoured. If the user explicitly
asks for bun and a cache, tell them the pairing isn't supported yet and let them
choose: GORM with a cache, or bun without one.
AskUserQuestion({
question: "Should the repository have a cache wrapper?",
header: "Cache",
options: [
{ label: "None (Recommended)", description: "Skip cache wiring; direct SQL only" },
{ label: "Redis", description: "Redis cache backend" },
{ label: "In-memory", description: "In-process cache (single-pod only)" },
{ label: "Couchbase", description: "Couchbase cache backend" }
]
})
Step 7 — entity schema (always ask, unless the user already provided it in the invocation message) — free-text, plain prompt:
Provide the entity schema. Accepted formats below.
Mapping selected labels → parameter values
When the user picks an option above, the user-friendly label is the surface and the parameter value is what gets written into the PARAMS JSON. Use this mapping:
| Step | Selected label | Parameter value |
|---|---|---|
| 1 | API / Consumer / Publisher | api / consumer / publisher |
| 3 | gin / nethttp / chi / echo / Other(mux) |
gin / nethttp / chi / echo / mux |
| 4 | Kafka / RabbitMQ / Redis | kafka / rabbitmq / redis |
| 4b | Queue / Pub/Sub | queue / pubsub |
| 5 | PostgreSQL via GORM / MySQL via GORM / PostgreSQL via bun / MySQL via bun / Other(none) |
gorm-postgres / gorm-mysql / bun-postgres / bun-mysql / none |
| 6 | None / Redis / In-memory / Couchbase | none / redis / memory / couchbase |
| Item | Default |
|---|---|
| Git module path prefix | github.com/labspangaea — use silently |
| Go version | 1.26.2 — use silently |
Template Syntax
Templates use Go text/template directives. Beyond simple substitution ({{.Entity}}, {{.Module}}, {{range .Fields}}), some templates contain {{if eq .Cache "redis"}}…{{end}} blocks that gate sections by parameter selection.
MUST use the render-file tool for every file — never evaluate conditionals mentally. The tool runs the real Go text/template engine, so every {{if}} branch is evaluated correctly regardless of nesting depth. See Template Rendering below.
The tools/smoke/ runner uses the same engine and the same Fixture/Params struct, so any file the render tool produces will also pass the smoke check.
Schema Input
Accepted formats for the entity schema:
| Format | Example |
|---|---|
| JSON example | {"name": "iPhone 15", "price": 999.99, "in_stock": true} |
| SQL CREATE TABLE | CREATE TABLE products (id VARCHAR(36) PRIMARY KEY, name VARCHAR(255) NOT NULL, ...) |
| Swagger / OpenAPI | properties: block from a schema object |
| Postman collection | request body JSON example |
Parse the schema into {{.Fields}} (see Template Variables below) before substituting into templates.
System fields — always injected, never from user schema
| Field | Domain type | Model type | GORM tag | bun tag |
|---|---|---|---|---|
ID |
string |
string |
gorm:"primaryKey;column:id" |
bun:"id,pk" |
CreatedAt |
time.Time |
int64 |
gorm:"column:created_at;autoCreateTime:milli" |
bun:"created_at,notnull" |
UpdatedAt |
time.Time |
int64 |
gorm:"column:updated_at;autoUpdateTime:milli" |
bun:"updated_at,notnull" |
bun has no autoCreateTime equivalent, so on the bun path the timestamps are set
in code: service.go stamps both when zero, and toModel guards IsZero() before
calling UnixMilli(). That guard is not cosmetic — UnixMilli() on a zero
time.Time yields -6795364578871, which bun would write to the row verbatim.
Strip these from the parsed field list if the user included them.
Type mapping
| SQL / JSON / OAS type | Go type |
|---|---|
VARCHAR, TEXT, CHAR, string |
string |
INT, BIGINT, INTEGER, number (integer) |
int64 |
FLOAT, DOUBLE, DECIMAL, number (float) |
float64 |
BOOLEAN, BOOL, TINYINT(1), boolean |
bool |
TIMESTAMP, DATETIME, string (date-time) |
time.Time |
UUID |
string |
Nullable fields
If a column allows NULL / nullable: true / absent from required array: prepend * to the Go type (e.g. string → *string).
validate tag assignment
Used internally by FieldDef to drive template conditionals — not emitted as a Go struct tag.
| Condition | Value |
|---|---|
NOT NULL or in required array |
required |
VARCHAR(n) / maxLength: n |
required,max=N |
| nullable / optional | `` (empty string) |
The templates use {{if not (isRequired .Validate)}} to decide whether to add ,omitempty to the json tag, and {{if parseMaxLength .Validate}} to add maxLength:"N". No validate:"..." struct tag is ever emitted.
Template Rendering
MUST render every file through the tool — do not write template output manually.
Build the params JSON once
After collecting all inputs (Step 1–7), assemble the JSON object. Use it for every subsequent render call — do not re-assemble per file.
SKILL_REFS="${CLAUDE_SKILL_DIR}/references"
RENDER="go run ${CLAUDE_SKILL_DIR}/../../tools/render-file/main.go"
PARAMS=$(cat <<'EOF'
{
"Name": "order-service",
"Module": "github.com/labspangaea/order-service",
"Entity": "Order",
"EntityLower": "order",
"ApperrBase": 1000,
"Type": "api",
"HTTPFramework": "gin",
"Broker": "kafka",
"Messaging": "queue",
"Database": "gorm-postgres",
"Cache": "redis",
"Fields": [
{"Name":"CustomerID","GoType":"string","JSONName":"customer_id","DBColumn":"customer_id","Validate":"required"},
{"Name":"Total","GoType":"float64","JSONName":"total","DBColumn":"total","Validate":"required"},
{"Name":"Note","GoType":"*string","JSONName":"note","DBColumn":"note","Validate":""}
]
}
EOF
)
Render each file
$RENDER -template "$SKILL_REFS/config.go.tmpl" -params "$PARAMS" -output config/config.go
$RENDER -template "$SKILL_REFS/domain.go.tmpl" -params "$PARAMS" -output internal/domain/order.go
$RENDER -template "$SKILL_REFS/port.go.tmpl" -params "$PARAMS" -output internal/port/order.go
$RENDER -template "$SKILL_REFS/port_service.go.tmpl" -params "$PARAMS" -output internal/port/order_service.go
$RENDER -template "$SKILL_REFS/service.go.tmpl" -params "$PARAMS" -output internal/service/order.go
$RENDER -template "$SKILL_REFS/service_stub.go.tmpl" -params "$PARAMS" -output internal/service/stub/order.go
$RENDER -template "$SKILL_REFS/service_factory_default.go.tmpl" -params "$PARAMS" -output internal/service/factory_default.go
$RENDER -template "$SKILL_REFS/service_factory_stub.go.tmpl" -params "$PARAMS" -output internal/service/factory_stub.go
$RENDER -template "$SKILL_REFS/apperr.go.tmpl" -params "$PARAMS" -output internal/apperr/order.go
$RENDER -template "$SKILL_REFS/repository.go.tmpl" -params "$PARAMS" -output internal/adapter/outbound/repository/order.go
# …or, when Database is bun-postgres / bun-mysql, render the bun adapter to the
# SAME path instead. The two are alternatives, never both:
# $RENDER -template "$SKILL_REFS/repository_bun.go.tmpl" -params "$PARAMS" -output internal/adapter/outbound/repository/order.go
$RENDER -template "$SKILL_REFS/httphandler.go.tmpl" -params "$PARAMS" -output internal/adapter/inbound/httphandler/order.go
$RENDER -template "$SKILL_REFS/httphandler_dto.go.tmpl" -params "$PARAMS" -output internal/adapter/inbound/httphandler/order_dto.go
$RENDER -template "$SKILL_REFS/main_api_gin.go.tmpl" -params "$PARAMS" -output cmd/order-service/main.go
$RENDER -template "$SKILL_REFS/health.go.tmpl" -params "$PARAMS" -output internal/adapter/inbound/httphandler/health.go
$RENDER -template "$SKILL_REFS/docker-compose.yml.tmpl" -params "$PARAMS" -output docker-compose.yml
$RENDER -template "$SKILL_REFS/.env.tmpl" -params "$PARAMS" -output .env
$RENDER -template "$SKILL_REFS/.gitignore.tmpl" -params "$PARAMS" -output .gitignore
$RENDER -template "$SKILL_REFS/Dockerfile.tmpl" -params "$PARAMS" -output Dockerfile
$RENDER -template "$SKILL_REFS/seed.go.tmpl" -params "$PARAMS" -output cmd/seed/main.go
Custom template functions available
The render-file tool registers extra functions used by the dev-env templates:
| Function | Signature | Used in |
|---|---|---|
seq |
seq(n int) []int → [0..n-1] |
seed.go.tmpl — {{range $i, $_ := (seq 25)}} |
seedValue |
seedValue(goType string, idx int) string |
seed.go.tmpl — type-appropriate placeholder |
hasFieldType |
hasFieldType(goType string, fields []FieldDef) bool |
seed.go.tmpl — conditional time import |
hasNullableField |
hasNullableField(fields []FieldDef) bool |
seed.go.tmpl — conditional ptr import (only emitted when ≥1 nullable/*T field, since seedValue only references ptr.Of(...) for those) |
dbOpen |
dbOpen(database string) string |
config.go.tmpl — postgres.Open vs mysql.Open |
msgPkg |
msgPkg(messaging string) string → pubsub · queue |
the seven broker templates — package name, type qualifier and import path all derive from it |
consumeFn |
consumeFn(messaging string) string → Subscribe · Consume |
main_consumer_* — the one method name that differs between the two models |
isBun |
isBun(database string) bool |
every template that imports db/repo — swaps in db/bunrepo; main_* — swaps the DB-open block |
dbEngine |
dbEngine(database string) string → postgres · mysql · none |
docker-compose.yml.tmpl, .env.tmpl — the server, with the ORM half dropped, so those blocks did not double when bun arrived |
isRequired |
isRequired(validate string) bool |
httphandler_dto.go.tmpl — ,omitempty on optional fields |
parseMaxLength |
parseMaxLength(validate string) int |
httphandler_dto.go.tmpl — maxLength:"N" struct tag |
Output
Generate {name}/ as a sibling directory to go-lib (not inside it). If the directory already exists, MUST ask before overwriting.
Architecture
Hexagonal (Ports & Adapters):
cmd/{name}/main.go — composition root, wires all deps
config/config.go — env-based config
internal/domain/{entity}.go — business types, errors, value objects
internal/port/
{entity}.go — {Entity}Repository port (driven side)
{entity}_service.go — (api) {Entity}Service port — handler contract
internal/service/
{entity}.go — real use cases; imports port only
stub/{entity}.go — (api) //go:build stub; canned-response stub
factory_default.go — (api) //go:build !stub; returns real service
factory_stub.go — (api) //go:build stub; switches on cfg.ServiceBackend
internal/adapter/inbound/
httphandler/{entity}.go — (api) huma handler funcs
httphandler/{entity}_dto.go — (api) huma input/output structs, huma-native validation
subscriber/handler.go — (consumer) message handlers
internal/adapter/outbound/
repository/{entity}.go — DB implementation of port interface
publisher/publisher.go — (publisher) message publisher
client/ — outbound HTTP clients
SOLID Constraints
| Principle | Constraint | Do | Don't |
|---|---|---|---|
| SRP | Single responsibility per layer | handlers serialize HTTP only | handlers calling db.Query(...) |
| OCP | Extend by adding files | new internal/service/refund.go |
editing existing switch blocks |
| DIP | service/ imports port/ only |
port.OrderRepository |
import ".../adapter/repository" in service |
| ISP | Port interfaces 1–3 methods | split OrderReader / OrderWriter |
one interface with 10 methods |
| LSP | All port impls substitutable | swap Redis cache for Nop in tests | impl that panics on unsupported method |
Wiring Rules
Before generating cmd/{name}/main.go and the surrounding internal/* files, read ${CLAUDE_SKILL_DIR}/references/wiring-rules.md and apply every rule. The full MUST / MUST NOT table (Foundational / HTTP / Error codes / Repository / Service / Cross-cutting) lives there; templates in references/*.tmpl already conform. Top three most-violated rules:
- HTTP error dispatch (MUST):
return nil, errfrom huma handlers — never serialize errors yourself. - No domain error sentinels (MUST NOT): no
var ErrNotFound = errors.New(...)indomain/. All named errors live ininternal/apperr/{{.EntityLower}}.goasCodeErrEnumconstants. - No hardcoded timeouts (MUST NOT): no
time.Second/time.Millisecondliterals inmain.go— timeouts come fromcfg.*viaenvDurationOr.
Cache Backend Wiring (only when cache != none, which implies database is gorm-*)
When cache != none, the per-backend main.go block is injected between the DB-open call and repository.NewCached. The exact code recipes per backend (redis / memory / couchbase) and the cache=none fallthrough live at ${CLAUDE_SKILL_DIR}/references/cache-backends.md — read that file when emitting the cache wiring.
The same recipes are also rendered by /go-scaffolder:create-go-repository Step 7 for the single-repository case; keep the reference file in sync if either changes.
File Generation — Decision Table
type |
database |
Files generated |
|---|---|---|
api |
gorm-* or bun-* |
all + repository/, apperr/ |
api |
none |
all except repository/; apperr/ included |
consumer |
gorm-* or bun-* |
all + repository/, apperr/, subscriber/ |
consumer |
none |
all except repository/ and apperr/; subscriber/ included |
publisher |
any | all + publisher/; no repository/ |
File Generation Templates
{http_framework} and {broker} substituted with parameter value.
| type | Output path | Template | Condition |
|---|---|---|---|
| all | config/config.go |
config.go.tmpl |
— |
| all | internal/domain/{entity}.go |
domain.go.tmpl |
— |
| all | internal/port/{entity}.go |
port.go.tmpl |
— |
api |
internal/port/{entity}_service.go |
port_service.go.tmpl |
— |
| all | internal/service/{entity}.go |
service.go.tmpl |
— |
api |
internal/service/stub/{entity}.go |
service_stub.go.tmpl |
— |
api |
internal/service/factory_default.go |
service_factory_default.go.tmpl |
— |
api |
internal/service/factory_stub.go |
service_factory_stub.go.tmpl |
— |
api |
cmd/{name}/main.go |
main_api_{http_framework}.go.tmpl |
— |
api |
internal/adapter/inbound/httphandler/{entity}.go |
httphandler.go.tmpl |
— |
api |
internal/adapter/inbound/httphandler/{entity}_dto.go |
httphandler_dto.go.tmpl |
— |
api |
internal/adapter/outbound/repository/{entity}.go |
repository.go.tmpl (gorm) · repository_bun.go.tmpl (bun) |
database ≠ none |
api |
internal/apperr/{entity}.go |
apperr.go.tmpl |
— |
consumer |
internal/apperr/{entity}.go |
apperr.go.tmpl |
database ≠ none (repository.go.tmpl references apperr.ErrXxxNotFound) |
consumer |
cmd/{name}/main.go |
main_consumer_{broker}.go.tmpl |
— |
consumer |
internal/adapter/inbound/subscriber/handler.go |
subscriber.go.tmpl |
— |
consumer |
internal/adapter/outbound/repository/{entity}.go |
repository.go.tmpl (gorm) · repository_bun.go.tmpl (bun) |
database ≠ none |
publisher |
cmd/{name}/main.go |
main_publisher_{broker}.go.tmpl |
— |
publisher |
internal/adapter/outbound/publisher/publisher.go |
(write inline) | — |
api |
internal/adapter/inbound/httphandler/health.go |
health.go.tmpl |
— |
| all | Dockerfile |
Dockerfile.tmpl |
— |
| all | docker-compose.yml |
docker-compose.yml.tmpl |
— |
| all | .env |
.env.tmpl |
— |
| all | .gitignore |
.gitignore.tmpl |
— |
api, consumer |
cmd/seed/main.go |
seed.go.tmpl |
database ≠ none |
Template Variables
| Variable | Derived from | Example (name=order-service) |
|---|---|---|
{{.Name}} |
name param |
order-service |
{{.Module}} |
module prefix + name |
github.com/labspangaea/order-service |
{{.Entity}} |
name → PascalCase singular |
Order |
{{.EntityLower}} |
{{.Entity}} → lowercase |
order |
{{.Fields}} |
parsed from user schema (see Schema Input) | []FieldDef — each has .Name, .GoType, .JSONName, .DBColumn, .Validate |
FieldDef attributes
| Attribute | Description | Example |
|---|---|---|
.Name |
PascalCase Go identifier | ProductCode, UserID |
.GoType |
Go type string | string, int64, *int64, bool, float64, time.Time |
.JSONName |
snake_case json/query tag | product_code |
.DBColumn |
snake_case column name (gorm:"column:…" / bun:"…") |
product_code |
.Validate |
constraint descriptor used by template functions (isRequired, parseMaxLength) |
required, required,max=255, `` (empty = optional) |
Templates use {{range .Fields}} to iterate. Use {{if eq .GoType "string"}} to conditionally include string-only filters.
Code Style
Local style + error-wrapping rules baked into the templates live at ${CLAUDE_SKILL_DIR}/references/code-style.md. Read it before authoring or hand-tweaking generated Go files. The wider cross-cutting Go rulebook is in labspangaea-code-standard/references/go.md.
Post-Generation
go mod init {module_path}
go mod tidy # fetches go-lib (github.com/labspangaea/go-lib) and deps from the public proxy — no replace directive, no token.
gofmt -w . # MUST run — templates emit hand-aligned struct fields; without this step every editor save produces noisy diffs.
# Verify both build modes compile clean:
go build ./... # production — stub package excluded
go build -tags=stub ./... # stub-mode — both factory variants visible
Stub mode — FE/BE parallel development
Default .env ships SERVICE_BACKEND=stub. Boot the API without postgres / mysql / redis and serve canned, shape-correct responses (huma OpenAPI + Stoplight Elements UI live at /docs):
go run -tags=stub ./cmd/{name} # FE points at http://localhost:8080/docs
When backend business logic is ready, switch to the real service:
docker compose up -d postgres redis # whatever the real service needs
echo "SERVICE_BACKEND=real" >> .env
go run -tags=stub ./cmd/{name} # OR: go run ./cmd/{name} (always real, no tag)
Removing the stub once business logic is final
The stub is structurally quarantined for mechanical removal:
- Soft removal (keep code, never run): Set
SERVICE_BACKEND=realin prod.env. Build without-tags=stub— the stub package isn't linked; the env var is ignored. Zero code change. - Hard removal (delete code): (1)
rm -rf internal/service/stub/; (2)rm internal/service/factory_stub.go; (3) deleteServiceBackendfromconfig/config.go(struct + Load); (4) deleteSERVICE_BACKEND=...from.env; (5) (optional) inlinereturn New(repo)fromfactory_default.gointocmd/{name}/main.goand deletefactory_default.go. After step 4 the project compiles identically to a pre-stub scaffold.
MUST: After writing each .go file, run mcp__go-lsp__go_diagnose before reporting done.
Dev environment bootstrap
Once go build passes, walk the user through bringing the stack up with Docker Compose, seeding the DB, and hitting the local endpoints. The exact command sequence (image build → health-wait → seed → smoke /healthz) plus the host-vs-container DSN explanation is in ${CLAUDE_SKILL_DIR}/references/dev-bootstrap.md.
Telemetry posture & OpenAPI
Both are env-driven, runtime-configurable, and rarely require code changes. Read these reference files when wiring the matching parts of main.go / httphandler/:
- Telemetry (env-driven OTLP exporter posture, log bridge, common SaaS configs):
${CLAUDE_SKILL_DIR}/references/telemetry.md - OpenAPI / Swagger UI (huma endpoints, validation rules, response envelope):
${CLAUDE_SKILL_DIR}/references/openapi.md
Example
/create-go-app type=api name=order-service
order-service/
├── cmd/order-service/main.go
├── config/config.go
├── internal/
│ ├── domain/order.go
│ ├── port/
│ │ ├── order.go # OrderRepository (driven port)
│ │ └── order_service.go # OrderService (handler-facing port)
│ ├── service/
│ │ ├── order.go # real service
│ │ ├── factory_default.go # //go:build !stub — wires real
│ │ ├── factory_stub.go # //go:build stub — switches on env
│ │ └── stub/order.go # //go:build stub — canned responses
│ ├── apperr/order.go
│ └── adapter/
│ ├── inbound/httphandler/order.go
│ ├── inbound/httphandler/order_dto.go
│ ├── inbound/httphandler/health.go
│ └── outbound/repository/order.go
├── go.mod
└── go.sum