@nakedev/go-scaffold 0.1.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +133 -44
  3. package/dist/commands/auth.js +116 -11
  4. package/dist/commands/create.js +13 -1
  5. package/dist/commands/generate.js +21 -11
  6. package/dist/commands/method.js +32 -3
  7. package/dist/commands/observability.js +114 -0
  8. package/dist/commands/rbac.js +19 -2
  9. package/dist/commands/undo.js +331 -0
  10. package/dist/commands/worker.js +92 -32
  11. package/dist/index.js +366 -63
  12. package/dist/prompts/auth-wizard.js +29 -0
  13. package/dist/prompts/create-wizard.js +5 -2
  14. package/dist/prompts/generate-wizard.js +57 -0
  15. package/dist/prompts/worker-wizard.js +25 -0
  16. package/dist/templates/auth-manifest.js +20 -3
  17. package/dist/templates/create-manifest.js +13 -20
  18. package/dist/templates/observability-manifest.js +24 -0
  19. package/dist/templates/rbac-manifest.js +1 -0
  20. package/dist/templates/worker-manifest.js +23 -6
  21. package/dist/utils/auth-patcher.js +96 -21
  22. package/dist/utils/config.js +58 -10
  23. package/dist/utils/gocheck.js +57 -5
  24. package/dist/utils/golangci-patcher.js +73 -0
  25. package/dist/utils/gomod-patcher.js +53 -0
  26. package/dist/utils/main-patcher.js +58 -4
  27. package/dist/utils/marker-patch.js +125 -3
  28. package/dist/utils/method-patcher.js +17 -2
  29. package/dist/utils/module-location.js +37 -1
  30. package/dist/utils/naming.js +50 -2
  31. package/dist/utils/observability-patcher.js +107 -0
  32. package/dist/utils/platform-patcher.js +98 -12
  33. package/dist/utils/rbac-patcher.js +60 -10
  34. package/package.json +3 -5
  35. package/templates/add/auth/internal/app/user/errors.go.hbs +7 -0
  36. package/templates/add/auth/internal/app/user/handler.go.hbs +64 -23
  37. package/templates/add/auth/internal/app/user/jwt.go.hbs +31 -7
  38. package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +39 -0
  39. package/templates/add/auth/internal/app/user/model/identity.go.hbs +3 -0
  40. package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +26 -0
  41. package/templates/add/auth/internal/app/user/model/user.go.hbs +9 -1
  42. package/templates/add/auth/internal/app/user/repository.go.hbs +64 -11
  43. package/templates/add/auth/internal/app/user/repository_test.go.hbs +192 -0
  44. package/templates/add/auth/internal/app/user/service.go.hbs +105 -21
  45. package/templates/add/auth/internal/app/user/service_test.go.hbs +81 -2
  46. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +9 -138
  47. package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +144 -0
  48. package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +147 -0
  49. package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +21 -19
  50. package/templates/add/auth/internal/shared/middleware/ratelimit_memory.go.hbs +63 -0
  51. package/templates/add/auth/internal/shared/middleware/ratelimit_redis.go.hbs +32 -0
  52. package/templates/add/auth/migrations/create_auth_tokens.down.sql.hbs +1 -0
  53. package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +16 -0
  54. package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -1
  55. package/templates/add/auth/migrations/create_identities.up.sql.hbs +9 -5
  56. package/templates/add/auth/migrations/create_login_throttle.down.sql.hbs +1 -0
  57. package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +10 -0
  58. package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -1
  59. package/templates/add/auth/migrations/create_users.up.sql.hbs +13 -2
  60. package/templates/add/rbac/internal/app/role/dto.go.hbs +8 -3
  61. package/templates/add/rbac/internal/app/role/handler.go.hbs +4 -1
  62. package/templates/add/rbac/internal/app/role/model/permission.go.hbs +3 -0
  63. package/templates/add/rbac/internal/app/role/model/role.go.hbs +4 -0
  64. package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +3 -0
  65. package/templates/add/rbac/internal/app/role/repository.go.hbs +12 -11
  66. package/templates/add/rbac/internal/app/role/repository_test.go.hbs +176 -0
  67. package/templates/add/rbac/internal/app/role/service.go.hbs +7 -0
  68. package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +19 -0
  69. package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +1 -1
  70. package/templates/add/rbac/migrations/add_roles.down.sql.hbs +5 -5
  71. package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -11
  72. package/templates/add/worker/cmd/worker/main.go.hbs +30 -24
  73. package/templates/add/worker/internal/platform/mail/mail.go.hbs +21 -0
  74. package/templates/add/worker/internal/platform/mail/task.go.hbs +31 -34
  75. package/templates/add/worker/internal/platform/queue/asynq.go.hbs +140 -0
  76. package/templates/add/worker/internal/platform/queue/queue.go.hbs +87 -0
  77. package/templates/add/worker/internal/platform/queue/river.go.hbs +148 -0
  78. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +59 -12
  79. package/templates/create/base/.dockerignore.hbs +13 -0
  80. package/templates/create/base/.env.example.hbs +18 -9
  81. package/templates/create/base/.github/dependabot.yml.hbs +20 -0
  82. package/templates/create/base/.github/workflows/ci.yml.hbs +12 -2
  83. package/templates/create/base/.golangci.yml.hbs +27 -0
  84. package/templates/create/base/AGENTS.md.hbs +8 -4
  85. package/templates/create/base/Dockerfile.hbs +42 -0
  86. package/templates/create/base/Makefile.hbs +43 -13
  87. package/templates/create/base/README.md.hbs +45 -8
  88. package/templates/create/base/cmd/api/main.go.hbs +17 -130
  89. package/templates/create/base/cmd/api/wiring.go.hbs +161 -0
  90. package/templates/create/base/go.mod.hbs +4 -4
  91. package/templates/create/base/internal/platform/database/database.go.hbs +30 -11
  92. package/templates/create/base/internal/shared/config/config.go.hbs +13 -7
  93. package/templates/create/base/internal/shared/pagination/pagination.go.hbs +11 -0
  94. package/templates/create/base/internal/shared/tx/tx.go.hbs +47 -0
  95. package/templates/create/base/redocly.yaml.hbs +21 -0
  96. package/templates/create/features/docs/architecture.md.hbs +32 -11
  97. package/templates/create/features/docs/openapi.yaml.hbs +0 -4
  98. package/templates/create/features/docs/patterns.md.hbs +82 -8
  99. package/templates/create/features/docs/techstack.md.hbs +8 -3
  100. package/templates/generate/module/dto.go.hbs +8 -1
  101. package/templates/generate/module/errors.go.hbs +5 -0
  102. package/templates/generate/module/field-column.down.sql.hbs +2 -0
  103. package/templates/generate/module/field-column.up.sql.hbs +15 -0
  104. package/templates/generate/module/handler_test.go.hbs +8 -1
  105. package/templates/generate/module/migration.down.sql.hbs +3 -1
  106. package/templates/generate/module/migration.up.sql.hbs +7 -2
  107. package/templates/generate/module/minimal/dto.go.hbs +3 -1
  108. package/templates/generate/module/model/model.go.hbs +10 -1
  109. package/templates/generate/module/permission.up.sql.hbs +3 -1
  110. package/templates/generate/module/repository.go.hbs +60 -6
  111. package/templates/generate/module/repository_test.go.hbs +30 -0
  112. package/templates/generate/module/service.go.hbs +10 -1
  113. package/templates/generate/module/service_test.go.hbs +45 -0
  114. package/dist/commands/remove.js +0 -88
  115. package/scripts/smoke-test.mjs +0 -2058
  116. package/templates/add/worker/internal/platform/queue/client.go.hbs +0 -31
  117. package/templates/add/worker/internal/platform/queue/server.go.hbs +0 -68
  118. package/tests/integration/default-module.test.mjs +0 -46
  119. package/tests/integration/generator-naming.test.mjs +0 -81
  120. package/tests/integration/generator-unit-test-seams.test.mjs +0 -91
  121. package/tests/integration/legacy-method-compat.test.mjs +0 -222
  122. package/tests/integration/remove-module.test.mjs +0 -58
  123. package/tests/unit/naming.test.mjs +0 -94
  124. package/tests/unit/smoke-isolation.test.mjs +0 -35
@@ -5,12 +5,39 @@ version: "2"
5
5
 
6
6
  linters:
7
7
  enable:
8
+ - depguard
8
9
  - errcheck
9
10
  - govet
10
11
  - ineffassign
11
12
  - staticcheck
12
13
  - unused
13
14
  settings:
15
+ depguard:
16
+ # What keeps this a modular monolith rather than a pile of folders: a
17
+ # domain may not reach into another domain's package. It declares a
18
+ # narrow interface for what it needs and cmd/api/wiring.go supplies the
19
+ # concrete service — see docs/architect/patterns.md "Calling Another
20
+ # Domain's Logic". Without this, the first shortcut someone takes stays
21
+ # invisible until the coupling is everywhere.
22
+ #
23
+ # One rule per domain, written by `go-scaffold generate module`: depguard
24
+ # matches import paths as static prefixes and has no notion of "the
25
+ # domain this file belongs to", so denying internal/app wholesale would
26
+ # also reject a domain importing its own model/ subpackage. Each rule
27
+ # scopes itself with `files:` and allows exactly its own path.
28
+ #
29
+ # This only sees Go imports. A raw SQL JOIN into another domain's table
30
+ # couples just as hard and no linter can see it — that boundary comes
31
+ # from giving each domain its own schema.
32
+ rules:
33
+ wiring-flows-inward:
34
+ list-mode: lax
35
+ files:
36
+ - "**/internal/app/**"
37
+ deny:
38
+ - pkg: "{{goModule}}/cmd"
39
+ desc: a domain must not import cmd/ — wiring flows the other way
40
+ # go-scaffold:depguard-rules
14
41
  errcheck:
15
42
  exclude-functions:
16
43
  # gin.Context.Error()'s return value is intentionally never checked —
@@ -46,7 +46,7 @@ hand-rolling anything that looks like scaffolding.
46
46
  ## Command quick reference
47
47
 
48
48
  - `go-scaffold generate module <name>` — safe minimal model + errors +
49
- repository + service/handler plumbing, wired into `cmd/api/main.go` and
49
+ repository + service/handler plumbing, wired into `cmd/api/wiring.go` and
50
50
  appended to `migrations/`. Add endpoints one at a time with `generate
51
51
  method`, or pass `--full` to opt into a CRUD skeleton with TODO DTO fields
52
52
  - `go-scaffold generate method <module> <name> --type <get|post|put|patch|delete> [--get-mode all|one] [--field <name>]` —
@@ -55,9 +55,13 @@ hand-rolling anything that looks like scaffolding.
55
55
  with the same name — pick a different one if it collides. With OpenAPI
56
56
  enabled it also writes a valid TODO path document and wires the index;
57
57
  replace placeholder schemas while implementing the method
58
- - `go-scaffold remove module <name>` — deletes the package and un-wires
59
- main.go/OpenAPI while preserving immutable migrations and table data. Use
60
- `generate migration drop_<table>` for an explicit reviewed data removal
58
+ - `go-scaffold undo module <name>` — takes back a `generate module` that
59
+ shouldn't have happened (typo'd name, domain decided against): deletes the
60
+ package, un-wires main.go/OpenAPI, and deletes the module's migration files.
61
+ It refuses if those migrations are committed to git or already applied to
62
+ your database, since then they may exist somewhere this can't reach. Not for
63
+ retiring a domain that has shipped — that's `generate migration drop_<table>`
64
+ plus a reviewed data removal; the table is never dropped either way
61
65
  - Every route in this project is grouped under{{#if apiPrefix}} `/{{apiPrefix}}`{{else}} no prefix{{/if}}
62
66
  (set once at `create` time via `--api-prefix`) — there is no per-domain
63
67
  versioning; a breaking API change gets a new domain package or a new DTO
@@ -0,0 +1,42 @@
1
+ # Build both binaries in one image, ship each from a separate stage — cmd/api
2
+ # and cmd/worker are deployed as two processes, and neither needs the other's.
3
+ #
4
+ # docker build --target api -t {{projectName}}-api .
5
+ # docker build --target worker -t {{projectName}}-worker .
6
+ #
7
+ # The worker target only builds once `go-scaffold add worker` has created
8
+ # cmd/worker; until then `--target api` is the only one that resolves.
9
+ FROM golang:1.25-alpine AS build
10
+ WORKDIR /src
11
+
12
+ # Dependencies first: this layer is cached until go.mod/go.sum change, so an
13
+ # ordinary code edit doesn't re-download the module graph.
14
+ COPY go.mod go.sum ./
15
+ RUN go mod download
16
+
17
+ COPY . .
18
+ # CGO off so the result runs on a distroless/scratch base with no libc.
19
+ # -trimpath keeps build-machine paths out of panics; -s -w drops the symbol
20
+ # table and DWARF, which is most of the binary size and nothing you can use in
21
+ # production anyway.
22
+ ENV CGO_ENABLED=0
23
+ RUN go build -trimpath -ldflags="-s -w" -o /out/api ./cmd/api
24
+ RUN if [ -d ./cmd/worker ]; then go build -trimpath -ldflags="-s -w" -o /out/worker ./cmd/worker; fi
25
+
26
+ # nonroot, and no shell: there is nothing in this image to exec into if
27
+ # something gets in. Debug with `docker run --entrypoint` against the build
28
+ # stage instead.
29
+ FROM gcr.io/distroless/static-debian12:nonroot AS api
30
+ WORKDIR /app
31
+ COPY --from=build /out/api /app/api
32
+ # migrations/ is embedded in the binary (migrations/embed.go), so nothing to
33
+ # copy for CheckMigrationVersion. docs/ is only served outside production.
34
+ USER nonroot:nonroot
35
+ EXPOSE 8080
36
+ ENTRYPOINT ["/app/api"]
37
+
38
+ FROM gcr.io/distroless/static-debian12:nonroot AS worker
39
+ WORKDIR /app
40
+ COPY --from=build /out/worker /app/worker
41
+ USER nonroot:nonroot
42
+ ENTRYPOINT ["/app/worker"]
@@ -1,3 +1,15 @@
1
+ # Pinned dev-tool versions — one place, used by `make tools` and by CI, so a
2
+ # lint rule or migration runner never behaves differently on someone's laptop
3
+ # than it does on a pull request.
4
+ #
5
+ # ponytail: installed with `go install`, not tracked as a go.mod `tool`
6
+ # directive — golang-migrate's CLI pulls a driver for every database it
7
+ # supports and golangci-lint pulls its whole linter tree, which between them
8
+ # turned this project's go.sum from 117 lines into 2,700. Tool pinning is not
9
+ # worth that in a scaffold whose point is a clean starting project.
10
+ MIGRATE_VERSION ?= v4.19.1
11
+ GOLANGCI_LINT_VERSION ?= v2.12.2
12
+
1
13
  DB_HOST ?= localhost
2
14
  DB_PORT ?= 5432
3
15
  DB_USER ?= postgres
@@ -9,19 +21,26 @@ POSTGRES_CONTAINER ?=
9
21
  # override to run against another file, e.g. copy .env.example to .env.production,
10
22
  # fill it in, then `make run ENV_FILE=.env.production`.
11
23
  #
12
- # Every target that needs config loads it the same way: drop whole-line comments
13
- # (^#) AND trailing ` # ...` comments the sed only strips a `#` preceded by
14
- # whitespace, so a `#` inside a value (password, DSN) is kept. Without the sed,
15
- # `xargs` hands the comment's words to `export` too: `PORT=8080 # prod: PORT=80`
16
- # would export PORT twice and the comment's value would win. (Can't factor this
17
- # into a make variable: a `#` in a variable value starts a make comment; in a
18
- # recipe line it's passed to the shell untouched.)
24
+ # Every target that needs config loads it the same way: `set -a` marks whatever
25
+ # follows for export, the file is sourced, `set +a` stops.
26
+ #
27
+ # Sourcing rather than piping through xargs, which is what this used to do. The
28
+ # shell already understands the format: `PORT=8080 # prod: 80` keeps 8080,
29
+ # blank lines are nothing, and the reason it changed a quoted value with a
30
+ # space in it survives. xargs word-split `SMTP_PASSWORD="two words"` into two
31
+ # arguments and exported the wrong thing, silently.
32
+ #
33
+ # It does mean .env is executed, so `$(...)` in a value runs. That file is your
34
+ # own gitignored config, and the alternative could not read half of it.
35
+ #
36
+ # (Can't factor this into a make variable: a `#` in a variable value starts a
37
+ # make comment; in a recipe line it is passed to the shell untouched.)
19
38
  ENV_FILE ?= .env
20
39
 
21
- .PHONY: run build test fmt vet lint tidy db-create db-drop migrate-up migrate-down migrate-verify{{#if openapiDocs}} openapi-bundle{{/if}}{{#if docker}} docker-up docker-down{{/if}}
40
+ .PHONY: tools run build test fmt vet lint tidy db-create db-drop migrate-up migrate-up-test migrate-down migrate-verify{{#if openapiDocs}} openapi-bundle{{/if}}{{#if docker}} docker-up docker-down{{/if}}
22
41
 
23
42
  run:
24
- @[ -f $(ENV_FILE) ] && export $$(grep -v '^#' $(ENV_FILE) | sed -E 's/[[:space:]]+#.*$$//' | xargs); go run ./cmd/api
43
+ @set -a; [ -f $(ENV_FILE) ] && . ./$(ENV_FILE); set +a; go run ./cmd/api
25
44
 
26
45
  build:
27
46
  go build -o bin/api ./cmd/api
@@ -30,7 +49,7 @@ build:
30
49
  # integration tests. A bare `go test ./...` still works, but falls back to
31
50
  # whatever defaults the test files carry.
32
51
  test:
33
- @[ -f $(ENV_FILE) ] && export $$(grep -v '^#' $(ENV_FILE) | sed -E 's/[[:space:]]+#.*$$//' | xargs); go test ./...
52
+ @set -a; [ -f $(ENV_FILE) ] && . ./$(ENV_FILE); set +a; go test ./...
34
53
 
35
54
  fmt:
36
55
  gofmt -w .
@@ -41,6 +60,11 @@ vet:
41
60
  lint:
42
61
  golangci-lint run
43
62
 
63
+ # install the pinned dev tools into $(go env GOPATH)/bin
64
+ tools:
65
+ go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@$(MIGRATE_VERSION)
66
+ go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)
67
+
44
68
  tidy:
45
69
  go mod tidy
46
70
 
@@ -91,16 +115,22 @@ db-drop:
91
115
  fi
92
116
 
93
117
  migrate-up:
94
- @[ -f $(ENV_FILE) ] && export $$(grep -v '^#' $(ENV_FILE) | sed -E 's/[[:space:]]+#.*$$//' | xargs); migrate -path migrations -database "$$DB_DSN" up
118
+ @set -a; [ -f $(ENV_FILE) ] && . ./$(ENV_FILE); set +a; migrate -path migrations -database "$$DB_DSN" up
119
+
120
+ # same migration files, applied to TEST_DB_DSN instead of DB_DSN — run this
121
+ # once after `make db-create DB_NAME=..._test` so repository_test.go files
122
+ # see real tables instead of skipping (or failing, if REQUIRE_TEST_DB=true).
123
+ migrate-up-test:
124
+ @set -a; [ -f $(ENV_FILE) ] && . ./$(ENV_FILE); set +a; migrate -path migrations -database "$$TEST_DB_DSN" up
95
125
 
96
126
  migrate-down:
97
- @[ -f $(ENV_FILE) ] && export $$(grep -v '^#' $(ENV_FILE) | sed -E 's/[[:space:]]+#.*$$//' | xargs); migrate -path migrations -database "$$DB_DSN" down 1
127
+ @set -a; [ -f $(ENV_FILE) ] && . ./$(ENV_FILE); set +a; migrate -path migrations -database "$$DB_DSN" down 1
98
128
 
99
129
  # runs up -> down-to-zero -> up against $DB_DSN to catch a bit-rotted down.sql
100
130
  # (one that no longer reverses cleanly) before you actually need a rollback.
101
131
  # point DB_DSN at a throwaway/test database first — this drops every table.
102
132
  migrate-verify:
103
- @[ -f $(ENV_FILE) ] && export $$(grep -v '^#' $(ENV_FILE) | sed -E 's/[[:space:]]+#.*$$//' | xargs); \
133
+ @set -a; [ -f $(ENV_FILE) ] && . ./$(ENV_FILE); set +a; \
104
134
  migrate -path migrations -database "$$DB_DSN" up && \
105
135
  migrate -path migrations -database "$$DB_DSN" down -all && \
106
136
  migrate -path migrations -database "$$DB_DSN" up
@@ -27,8 +27,10 @@ internal/
27
27
  │ ├── apperror/ # central error type (status + payload)
28
28
  │ ├── dberr/ # maps DB errors to constraint kind (IsDuplicate, IsForeignKey) — shared by every domain
29
29
  │ ├── httpx/ # HTTP helpers shared by every domain (ParseID, BindErr)
30
- │ ├── middleware/ # RequestID, Logger (slog), Error
31
- └── pagination/ # parses ?limit=&offset=, response envelope
30
+ │ ├── id/ # UUID v7 generation (id.New) — app-side, not a DB default
31
+ ├── middleware/ # RequestID, Logger (slog), Error, CORS
32
+ │ ├── pagination/ # parses ?limit=&offset=, response envelope
33
+ │ └── tx/ # carries a transaction on the ctx (tx.Do / tx.From) so two repositories commit together
32
34
  └── app/ # domain packages — empty until you `generate module`
33
35
  ```
34
36
 
@@ -53,13 +55,14 @@ safe; a `#` inside a value (password, DSN) is kept.
53
55
 
54
56
  `make db-create` connects to Postgres at `DB_HOST`/`DB_PORT`/`DB_USER` (default:
55
57
  `localhost`/`5432`/`postgres`, matching `.env.example`) using the `psql`
56
- client — works the same whether Postgres came from `make docker-up` or an
57
- existing instance you already have running. Override any of
58
+ client — works the same whether Postgres came from
59
+ {{#if docker}}`make docker-up`{{else}}a container{{/if}} or an existing instance you already have
60
+ running. Override any of
58
61
  `DB_HOST`/`DB_PORT`/`DB_USER`/`DB_NAME`/`PGPASSWORD` to point at a different
59
62
  server. If `psql` isn't installed locally and `DB_HOST` is `localhost`, it
60
63
  falls back to `docker exec` into whichever container is publishing
61
- `DB_PORT` — this project's own Postgres (`make docker-up` first) or any
62
- other Postgres container you already have running.
64
+ `DB_PORT` — {{#if docker}}this project's own Postgres (`make docker-up` first) or any
65
+ other Postgres container{{else}}any Postgres container{{/if}} you already have running.
63
66
 
64
67
  Server listens on `:8080` (override with `PORT`). Ctrl+C = graceful shutdown.
65
68
 
@@ -76,6 +79,7 @@ make tidy # go mod tidy
76
79
  make db-create # create the database itself (safe to re-run)
77
80
  make db-drop # drop the database
78
81
  make migrate-up # apply migrations (reads DB_DSN from ENV_FILE)
82
+ make migrate-up-test # same migrations, applied to TEST_DB_DSN instead — see Tests
79
83
  make migrate-down # roll back one migration
80
84
  make migrate-verify # up -> down-to-zero -> up, catches a broken down.sql (point DB_DSN at a throwaway DB)
81
85
  {{#if docker}}make docker-up # docker compose up -d
@@ -147,7 +151,7 @@ becoming a false-green skip. Use a separate `{{dbName}}_test` database:
147
151
  make docker-up # local postgres first
148
152
  {{/if}}
149
153
  make db-create DB_NAME={{dbName}}_test
150
- DB_DSN=postgres://postgres:postgres@localhost:5432/{{dbName}}_test?sslmode=disable make migrate-up
154
+ make migrate-up-test
151
155
  TEST_DB_DSN=postgres://postgres:postgres@localhost:5432/{{dbName}}_test?sslmode=disable REQUIRE_TEST_DB=true go test ./...
152
156
  ```
153
157
 
@@ -180,6 +184,39 @@ go-scaffold generate module orders
180
184
  ```
181
185
 
182
186
  Scaffolds the safe minimal `internal/app/order/` module, wires its empty route
183
- group/model into `cmd/api/main.go`, and appends a migration file. Add endpoints
187
+ group/model into `cmd/api/wiring.go`, and appends a migration file. Add endpoints
184
188
  with `generate method`; use `--full` only when a CRUD skeleton is intentional.
185
189
  See `docs/architect/patterns.md` for the module shape and foreign-key rules.
190
+
191
+ ## Deploying
192
+
193
+ Two processes, one image, one target each:
194
+
195
+ ```bash
196
+ docker build --target api -t {{projectName}}-api .
197
+ docker build --target worker -t {{projectName}}-worker . # once `add worker` exists
198
+ ```
199
+
200
+ Both stages are distroless and run as `nonroot`, so there is no shell in either
201
+ image. Debug against the `build` stage instead:
202
+ `docker run --rm -it --entrypoint sh $(docker build -q --target build .)`.
203
+
204
+ `cmd/api` serves HTTP. `cmd/worker` consumes the queue and is a separate
205
+ deployment with **no port and no health endpoint** — scale it independently,
206
+ and remember that not running it means queued mail is never sent, silently.
207
+
208
+ Before the first deploy of a release:
209
+
210
+ | | |
211
+ |---|---|
212
+ | `AUTO_MIGRATE` | leave unset — it defaults off when `APP_ENV=production`, and the app checks the applied migration version instead |
213
+ | migrations | `migrate -path migrations -database "$DB_DSN" up` as its own step, before the new binaries roll |
214
+ | `TRUSTED_PROXIES` | the CIDRs of your ingress, or the auth rate limiter keys on a header anyone can send |
215
+ | `COOKIE_SECURE` | `true` · `COOKIE_SAMESITE=none` as well if your frontend is on a different site |
216
+ | `/metrics` | reachable in-cluster for Prometheus, blocked at the ingress |
217
+ | `JWT_SECRET`, `SMTP_HOST` | the app refuses to start in production without real values |
218
+
219
+ The migration check is what makes the rollout order safe: a binary whose
220
+ embedded migrations are ahead of the database refuses to start, while one
221
+ that's behind logs a warning and serves — so migrating first, then rolling, is
222
+ the order that never leaves a pod serving against a schema it doesn't know.
@@ -1,141 +1,28 @@
1
1
  package main
2
2
 
3
3
  import (
4
- "context"
5
- "errors"
6
4
  "log/slog"
7
- "net/http"
8
5
  "os"
9
- "os/signal"
10
- "syscall"
11
- "time"
12
-
13
- "{{goModule}}/internal/platform/database"
14
- {{#if observability}}
15
- "{{goModule}}/internal/platform/telemetry"
16
- {{/if}}
17
- "{{goModule}}/internal/shared/config"
18
- "{{goModule}}/internal/shared/middleware"
19
- // go-scaffold:imports
20
-
21
- "github.com/gin-gonic/gin"
22
- {{#if observability}}
23
- "github.com/prometheus/client_golang/prometheus/promhttp"
24
- {{/if}}
25
6
  )
26
7
 
8
+ // main decides the exit code and nothing else. Everything it used to do lives
9
+ // in run() — see wiring.go, which is this binary's composition root: the one
10
+ // place that sees every module and hands them what they need.
11
+ //
12
+ // Two things that split buys. Deferred cleanup actually runs, because os.Exit
13
+ // skips defers and this doesn't call it until run has returned — telemetry
14
+ // shutdown and the signal handler used to be silently abandoned on a startup
15
+ // error. And run() is an ordinary function returning an ordinary error, so a
16
+ // test can call it.
17
+ //
18
+ // wiring.go is also the file `go-scaffold` patches. Keeping it out of main.go
19
+ // means the first file you open to understand this binary is not the one that
20
+ // grows a line every time a module is added.
27
21
  func main() {
28
- cfg := config.Load()
29
-
30
- logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: parseLevel(cfg.LogLevel)}))
31
- slog.SetDefault(logger)
32
- // go-scaffold:config-checks
33
-
34
- {{#if observability}}
35
- shutdownTelemetry, err := telemetry.Init(context.Background(), "{{projectName}}", cfg.OTELExporterEndpoint)
36
- if err != nil {
37
- logger.Error("init telemetry", "error", err)
38
- os.Exit(1)
39
- }
40
- defer func() { _ = shutdownTelemetry(context.Background()) }()
41
-
42
- {{/if}}
43
- db, err := database.Open(cfg)
44
- if err != nil {
45
- logger.Error("open db", "error", err)
46
- os.Exit(1)
47
- }
48
-
49
- sqlDB, err := db.DB()
50
- if err != nil {
51
- logger.Error("db handle", "error", err)
52
- os.Exit(1)
53
- }
54
- // go-scaffold:platform-init
55
-
56
- if cfg.AutoMigrate {
57
- // ponytail: AutoMigrate is for dev only (add-only, locks the table once data grows)
58
- // prod: set AUTO_MIGRATE=false and run golang-migrate as versioned SQL instead
59
- if err := db.AutoMigrate(
60
- // go-scaffold:models
61
- ); err != nil {
62
- logger.Error("migrate", "error", err)
63
- os.Exit(1)
64
- }
65
- } else if err := database.CheckMigrationVersion(db); err != nil {
66
- logger.Error("migration version check", "error", err)
22
+ if err := run(); err != nil {
23
+ // Correct before and after run() sets the JSON handler: slog's default
24
+ // writes to stderr, so a failure this early is still reported.
25
+ slog.Error("startup failed", "error", err)
67
26
  os.Exit(1)
68
27
  }
69
-
70
- r := gin.New()
71
- r.Use(gin.Recovery(), middleware.CORS(cfg.CORSAllowedOrigins), middleware.RequestID(), middleware.Logger(logger), middleware.Error(!cfg.IsProd()){{#if observability}}, middleware.Metrics(), middleware.Tracing("{{projectName}}"){{/if}})
72
-
73
- // liveness = is the process up / readiness = ready for traffic (can it reach the DB)
74
- r.GET("/livez", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) })
75
- r.GET("/readyz", func(c *gin.Context) {
76
- if err := sqlDB.PingContext(c.Request.Context()); err != nil {
77
- c.JSON(http.StatusServiceUnavailable, gin.H{"status": "unavailable"})
78
- return
79
- }
80
- // go-scaffold:readyz-checks
81
- c.JSON(http.StatusOK, gin.H{"status": "ok"})
82
- })
83
- {{#if observability}}
84
- r.GET("/metrics", gin.WrapH(promhttp.Handler()))
85
- {{/if}}
86
- {{#if openapiDocs}}
87
- // hand-written spec at docs/openapi.yaml, split across sibling files (common/, health/,
88
- // <domain>/) via relative $ref — serve the whole tree under one prefix so a client that
89
- // resolves $ref over HTTP (Scalar, Swagger UI, Redoc, Hey API pointed at a URL) can reach
90
- // them too; StaticFile on just the index file would 404 on every $ref it follows.
91
- r.Static("/docs", "./docs")
92
- {{/if}}
93
-
94
- api := r.Group("/{{apiPrefix}}")
95
- // go-scaffold:routes
96
- _ = api // dropped once `generate module` registers the first route
97
-
98
- srv := &http.Server{
99
- Addr: ":" + cfg.Port,
100
- Handler: r,
101
- ReadTimeout: 10 * time.Second,
102
- WriteTimeout: 10 * time.Second,
103
- IdleTimeout: 60 * time.Second,
104
- }
105
-
106
- // graceful shutdown: catch SIGINT/SIGTERM, let in-flight requests finish before closing
107
- ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
108
- defer stop()
109
-
110
- go func() {
111
- logger.Info("listening", "addr", srv.Addr)
112
- if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
113
- logger.Error("server", "error", err)
114
- os.Exit(1)
115
- }
116
- }()
117
-
118
- <-ctx.Done()
119
- logger.Info("shutting down")
120
-
121
- shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
122
- defer cancel()
123
- if err := srv.Shutdown(shutdownCtx); err != nil {
124
- logger.Error("shutdown", "error", err)
125
- }
126
- // go-scaffold:shutdown
127
- logger.Info("stopped")
128
- }
129
-
130
- func parseLevel(s string) slog.Level {
131
- switch s {
132
- case "debug":
133
- return slog.LevelDebug
134
- case "warn":
135
- return slog.LevelWarn
136
- case "error":
137
- return slog.LevelError
138
- default:
139
- return slog.LevelInfo
140
- }
141
28
  }
@@ -0,0 +1,161 @@
1
+ package main
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "log/slog"
8
+ "net/http"
9
+ "os"
10
+ "os/signal"
11
+ "syscall"
12
+ "time"
13
+
14
+ "{{goModule}}/internal/platform/database"
15
+ "{{goModule}}/internal/shared/config"
16
+ "{{goModule}}/internal/shared/middleware"
17
+ // go-scaffold:imports
18
+
19
+ "github.com/gin-gonic/gin"
20
+ )
21
+
22
+ // run is this binary's composition root — the only function that sees every
23
+ // module and wires them to each other. Modules never import one another; a
24
+ // domain that needs another's behaviour declares a narrow interface and gets
25
+ // the concrete service from here (see docs/architect/patterns.md).
26
+ //
27
+ // It returns an error rather than calling os.Exit, so every defer below runs
28
+ // on the way out and main() holds the only exit in the binary.
29
+ //
30
+ // This is the file `go-scaffold generate module` and `go-scaffold add *` patch,
31
+ // at the // go-scaffold: markers. It grows with the system, which is what a
32
+ // composition root is for.
33
+ func run() error {
34
+ cfg := config.Load()
35
+
36
+ logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: parseLevel(cfg.LogLevel)}))
37
+ slog.SetDefault(logger)
38
+ // go-scaffold:config-checks
39
+
40
+ db, err := database.Open(cfg)
41
+ if err != nil {
42
+ return fmt.Errorf("open db: %w", err)
43
+ }
44
+
45
+ sqlDB, err := db.DB()
46
+ if err != nil {
47
+ return fmt.Errorf("db handle: %w", err)
48
+ }
49
+ // go-scaffold:platform-init
50
+
51
+ // AutoMigrate creates tables but never the schema they live in — each
52
+ // domain gets its own (see model.go's TableName), so it has to exist
53
+ // before AutoMigrate runs. The versioned SQL migrations create the same
54
+ // schemas with CREATE SCHEMA IF NOT EXISTS, so this only matters for dev.
55
+ // go-scaffold:schemas
56
+
57
+ if cfg.AutoMigrate {
58
+ // ponytail: AutoMigrate is for dev only (add-only, locks the table once data grows)
59
+ // prod: set AUTO_MIGRATE=false and run golang-migrate as versioned SQL instead
60
+ if err := db.AutoMigrate(
61
+ // go-scaffold:models
62
+ ); err != nil {
63
+ return fmt.Errorf("migrate: %w", err)
64
+ }
65
+ } else if err := database.CheckMigrationVersion(db); err != nil {
66
+ return fmt.Errorf("migration version check: %w", err)
67
+ }
68
+
69
+ r := gin.New()
70
+ // gin trusts every proxy by default, which makes c.ClientIP() — and so
71
+ // anything keyed on it, like the auth rate limiter — whatever
72
+ // X-Forwarded-For the caller felt like sending. Empty TRUSTED_PROXIES
73
+ // means trust nobody and use the peer address; set it to your load
74
+ // balancer's CIDRs when you actually run behind one.
75
+ if err := r.SetTrustedProxies(cfg.TrustedProxies); err != nil {
76
+ return fmt.Errorf("trusted proxies: %w", err)
77
+ }
78
+ r.Use(gin.Recovery(), middleware.CORS(cfg.CORSAllowedOrigins), middleware.RequestID(), middleware.Logger(logger), middleware.Error(!cfg.IsProd()))
79
+
80
+ // liveness = is the process up / readiness = ready for traffic (can it reach the DB)
81
+ r.GET("/livez", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) })
82
+ r.GET("/readyz", func(c *gin.Context) {
83
+ if err := sqlDB.PingContext(c.Request.Context()); err != nil {
84
+ c.JSON(http.StatusServiceUnavailable, gin.H{"status": "unavailable"})
85
+ return
86
+ }
87
+ // go-scaffold:readyz-checks
88
+ c.JSON(http.StatusOK, gin.H{"status": "ok"})
89
+ })
90
+ // go-scaffold:extra-routes
91
+ {{#if openapiDocs}}
92
+ // hand-written spec at docs/openapi.yaml, split across sibling files (common/, health/,
93
+ // <domain>/) via relative $ref — serve the whole tree under one prefix so a client that
94
+ // resolves $ref over HTTP (Scalar, Swagger UI, Redoc, Hey API pointed at a URL) can reach
95
+ // them too; StaticFile on just the index file would 404 on every $ref it follows.
96
+ //
97
+ // Not in production: this serves your whole API surface — every path,
98
+ // parameter and schema — to anyone who asks. Publish the spec deliberately
99
+ // (`make openapi-bundle`) rather than by leaving this on.
100
+ if !cfg.IsProd() {
101
+ r.Static("/docs", "./docs")
102
+ }
103
+ {{/if}}
104
+
105
+ api := r.Group("/{{apiPrefix}}")
106
+ // go-scaffold:routes
107
+ _ = api // dropped once `generate module` registers the first route
108
+
109
+ srv := &http.Server{
110
+ Addr: ":" + cfg.Port,
111
+ Handler: r,
112
+ ReadTimeout: 10 * time.Second,
113
+ WriteTimeout: 10 * time.Second,
114
+ IdleTimeout: 60 * time.Second,
115
+ }
116
+
117
+ // graceful shutdown: catch SIGINT/SIGTERM, let in-flight requests finish before closing
118
+ ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
119
+ defer stop()
120
+
121
+ // Buffered: if ListenAndServe fails before anything reads this, the
122
+ // goroutine still exits instead of blocking forever on an unread channel.
123
+ serverErr := make(chan error, 1)
124
+ go func() {
125
+ logger.Info("listening", "addr", srv.Addr)
126
+ if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
127
+ serverErr <- err
128
+ }
129
+ }()
130
+
131
+ // A port already in use used to kill the process from inside that
132
+ // goroutine, skipping every defer on the way out. Now it comes back here.
133
+ select {
134
+ case err := <-serverErr:
135
+ return fmt.Errorf("server: %w", err)
136
+ case <-ctx.Done():
137
+ }
138
+ logger.Info("shutting down")
139
+
140
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
141
+ defer cancel()
142
+ if err := srv.Shutdown(shutdownCtx); err != nil {
143
+ logger.Error("shutdown", "error", err)
144
+ }
145
+ // go-scaffold:shutdown
146
+ logger.Info("stopped")
147
+ return nil
148
+ }
149
+
150
+ func parseLevel(s string) slog.Level {
151
+ switch s {
152
+ case "debug":
153
+ return slog.LevelDebug
154
+ case "warn":
155
+ return slog.LevelWarn
156
+ case "error":
157
+ return slog.LevelError
158
+ default:
159
+ return slog.LevelInfo
160
+ }
161
+ }
@@ -3,9 +3,9 @@ module {{goModule}}
3
3
  go 1.25
4
4
 
5
5
  require (
6
- github.com/gin-gonic/gin v1.10.0
7
- github.com/go-playground/validator/v10 v10.20.0
6
+ github.com/gin-gonic/gin v1.10.1
7
+ github.com/go-playground/validator/v10 v10.30.3
8
8
  github.com/google/uuid v1.6.0
9
- gorm.io/driver/postgres v1.5.9
10
- gorm.io/gorm v1.25.12
9
+ gorm.io/driver/postgres v1.6.2
10
+ gorm.io/gorm v1.31.2
11
11
  )