@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
@@ -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
@@ -18,10 +18,6 @@ paths:
18
18
  $ref: './health/health-livez.yaml'
19
19
  /readyz:
20
20
  $ref: './health/health-readyz.yaml'
21
- {{#if observability}}
22
- /metrics:
23
- $ref: './observability/metrics.yaml'
24
- {{/if}}
25
21
  # go-scaffold:paths
26
22
 
27
23
  components:
@@ -29,9 +29,17 @@ internal/app/<domain>/
29
29
  - A folder, not a single file, so a domain with more than one table (e.g.
30
30
  `order` + `order_item`) adds one file per table instead of growing a single
31
31
  file — `generate module` only ever creates the first one
32
- - 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`
33
33
  package under an alias (`ordermodel`, `usermodel`, ...) since they all share
34
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.
35
43
 
36
44
  ## Layer Conventions
37
45
 
@@ -54,6 +62,15 @@ internal/app/<domain>/
54
62
  - The only file per domain that talks to GORM
55
63
  - Every method takes `ctx context.Context` first, so a cancelled request
56
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.
57
74
  - Look up by ID with an explicit `"id = ?"` — the PK is a UUID, not an int,
58
75
  and GORM can misinterpret a bare struct arg
59
76
 
@@ -69,6 +86,31 @@ internal/app/<domain>/
69
86
  - Codes are domain-specific (`ORDER_NOT_FOUND`), never the generic
70
87
  `apperror.NewNotFound()` directly from a handler
71
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
+
72
114
  ## Domains With a Foreign Key (Relations) — 3 Rules
73
115
 
74
116
  The CLI does not scaffold relations between domains; when you add one by
@@ -92,23 +134,55 @@ if this project has one):
92
134
  For behavior, not just a data reference (e.g. `order` needs `user`'s email
93
135
  to put on a receipt) — the caller's `service.go` declares its own narrow
94
136
  interface for exactly what it needs, the same way it already declares a
95
- `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`:
96
143
 
97
144
  ```go
98
145
  // order/service.go
99
146
  type userLookup interface {
100
- GetByID(ctx context.Context, id uuid.UUID) (user.Response, error)
147
+ EmailOf(ctx context.Context, id uuid.UUID) (string, error)
101
148
  }
102
149
  ```
103
150
 
104
- `cmd/api/main.go` wires the concrete `user` service in it already
105
- 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:
106
169
 
107
170
  ```go
108
- userSvc := user.NewService(userRepo)
109
- 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
+ }
110
177
  ```
111
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
+
112
186
  - **One direction only.** If `user` would need to call back into `order`,
113
187
  don't wire it both ways — either the two belong in one domain, or the
114
188
  callback needs an event/queue, not a direct call.
@@ -125,7 +199,7 @@ go-scaffold generate method <domain> <name> --type <get|post|put|patch|delete> [
125
199
  ```
126
200
 
127
201
  Patches `handler.go`/`service.go` (and `repository.go` + the `repository`
128
- 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`
129
203
  lookup) in place, at the `// go-scaffold:*` marker comments near the end of
130
204
  each file. **Don't delete those markers** — they're where the next
131
205
  `generate method` call inserts. The method body is always left as a `TODO`
@@ -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
@@ -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}});
@@ -113,6 +113,13 @@ func doHandlerRequest(r *gin.Engine, method, requestPath, body string) *httptest
113
113
  return w
114
114
  }
115
115
 
116
+ // createBody is the JSON these tests POST. It starts empty because createInput
117
+ // starts empty — add a value here for every field you add to createInput in
118
+ // dto.go. Miss that and this test fails with a 400 the moment one of those
119
+ // fields is `binding:"required"`: the handler is fine, the fixture just no
120
+ // longer satisfies it.
121
+ const createBody = `{}`
122
+
116
123
  func TestHandler_Create_OK(t *testing.T) {
117
124
  svc := &serviceStub{
118
125
  createFn: func(context.Context, createInput) (*model.{{pascalName}}, error) {
@@ -121,7 +128,7 @@ func TestHandler_Create_OK(t *testing.T) {
121
128
  }
122
129
  r := setupHandlerTest(t, svc)
123
130
 
124
- w := doHandlerRequest(r, http.MethodPost, "/{{plural}}", `{}`)
131
+ w := doHandlerRequest(r, http.MethodPost, "/{{plural}}", createBody)
125
132
 
126
133
  if w.Code != http.StatusCreated {
127
134
  t.Fatalf("want 201, got %d body=%s", w.Code, w.Body)
@@ -1 +1,3 @@
1
- DROP TABLE IF EXISTS {{tableName}};
1
+ DROP TABLE IF EXISTS {{schemaName}}.{{tableName}};
2
+ -- the schema itself is left in place: cheap to keep, and dropping it here
3
+ -- would be wrong the moment a second table (added later, by hand) shares it
@@ -1,5 +1,10 @@
1
- CREATE TABLE {{tableName}} (
1
+ -- one schema per domain — see model.go's TableName for why
2
+ CREATE SCHEMA IF NOT EXISTS {{schemaName}};
3
+
4
+ CREATE TABLE {{schemaName}}.{{tableName}} (
2
5
  id UUID PRIMARY KEY,
3
6
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
4
- updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
7
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
8
+ -- bumped on every update; an update that carries an older value is refused
9
+ version INTEGER NOT NULL DEFAULT 1
5
10
  );
@@ -16,11 +16,13 @@ import (
16
16
  type response struct {
17
17
  ID uuid.UUID `json:"id"`
18
18
  CreatedAt time.Time `json:"created_at"`
19
+ // clients send this back on an update so a concurrent save can be detected
20
+ Version int `json:"version"`
19
21
  }
20
22
 
21
23
  //nolint:unused
22
24
  func toResponse(m *model.{{pascalName}}) response {
23
- return response{ID: m.ID, CreatedAt: m.CreatedAt}
25
+ return response{ID: m.ID, CreatedAt: m.CreatedAt, Version: m.Version}
24
26
  }
25
27
 
26
28
  // go-scaffold:dto
@@ -17,11 +17,20 @@ type {{pascalName}} struct {
17
17
  ID uuid.UUID `json:"id" gorm:"type:uuid;primaryKey"`
18
18
  CreatedAt time.Time `json:"created_at"`
19
19
  UpdatedAt time.Time `json:"updated_at"`
20
+ // Version guards against two clients that both loaded this row overwriting
21
+ // each other: an update only applies if the version it carries still
22
+ // matches the stored one. Bumped by Repository.Update, never by hand.
23
+ Version int `json:"version" gorm:"not null;default:1"`
20
24
  }
21
25
 
22
26
  // TableName pins the persistence contract to the versioned SQL migrations.
23
27
  // Do not rely on GORM's English inflector: irregular/plural module names must
24
28
  // resolve to exactly the same table in development and production.
29
+ //
30
+ // Schema-qualified ("{{schemaName}}", not just "{{tableName}}") so this
31
+ // domain's table can never collide with another domain's, and a raw SQL JOIN
32
+ // reaching into it from outside this package fails loudly instead of quietly
33
+ // coupling two domains together.
25
34
  func ({{pascalName}}) TableName() string {
26
- return "{{tableName}}"
35
+ return "{{schemaName}}.{{tableName}}"
27
36
  }
@@ -1,4 +1,6 @@
1
1
  -- ON CONFLICT DO NOTHING: safe to re-generate a module reusing a permission
2
2
  -- code another module already inserted (e.g. a shared "orders:manage").
3
- INSERT INTO permissions (code, description) VALUES ('{{permission}}', 'Manage {{plural}}')
3
+ -- role_svc, not this module's own schema: `add rbac` owns the permissions
4
+ -- table, wherever it ends up living — see `add rbac`'s migration.
5
+ INSERT INTO role_svc.permissions (code, description) VALUES ('{{permission}}', 'Manage {{plural}}')
4
6
  ON CONFLICT (code) DO NOTHING;