@nakedev/go-scaffold 0.1.3 → 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 +143 -50
  3. package/dist/commands/auth.js +116 -11
  4. package/dist/commands/create.js +13 -1
  5. package/dist/commands/generate.js +23 -12
  6. package/dist/commands/method.js +94 -17
  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 +368 -64
  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/module-manifest.js +2 -0
  19. package/dist/templates/observability-manifest.js +24 -0
  20. package/dist/templates/rbac-manifest.js +1 -0
  21. package/dist/templates/worker-manifest.js +23 -6
  22. package/dist/utils/auth-patcher.js +96 -21
  23. package/dist/utils/config.js +58 -10
  24. package/dist/utils/gocheck.js +57 -5
  25. package/dist/utils/golangci-patcher.js +73 -0
  26. package/dist/utils/gomod-patcher.js +53 -0
  27. package/dist/utils/main-patcher.js +58 -4
  28. package/dist/utils/marker-patch.js +125 -3
  29. package/dist/utils/method-patcher.js +97 -18
  30. package/dist/utils/module-location.js +58 -0
  31. package/dist/utils/naming.js +125 -14
  32. package/dist/utils/observability-patcher.js +107 -0
  33. package/dist/utils/openapi-patcher.js +19 -1
  34. package/dist/utils/platform-patcher.js +98 -12
  35. package/dist/utils/rbac-patcher.js +60 -10
  36. package/dist/utils/smoke-run.js +31 -0
  37. package/package.json +11 -4
  38. package/templates/add/auth/internal/app/user/errors.go.hbs +7 -0
  39. package/templates/add/auth/internal/app/user/handler.go.hbs +64 -23
  40. package/templates/add/auth/internal/app/user/jwt.go.hbs +31 -7
  41. package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +39 -0
  42. package/templates/add/auth/internal/app/user/model/identity.go.hbs +3 -0
  43. package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +26 -0
  44. package/templates/add/auth/internal/app/user/model/user.go.hbs +9 -1
  45. package/templates/add/auth/internal/app/user/repository.go.hbs +64 -11
  46. package/templates/add/auth/internal/app/user/repository_test.go.hbs +192 -0
  47. package/templates/add/auth/internal/app/user/service.go.hbs +105 -21
  48. package/templates/add/auth/internal/app/user/service_test.go.hbs +81 -2
  49. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +9 -138
  50. package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +144 -0
  51. package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +147 -0
  52. package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +21 -19
  53. package/templates/add/auth/internal/shared/middleware/ratelimit_memory.go.hbs +63 -0
  54. package/templates/add/auth/internal/shared/middleware/ratelimit_redis.go.hbs +32 -0
  55. package/templates/add/auth/migrations/create_auth_tokens.down.sql.hbs +1 -0
  56. package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +16 -0
  57. package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -1
  58. package/templates/add/auth/migrations/create_identities.up.sql.hbs +9 -5
  59. package/templates/add/auth/migrations/create_login_throttle.down.sql.hbs +1 -0
  60. package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +10 -0
  61. package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -1
  62. package/templates/add/auth/migrations/create_users.up.sql.hbs +13 -2
  63. package/templates/add/rbac/internal/app/role/dto.go.hbs +8 -3
  64. package/templates/add/rbac/internal/app/role/handler.go.hbs +4 -1
  65. package/templates/add/rbac/internal/app/role/model/permission.go.hbs +3 -0
  66. package/templates/add/rbac/internal/app/role/model/role.go.hbs +4 -0
  67. package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +3 -0
  68. package/templates/add/rbac/internal/app/role/repository.go.hbs +12 -11
  69. package/templates/add/rbac/internal/app/role/repository_test.go.hbs +176 -0
  70. package/templates/add/rbac/internal/app/role/service.go.hbs +7 -0
  71. package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +19 -0
  72. package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +1 -1
  73. package/templates/add/rbac/migrations/add_roles.down.sql.hbs +5 -5
  74. package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -11
  75. package/templates/add/worker/cmd/worker/main.go.hbs +30 -24
  76. package/templates/add/worker/internal/platform/mail/mail.go.hbs +21 -0
  77. package/templates/add/worker/internal/platform/mail/task.go.hbs +31 -34
  78. package/templates/add/worker/internal/platform/queue/asynq.go.hbs +140 -0
  79. package/templates/add/worker/internal/platform/queue/queue.go.hbs +87 -0
  80. package/templates/add/worker/internal/platform/queue/river.go.hbs +148 -0
  81. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +59 -12
  82. package/templates/create/base/.dockerignore.hbs +13 -0
  83. package/templates/create/base/.env.example.hbs +18 -9
  84. package/templates/create/base/.github/dependabot.yml.hbs +20 -0
  85. package/templates/create/base/.github/workflows/ci.yml.hbs +29 -6
  86. package/templates/create/base/.golangci.yml.hbs +27 -0
  87. package/templates/create/base/AGENTS.md.hbs +14 -12
  88. package/templates/create/base/Dockerfile.hbs +42 -0
  89. package/templates/create/base/Makefile.hbs +52 -16
  90. package/templates/create/base/README.md.hbs +61 -12
  91. package/templates/create/base/cmd/api/main.go.hbs +17 -130
  92. package/templates/create/base/cmd/api/wiring.go.hbs +161 -0
  93. package/templates/create/base/go.mod.hbs +4 -4
  94. package/templates/create/base/internal/platform/database/database.go.hbs +30 -11
  95. package/templates/create/base/internal/shared/config/config.go.hbs +13 -7
  96. package/templates/create/base/internal/shared/pagination/pagination.go.hbs +11 -0
  97. package/templates/create/base/internal/shared/tx/tx.go.hbs +47 -0
  98. package/templates/create/base/redocly.yaml.hbs +21 -0
  99. package/templates/create/features/docs/architecture.md.hbs +32 -11
  100. package/templates/create/features/docs/openapi.yaml.hbs +4 -9
  101. package/templates/create/features/docs/patterns.md.hbs +91 -14
  102. package/templates/create/features/docs/techstack.md.hbs +8 -3
  103. package/templates/generate/module/docs/item.yaml.hbs +3 -3
  104. package/templates/generate/module/dto.go.hbs +8 -1
  105. package/templates/generate/module/errors.go.hbs +5 -0
  106. package/templates/generate/module/field-column.down.sql.hbs +2 -0
  107. package/templates/generate/module/field-column.up.sql.hbs +15 -0
  108. package/templates/generate/module/handler.go.hbs +18 -4
  109. package/templates/generate/module/handler_test.go.hbs +88 -62
  110. package/templates/generate/module/migration.down.sql.hbs +3 -1
  111. package/templates/generate/module/migration.up.sql.hbs +7 -2
  112. package/templates/generate/module/minimal/dto.go.hbs +3 -1
  113. package/templates/generate/module/minimal/handler.go.hbs +8 -2
  114. package/templates/generate/module/minimal/handler_test.go.hbs +5 -112
  115. package/templates/generate/module/minimal/service_test.go.hbs +39 -16
  116. package/templates/generate/module/model/model.go.hbs +16 -0
  117. package/templates/generate/module/permission.up.sql.hbs +3 -1
  118. package/templates/generate/module/repository.go.hbs +60 -6
  119. package/templates/generate/module/repository_test.go.hbs +109 -0
  120. package/templates/generate/module/service.go.hbs +15 -4
  121. package/templates/generate/module/service_test.go.hbs +113 -17
  122. package/dist/commands/remove.js +0 -89
  123. package/templates/add/worker/internal/platform/queue/client.go.hbs +0 -31
  124. package/templates/add/worker/internal/platform/queue/server.go.hbs +0 -68
@@ -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
  )
@@ -2,34 +2,45 @@ package database
2
2
 
3
3
  import (
4
4
  "fmt"
5
+ "log"
6
+ "log/slog"
7
+ "os"
5
8
  "regexp"
6
9
  "strconv"
10
+ "time"
7
11
 
8
- {{#if observability}}
9
- "{{goModule}}/internal/platform/telemetry"
10
- {{/if}}
11
12
  "{{goModule}}/internal/shared/config"
12
13
  "{{goModule}}/migrations"
14
+ // go-scaffold:imports
13
15
 
14
16
  "gorm.io/driver/postgres"
15
17
  "gorm.io/gorm"
18
+ gormlogger "gorm.io/gorm/logger"
16
19
  )
17
20
 
18
21
  // Open connects to Postgres and sets the connection pool — it talks to a
19
22
  // real external system, so it lives in platform/, not shared/.
20
23
  // Schema is managed via golang-migrate (migrations/) in prod, not AutoMigrate — see README.
21
24
  func Open(cfg config.Config) (*gorm.DB, error) {
22
- db, err := gorm.Open(postgres.Open(cfg.DBDSN), &gorm.Config{TranslateError: true})
23
- if err != nil {
24
- return nil, err
25
- }
25
+ // GORM's default logger reports ErrRecordNotFound at ERROR level, so an
26
+ // ordinary miss every FindByID behind a 404, cmd/seed's "does this admin
27
+ // exist yet" check — printed a red line that reads like a failure and
28
+ // isn't. Everything else GORM's default does is kept.
29
+ gormLog := gormlogger.New(log.New(os.Stdout, "", log.LstdFlags), gormlogger.Config{
30
+ SlowThreshold: 200 * time.Millisecond,
31
+ LogLevel: gormlogger.Warn,
32
+ IgnoreRecordNotFoundError: true,
33
+ })
26
34
 
27
- {{#if observability}}
28
- if err := db.Use(telemetry.NewGormPlugin()); err != nil {
35
+ db, err := gorm.Open(postgres.Open(cfg.DBDSN), &gorm.Config{
36
+ TranslateError: true,
37
+ Logger: gormLog,
38
+ })
39
+ if err != nil {
29
40
  return nil, err
30
41
  }
42
+ // go-scaffold:platform-init
31
43
 
32
- {{/if}}
33
44
  sqlDB, err := db.DB()
34
45
  if err != nil {
35
46
  return nil, err
@@ -74,8 +85,16 @@ func CheckMigrationVersion(db *gorm.DB) error {
74
85
  if dirty {
75
86
  return fmt.Errorf("schema_migrations is dirty at version %d — a previous migration failed partway; fix it before starting the app", version)
76
87
  }
77
- if version != latest {
88
+ if version < latest {
78
89
  return fmt.Errorf("DB schema is at migration %d, this binary expects %d — run `make migrate-up`", version, latest)
79
90
  }
91
+ if version > latest {
92
+ // Only "DB behind binary" is fatal. A rolling deploy migrates first and
93
+ // replaces pods after, so an old pod restarting mid-rollout legitimately
94
+ // sees a newer schema — refusing to boot there turns a normal rollout
95
+ // into a crashloop of the very replicas still serving traffic.
96
+ slog.Warn("DB schema is ahead of this binary — fine mid-rollout, investigate if it persists",
97
+ "db_version", version, "binary_version", latest)
98
+ }
80
99
  return nil
81
100
  }
@@ -20,9 +20,9 @@ type Config struct {
20
20
  DBConnMaxLifetime time.Duration
21
21
 
22
22
  CORSAllowedOrigins []string
23
- {{#if observability}}
24
- OTELExporterEndpoint string
25
- {{/if}}
23
+ // TrustedProxies is which peers may set X-Forwarded-For — empty means
24
+ // "nobody", so ClientIP() is the address that actually connected.
25
+ TrustedProxies []string
26
26
  // go-scaffold:config-fields
27
27
  }
28
28
 
@@ -37,15 +37,14 @@ func Load() Config {
37
37
  Port: env("PORT", "8080"),
38
38
  DBDSN: env("DB_DSN", "postgres://postgres:postgres@localhost:5432/{{dbName}}?sslmode=disable"),
39
39
  LogLevel: env("LOG_LEVEL", "info"),
40
- AutoMigrate: env("AUTO_MIGRATE", "true") == "true",
41
40
  DBMaxOpenConns: envInt("DB_MAX_OPEN_CONNS", 10),
42
41
  DBMaxIdleConns: envInt("DB_MAX_IDLE_CONNS", 10),
43
42
  DBConnMaxLifetime: time.Duration(envInt("DB_CONN_MAX_LIFETIME_MIN", 5)) * time.Minute,
44
43
 
45
44
  CORSAllowedOrigins: envList("CORS_ALLOWED_ORIGINS", "http://localhost:3000"),
46
- {{#if observability}}
47
- OTELExporterEndpoint: env("OTEL_EXPORTER_OTLP_ENDPOINT", ""),
48
- {{/if}}
45
+ // empty on purpose: trust nothing until you know which hop in front of
46
+ // this app is yours. See main.go's SetTrustedProxies call.
47
+ TrustedProxies: envList("TRUSTED_PROXIES", ""),
49
48
  // go-scaffold:config-load
50
49
  }
51
50
 
@@ -57,6 +56,13 @@ func Load() Config {
57
56
  panic(fmt.Sprintf("invalid APP_ENV %q — must be development or production", cfg.AppEnv))
58
57
  }
59
58
 
59
+ // Defaults off in production, which is why it's set here rather than in
60
+ // the literal above — it needs a validated AppEnv first. AutoMigrate
61
+ // rewrites the schema at boot AND skips CheckMigrationVersion entirely
62
+ // (see main.go), so a deploy that merely forgets to set it used to get
63
+ // both. AUTO_MIGRATE=true still forces it on if you really mean it.
64
+ cfg.AutoMigrate = env("AUTO_MIGRATE", strconv.FormatBool(!cfg.IsProd())) == "true"
65
+
60
66
  return cfg
61
67
  }
62
68
 
@@ -37,3 +37,14 @@ func Parse(c *gin.Context) Params {
37
37
  func (p Params) Response(data any) gin.H {
38
38
  return gin.H{"data": data, "limit": p.Limit, "offset": p.Offset}
39
39
  }
40
+
41
+ // ResponseWithTotal is Response plus the row count the filter matched, which
42
+ // is what a client needs to render "page 3 of 12". Separate rather than
43
+ // folded into Response because the count costs a second query:
44
+ //
45
+ // var total int64
46
+ // if err := db.Model(&model.Thing{}).Count(&total).Error; err != nil { ... }
47
+ // c.JSON(http.StatusOK, p.ResponseWithTotal(out, total))
48
+ func (p Params) ResponseWithTotal(data any, total int64) gin.H {
49
+ return gin.H{"data": data, "limit": p.Limit, "offset": p.Offset, "total": total}
50
+ }
@@ -0,0 +1,47 @@
1
+ // Package tx carries a database transaction on the request context, so that
2
+ // several repositories can take part in one atomic write without every
3
+ // method growing a *gorm.DB parameter (which would leak GORM into the
4
+ // service layer and break every existing signature).
5
+ //
6
+ // Repositories call From; callers that need more than one write to succeed
7
+ // or fail together call Do. Code that does neither behaves exactly as it did
8
+ // before this package existed.
9
+ package tx
10
+
11
+ import (
12
+ "context"
13
+
14
+ "gorm.io/gorm"
15
+ )
16
+
17
+ // ctxKey is unexported so no other package can put a *gorm.DB in this slot.
18
+ type ctxKey struct{}
19
+
20
+ // Do runs fn inside one transaction: every repository called from fn picks
21
+ // it up via From, so returning an error from anywhere rolls back everything.
22
+ //
23
+ // A nested Do reuses the outer transaction instead of opening a savepoint —
24
+ // a partial rollback is almost never what the caller means, and one commit
25
+ // boundary per request is far easier to reason about.
26
+ //
27
+ // Never hand the ctx passed to fn to a goroutine, a queue, or anything that
28
+ // outlives Do: the transaction is closed on return, and later use of it
29
+ // fails with "transaction has already been committed or rolled back".
30
+ func Do(ctx context.Context, db *gorm.DB, fn func(context.Context) error) error {
31
+ if _, ok := ctx.Value(ctxKey{}).(*gorm.DB); ok {
32
+ return fn(ctx)
33
+ }
34
+ return db.WithContext(ctx).Transaction(func(t *gorm.DB) error {
35
+ return fn(context.WithValue(ctx, ctxKey{}, t))
36
+ })
37
+ }
38
+
39
+ // From returns the transaction running on ctx, or db when there is none, so
40
+ // a repository method behaves identically whether or not its caller opened
41
+ // a transaction.
42
+ func From(ctx context.Context, db *gorm.DB) *gorm.DB {
43
+ if t, ok := ctx.Value(ctxKey{}).(*gorm.DB); ok {
44
+ return t
45
+ }
46
+ return db
47
+ }
@@ -0,0 +1,21 @@
1
+ # Config for `npx @redocly/cli lint docs/openapi.yaml` (run in CI, see
2
+ # .github/workflows/ci.yml) and `make openapi-bundle`.
3
+ #
4
+ # `recommended` (the CLI's default with no config at all) enforces
5
+ # documentation-completeness style — every operation must declare security,
6
+ # every operation needs a documented 4xx, no unused security scheme, no
7
+ # localhost server URL — none of which are spec defects, and all of which are
8
+ # true of a freshly generated project before auth exists or of a redirect/
9
+ # no-content endpoint by design. `minimal` checks the things that are
10
+ # actually bugs: unresolved $refs, malformed schemas, invalid enum/type
11
+ # values. The specific rules below would otherwise fire on this project's own
12
+ # generated output, which would make the CI step fail from the day the
13
+ # project is created rather than when someone actually breaks the spec.
14
+ extends:
15
+ - minimal
16
+ rules:
17
+ security-defined: off
18
+ operation-4xx-response: off
19
+ operation-2xx-response: off
20
+ no-unused-components: off
21
+ no-server-example.com: off
@@ -1,5 +1,24 @@
1
1
  # Architecture Decision Record: {{projectName}}
2
2
 
3
+ ## Composition root
4
+
5
+ `cmd/api/main.go` decides the exit code and nothing else. Everything it used to
6
+ do lives in `cmd/api/wiring.go`, in a `run() error` — the one function that sees
7
+ every module and hands them what they need.
8
+
9
+ Modules never import one another. A domain that needs another's behaviour
10
+ declares a narrow interface for exactly what it needs and receives the concrete
11
+ service from `run()`; `.golangci.yml` has a depguard rule per domain that fails
12
+ the build if anyone shortcuts that.
13
+
14
+ Two things the split buys. `run()` returns an error instead of calling
15
+ `os.Exit`, so the deferred cleanup below it actually runs — `os.Exit` skips
16
+ defers, and telemetry shutdown and the signal handler used to be abandoned on a
17
+ startup error. And the file `go-scaffold` patches is no longer the first file
18
+ you open to understand the binary: `wiring.go` grows with the system, which is
19
+ what a composition root is for, while `main.go` stays put.
20
+
21
+
3
22
  > Status: generated by `@nakedev/go-scaffold`
4
23
  > Scope: initial scaffold
5
24
 
@@ -20,7 +39,8 @@ internal/
20
39
  ├── platform/ # talks to real external systems (DB, later: cache, queue, mail, ...)
21
40
  │ └── database/
22
41
  ├── shared/ # pure logic/framework glue, no I/O
23
- │ ├── config/ apperror/ dberr/ httpx/ middleware/ pagination/
42
+ │ ├── config/ apperror/ dberr/ httpx/ id/
43
+ │ └── middleware/ pagination/ tx/
24
44
  └── app/ # domain packages (one per feature)
25
45
  └── <domain>/
26
46
  ```
@@ -102,20 +122,21 @@ is set — empty means no exporter is created and no network calls are made.
102
122
  scrapes the app, the app never dials out) so there's no reason to gate them
103
123
  further. Tracing needs a collector to be useful, so it stays off until one's
104
124
  actually configured, instead of trying to dial a collector that isn't there.
105
- This is a `create`-time choice (unlike `add auth`/`add rbac`), not something
106
- layered on afterward flip it by hand (`internal/shared/middleware/{metrics,tracing}.go`,
107
- `internal/platform/telemetry/tracing.go`, wiring in `cmd/api/main.go` and
108
- `internal/platform/database`) if the project needs it later. Both the Gin and
109
- GORM tracing hooks are hand-rolled against the OTel SDK directly, not the
110
- official `otelgin`/`gorm.io/plugin/opentelemetry` contrib packages those
111
- pull in a newer Gin (→ HTTP/3/quic-go) and every DB driver they support
112
- tracing for (MySQL, ClickHouse, MongoDB), respectively, for a Postgres-only
113
- project that only wants request/query spans.
125
+ This is not a one-way `create`-time door: `go-scaffold add observability`
126
+ layers the same files onto a project that started without it, the way `add
127
+ auth`/`add rbac` do — `internal/shared/middleware/{metrics,tracing}.go`,
128
+ `internal/platform/telemetry/tracing.go`, and the wiring in `cmd/api/wiring.go`
129
+ and `internal/platform/database`. Both the Gin and GORM tracing hooks are
130
+ hand-rolled against the OTel SDK directly, not the official
131
+ `otelgin`/`gorm.io/plugin/opentelemetry` contrib packages those pull in a
132
+ newer Gin ( HTTP/3/quic-go) and every DB driver they support tracing for
133
+ (MySQL, ClickHouse, MongoDB), respectively, for a Postgres-only project that
134
+ only wants request/query spans.
114
135
 
115
136
  {{/if}}
116
137
  ## Evolution Notes
117
138
 
118
139
  - `go-scaffold generate module <name>` adds a new domain package and
119
- wires it into `cmd/api/main.go`
140
+ wires it into `cmd/api/wiring.go`
120
141
  - This document only reflects the initial scaffold — update it as the real
121
142
  architecture evolves
@@ -1,11 +1,10 @@
1
1
  # ponytail: hand-written, not generated from annotations — cheap while the
2
- # endpoint count is low. Ceiling: must be updated by hand whenever an
3
- # endpoint/DTO changes beyond what `generate module` scaffolds.
2
+ # endpoint count is low. Generated module/method stubs keep the index wired,
3
+ # but request/response schemas still need to be completed with the code.
4
4
  # Switch to swaggo (comment-generated) if drift becomes a recurring problem.
5
5
  # multi-file: split by domain module (health/<domain>) + common/ for shared
6
- # pieces — this file is just the index. `generate module <name>`
7
- # adds its paths/schemas here automatically; `generate method` does not
8
- # (endpoint-specific docs stay hand-written).
6
+ # pieces — this file is just the index. `generate module` and `generate method`
7
+ # add path entries automatically.
9
8
  openapi: 3.0.3
10
9
  info:
11
10
  title: {{projectName}} API
@@ -19,10 +18,6 @@ paths:
19
18
  $ref: './health/health-livez.yaml'
20
19
  /readyz:
21
20
  $ref: './health/health-readyz.yaml'
22
- {{#if observability}}
23
- /metrics:
24
- $ref: './observability/metrics.yaml'
25
- {{/if}}
26
21
  # go-scaffold:paths
27
22
 
28
23
  components:
@@ -17,8 +17,9 @@ internal/app/<domain>/
17
17
  ├── repository.go # the only place that touches the DB for this domain, every method takes ctx
18
18
  ├── service.go # business logic; declares the repository interface it needs (mockable in tests)
19
19
  ├── handler.go # HTTP: routing, bind, delegate, respond
20
- ├── service_test.go # unit test, fake repo, no DB
21
- └── handler_test.go # integration test, real Postgres, transaction rolled back per test
20
+ ├── service_test.go # unit test, function-backed repository stub, no DB
21
+ ├── handler_test.go # HTTP unit test, service stub, no DB
22
+ └── repository_test.go # Postgres integration test against migrated schema
22
23
  ```
23
24
 
24
25
  ### Model
@@ -28,9 +29,17 @@ internal/app/<domain>/
28
29
  - A folder, not a single file, so a domain with more than one table (e.g.
29
30
  `order` + `order_item`) adds one file per table instead of growing a single
30
31
  file — `generate module` only ever creates the first one
31
- - Every consumer inside `cmd/api/main.go` imports each domain's `model`
32
+ - Every consumer inside `cmd/api/wiring.go` imports each domain's `model`
32
33
  package under an alias (`ordermodel`, `usermodel`, ...) since they all share
33
34
  the package name `model`
35
+ - `TableName()` returns a schema-qualified name (`order_svc.orders`, not
36
+ `orders`) — every domain gets its own Postgres schema, created by its own
37
+ migration (`CREATE SCHEMA IF NOT EXISTS`) and by `cmd/api/wiring.go` before
38
+ `AutoMigrate` runs in dev (AutoMigrate creates tables, never the schema they
39
+ live in). A cross-domain FK is still fine — see the FK rules below — this
40
+ only stops one domain's table from silently colliding with another's, or a
41
+ raw SQL `JOIN` from reaching into a domain it doesn't own without at least
42
+ naming the schema it's crossing into.
34
43
 
35
44
  ## Layer Conventions
36
45
 
@@ -53,6 +62,15 @@ internal/app/<domain>/
53
62
  - The only file per domain that talks to GORM
54
63
  - Every method takes `ctx context.Context` first, so a cancelled request
55
64
  cancels the query
65
+ - Every query starts from `tx.From(ctx, r.db).WithContext(ctx)`, never from
66
+ `r.db` directly — `shared/tx` is what lets a caller wrap two repositories
67
+ in one `tx.Do(ctx, db, func(ctx) error { ... })` and have both commit or
68
+ roll back together. `From` returns the transaction on the context, or the
69
+ plain `*gorm.DB` when there is none, so a method written this way behaves
70
+ identically either way. A hand-written method that uses `r.db` compiles,
71
+ passes its tests, and silently escapes any surrounding transaction — that
72
+ write commits on its own even when the rest of `tx.Do` rolls back. This is
73
+ the single easiest convention to miss when adding a method by hand.
56
74
  - Look up by ID with an explicit `"id = ?"` — the PK is a UUID, not an int,
57
75
  and GORM can misinterpret a bare struct arg
58
76
 
@@ -68,6 +86,31 @@ internal/app/<domain>/
68
86
  - Codes are domain-specific (`ORDER_NOT_FOUND`), never the generic
69
87
  `apperror.NewNotFound()` directly from a handler
70
88
 
89
+ ## Optimistic Locking
90
+
91
+ Every generated table has a `version INTEGER NOT NULL DEFAULT 1` column, and
92
+ every generated `Repository.Update` is guarded by it — the `UPDATE` carries
93
+ `WHERE id = ? AND version = ?` and bumps the version in the same statement.
94
+ Zero rows affected means someone else saved between the caller's read and its
95
+ write, so the repository returns `ErrStaleVersion` instead of reporting a
96
+ success that changed nothing.
97
+
98
+ The version the check compares against is the one **the client** last read,
99
+ not the one on the row the service just loaded — comparing a row against
100
+ itself always matches and defeats the whole check. That's why a CRUD
101
+ skeleton's `updateInput` carries `Version int` with `binding:"required"`
102
+ and `response` echoes it back: the client round-trips the value, and an
103
+ update that arrives without one is rejected rather than treated as a blind
104
+ overwrite.
105
+
106
+ In a CRUD skeleton the service translates `ErrStaleVersion` into `errStale()`
107
+ from the domain's error catalog (`<DOMAIN>_STALE`, HTTP 409) — a lost update
108
+ is a client problem to retry, not a 500. A minimal module has the same
109
+ `Repository.Update` and the same `errStale()` waiting for it, just no update
110
+ path yet. Keep the chain intact when you add one by hand:
111
+ `errors.Is(err, ErrStaleVersion)` → `errStale()`, and never set `Version` on
112
+ the model yourself — `Repository.Update` owns the bump.
113
+
71
114
  ## Domains With a Foreign Key (Relations) — 3 Rules
72
115
 
73
116
  The CLI does not scaffold relations between domains; when you add one by
@@ -91,23 +134,55 @@ if this project has one):
91
134
  For behavior, not just a data reference (e.g. `order` needs `user`'s email
92
135
  to put on a receipt) — the caller's `service.go` declares its own narrow
93
136
  interface for exactly what it needs, the same way it already declares a
94
- `repository` interface:
137
+ `repository` interface.
138
+
139
+ **The interface speaks in the caller's own terms.** No domain package ever
140
+ imports another domain package — `golangci-lint`'s `depguard` rules enforce
141
+ this, and it is the same rule that makes rule 1 above work. So the interface
142
+ names primitives, or types the caller owns, never `user.Response`:
95
143
 
96
144
  ```go
97
145
  // order/service.go
98
146
  type userLookup interface {
99
- GetByID(ctx context.Context, id uuid.UUID) (user.Response, error)
147
+ EmailOf(ctx context.Context, id uuid.UUID) (string, error)
100
148
  }
101
149
  ```
102
150
 
103
- `cmd/api/main.go` wires the concrete `user` service in it already
104
- satisfies the interface, no adapter needed:
151
+ `cmd/api/wiring.go` is the one place that knows both domains, so the adapter
152
+ lives there — a func literal, not a type:
153
+
154
+ ```go
155
+ userSvc := user.NewService(userRepo, ...)
156
+ orderSvc := order.NewService(orderRepo, order.UserLookupFunc(
157
+ func(ctx context.Context, id uuid.UUID) (string, error) {
158
+ u, err := userSvc.Get(ctx, id)
159
+ if err != nil {
160
+ return "", err
161
+ }
162
+ return u.Email, nil
163
+ },
164
+ ))
165
+ ```
166
+
167
+ where `order` provides the usual func-to-interface shim next to its
168
+ interface:
105
169
 
106
170
  ```go
107
- userSvc := user.NewService(userRepo)
108
- orderSvc := order.NewService(orderRepo, userSvc)
171
+ // order/service.go
172
+ type UserLookupFunc func(ctx context.Context, id uuid.UUID) (string, error)
173
+
174
+ func (f UserLookupFunc) EmailOf(ctx context.Context, id uuid.UUID) (string, error) {
175
+ return f(ctx, id)
176
+ }
109
177
  ```
110
178
 
179
+ It looks like more ceremony than passing `*user.Service` straight in, and it
180
+ is — four lines of it. What you buy is that `order` compiles, tests, and
181
+ moves without `user` existing at all, and that a change to `user.Response`
182
+ can never silently ripple into `order`. The adapter is also the honest place
183
+ to notice you are reaching for something that should have been the caller's
184
+ own data.
185
+
111
186
  - **One direction only.** If `user` would need to call back into `order`,
112
187
  don't wire it both ways — either the two belong in one domain, or the
113
188
  callback needs an event/queue, not a direct call.
@@ -124,7 +199,7 @@ go-scaffold generate method <domain> <name> --type <get|post|put|patch|delete> [
124
199
  ```
125
200
 
126
201
  Patches `handler.go`/`service.go` (and `repository.go` + the `repository`
127
- interface + its `fakeRepo` test stub, for a `get --get-mode one --field`
202
+ interface + its `repositoryStub` test stub, for a `get --get-mode one --field`
128
203
  lookup) in place, at the `// go-scaffold:*` marker comments near the end of
129
204
  each file. **Don't delete those markers** — they're where the next
130
205
  `generate method` call inserts. The method body is always left as a `TODO`
@@ -137,10 +212,12 @@ placeholder fields.
137
212
  - Unit and integration tests live in the same directory as the code under
138
213
  test (Go convention) — never a separate `test/` folder. `test/` is only
139
214
  for e2e black-box suites or fixtures.
140
- - `service_test.go` uses a fake repository no DB required, always runs.
141
- - `handler_test.go` runs against a real Postgres instance inside a
142
- transaction that's rolled back after each test skips automatically if
143
- the DB isn't reachable.
215
+ - `service_test.go` uses a function-backed repository stub so each dependency
216
+ method has independent behavior and argument assertions.
217
+ - `handler_test.go` uses a service stubHTTP tests never need Postgres.
218
+ - `repository_test.go` runs against a migrated Postgres database inside a
219
+ transaction that's rolled back after each test. Local runs may skip when
220
+ `TEST_DB_DSN` is unset; CI sets `REQUIRE_TEST_DB=true` so it must run.
144
221
 
145
222
  ## Docs Maintenance
146
223
 
@@ -7,10 +7,10 @@
7
7
  | Layer | Technology |
8
8
  |-------|-----------|
9
9
  | Language | Go 1.25 |
10
- | HTTP framework | Gin (`github.com/gin-gonic/gin` v1.10.0) |
10
+ | HTTP framework | Gin (`github.com/gin-gonic/gin` v1.10.1) |
11
11
  | Database | PostgreSQL |
12
- | ORM | GORM (`gorm.io/gorm` v1.25.12 + `gorm.io/driver/postgres` v1.5.9) |
13
- | Validation | `go-playground/validator/v10` v10.20.0 |
12
+ | ORM | GORM (`gorm.io/gorm` v1.31.2 + `gorm.io/driver/postgres` v1.6.2) |
13
+ | Validation | `go-playground/validator/v10` v10.30.3 |
14
14
  | IDs | UUID v7 (`google/uuid` v1.6.0) |
15
15
  | Logging | `log/slog`, JSON handler |
16
16
  | Migrations | [golang-migrate](https://github.com/golang-migrate/migrate) |
@@ -39,3 +39,8 @@
39
39
  nowhere — set it to a real OTLP/HTTP collector address to see traces
40
40
  {{/if}}- Versions above are what `go-scaffold create` pinned in `go.mod` — bump them
41
41
  by hand (`go get -u` + `go mod tidy`) as the stack evolves
42
+ - Each `go-scaffold add ...` command pins its own dependencies the same way
43
+ (River/Asynq, JWT, oauth2, Prometheus, OpenTelemetry, ...), so `go mod tidy`
44
+ resolves them to the versions that feature was built against instead of
45
+ whatever happened to be newest that day. The full list is `go.mod`; this
46
+ table only covers what `create` itself brings in
@@ -29,9 +29,9 @@ put:
29
29
  "400": { $ref: '../common/responses.yaml#/ValidationError' }
30
30
  "404": { $ref: '../common/responses.yaml#/NotFoundError' }
31
31
  delete:
32
- summary: Delete {{pkg}}
32
+ summary: Delete {{name}}
33
+ description: Idempotent — deleting an already-missing resource also returns 204.
33
34
  operationId: delete{{pascalName}}
34
35
  tags: [{{plural}}]
35
36
  responses:
36
- "204": { description: deleted }
37
- "404": { $ref: '../common/responses.yaml#/NotFoundError' }
37
+ "204": { description: deleted or already absent }
@@ -9,21 +9,28 @@ import (
9
9
  )
10
10
 
11
11
  // TODO: add request fields, e.g. Name string `json:"name" binding:"required"`
12
+ // When you do: update createBody in handler_test.go to match, or the
13
+ // generated create test starts failing on its own empty `{}` body.
12
14
  type createInput struct {
13
15
  }
14
16
 
15
17
  // TODO: add request fields, e.g. Name string `json:"name" binding:"omitempty"`
16
18
  type updateInput struct {
19
+ // Version the client last read. Required: without it an update is a blind
20
+ // overwrite of whatever anyone else has since saved.
21
+ Version int `json:"version" binding:"required"`
17
22
  }
18
23
 
19
24
  // response = the DTO sent out (kept separate from the model so a later DB column doesn't leak automatically)
20
25
  type response struct {
21
26
  ID uuid.UUID `json:"id"`
22
27
  CreatedAt time.Time `json:"created_at"`
28
+ // echoed back so the client can send it with its next update
29
+ Version int `json:"version"`
23
30
  }
24
31
 
25
32
  func toResponse(m *model.{{pascalName}}) response {
26
- return response{ID: m.ID, CreatedAt: m.CreatedAt}
33
+ return response{ID: m.ID, CreatedAt: m.CreatedAt, Version: m.Version}
27
34
  }
28
35
 
29
36
  // go-scaffold:dto
@@ -22,6 +22,11 @@ func errConflict() *apperror.AppError {
22
22
  return apperror.New(http.StatusConflict, "{{errorPrefix}}_CONFLICT", "{{pkg}} already exists")
23
23
  }
24
24
 
25
+ //nolint:unused
26
+ func errStale() *apperror.AppError {
27
+ return apperror.New(http.StatusConflict, "{{errorPrefix}}_STALE", "{{pkg}} was modified by someone else — reload and try again")
28
+ }
29
+
25
30
  //nolint:unused
26
31
  func errHasReferences() *apperror.AppError {
27
32
  return apperror.New(http.StatusConflict, "{{errorPrefix}}_HAS_REFERENCES", "{{pkg}} still has related records")
@@ -0,0 +1,2 @@
1
+ DROP INDEX IF EXISTS {{schemaName}}.idx_{{tableName}}_{{fieldColumn}};
2
+ ALTER TABLE {{schemaName}}.{{tableName}} DROP COLUMN IF EXISTS {{fieldColumn}};
@@ -0,0 +1,15 @@
1
+ -- `generate method {{pkg}} {{methodName}} --field {{fieldColumn}}` generates a
2
+ -- lookup that queries this column. GORM builds that SQL at runtime, so without
3
+ -- the column the code compiles, the tests pass, and the endpoint fails on its
4
+ -- first real request — which is why the migration is generated with it.
5
+ --
6
+ -- TODO: the type is a guess. Change it to whatever the field actually is, and
7
+ -- drop NULL handling in if the column can be empty.
8
+ ALTER TABLE {{schemaName}}.{{tableName}}
9
+ ADD COLUMN IF NOT EXISTS {{fieldColumn}} VARCHAR(255);
10
+
11
+ -- The lookup fetches one row by this column on every call. Without an index
12
+ -- that is a sequential scan of the whole table, which is invisible until the
13
+ -- table is big enough for it to hurt.
14
+ CREATE INDEX IF NOT EXISTS idx_{{tableName}}_{{fieldColumn}}
15
+ ON {{schemaName}}.{{tableName}} ({{fieldColumn}});
@@ -1,8 +1,10 @@
1
1
  package {{pkg}}
2
2
 
3
3
  import (
4
+ "context"
4
5
  "net/http"
5
6
 
7
+ "{{goModule}}/internal/app/{{modulePath}}/model"
6
8
  "{{goModule}}/internal/shared/httpx"
7
9
  {{#if auth}}
8
10
  "{{goModule}}/internal/shared/middleware"
@@ -10,11 +12,23 @@ import (
10
12
  "{{goModule}}/internal/shared/pagination"
11
13
 
12
14
  "github.com/gin-gonic/gin"
15
+ "github.com/google/uuid"
13
16
  )
14
17
 
18
+ // service is the narrow application API required by this HTTP adapter. Keeping
19
+ // the dependency as an interface makes handler tests fast and database-free.
20
+ type service interface {
21
+ Create(context.Context, createInput) (*model.{{pascalName}}, error)
22
+ List(context.Context, int, int) ([]model.{{pascalName}}, error)
23
+ Get(context.Context, uuid.UUID) (*model.{{pascalName}}, error)
24
+ Update(context.Context, uuid.UUID, updateInput) (*model.{{pascalName}}, error)
25
+ Delete(context.Context, uuid.UUID) error
26
+ // go-scaffold:service-interface
27
+ }
28
+
15
29
  // Handler = delivery for {{pkg}} (parses HTTP, calls the service, attaches errors for the middleware to render)
16
30
  type Handler struct {
17
- svc *Service
31
+ svc service
18
32
  {{#if auth}}
19
33
  jwtSecret string
20
34
  {{/if}}
@@ -23,7 +37,7 @@ type Handler struct {
23
37
  {{/if}}
24
38
  }
25
39
 
26
- func NewHandler(svc *Service{{#if auth}}, jwtSecret string{{/if}}{{#if permission}}, authz *middleware.Authz{{/if}}) *Handler {
40
+ func NewHandler(svc service{{#if auth}}, jwtSecret string{{/if}}{{#if permission}}, authz *middleware.Authz{{/if}}) *Handler {
27
41
  return &Handler{
28
42
  svc: svc,
29
43
  {{#if auth}}
@@ -52,7 +66,7 @@ func (h *Handler) create(c *gin.Context) {
52
66
  c.Error(httpx.BindErr(err))
53
67
  return
54
68
  }
55
- m, err := h.svc.Create(c.Request.Context())
69
+ m, err := h.svc.Create(c.Request.Context(), in)
56
70
  if err != nil {
57
71
  c.Error(err)
58
72
  return
@@ -97,7 +111,7 @@ func (h *Handler) update(c *gin.Context) {
97
111
  c.Error(httpx.BindErr(err))
98
112
  return
99
113
  }
100
- m, err := h.svc.Update(c.Request.Context(), id)
114
+ m, err := h.svc.Update(c.Request.Context(), id, in)
101
115
  if err != nil {
102
116
  c.Error(err)
103
117
  return