@nakedev/go-scaffold 0.4.0 → 0.5.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 (192) hide show
  1. package/README.md +598 -306
  2. package/dist/commands/auth.js +65 -23
  3. package/dist/commands/check.js +281 -0
  4. package/dist/commands/config.js +50 -0
  5. package/dist/commands/create.js +33 -2
  6. package/dist/commands/generate.js +29 -3
  7. package/dist/commands/method.js +74 -63
  8. package/dist/commands/migration.js +2 -2
  9. package/dist/commands/observability.js +4 -53
  10. package/dist/commands/rbac.js +21 -10
  11. package/dist/commands/undo.js +11 -3
  12. package/dist/commands/worker.js +15 -5
  13. package/dist/index.js +198 -59
  14. package/dist/prompts/auth-wizard.js +40 -6
  15. package/dist/prompts/create-wizard.js +42 -1
  16. package/dist/prompts/generate-wizard.js +89 -9
  17. package/dist/templates/auth-manifest.js +50 -19
  18. package/dist/templates/create-manifest.js +8 -0
  19. package/dist/templates/module-manifest.js +84 -26
  20. package/dist/templates/rbac-manifest.js +16 -11
  21. package/dist/templates/worker-manifest.js +4 -1
  22. package/dist/types.js +8 -0
  23. package/dist/utils/auth-patcher.js +124 -33
  24. package/dist/utils/config.js +167 -4
  25. package/dist/utils/docs-patcher.js +68 -0
  26. package/dist/utils/hexagonal-method-patcher.js +334 -0
  27. package/dist/utils/main-patcher.js +32 -30
  28. package/dist/utils/marker-patch.js +7 -1
  29. package/dist/utils/module-location.js +17 -11
  30. package/dist/utils/module-profile.js +32 -0
  31. package/dist/utils/platform-patcher.js +56 -7
  32. package/dist/utils/rbac-patcher.js +89 -210
  33. package/package.json +7 -2
  34. package/templates/add/auth/cmd/seed/main.go.hbs +15 -3
  35. package/templates/add/auth/docs/login.yaml.hbs +11 -1
  36. package/templates/add/auth/docs/mfa-verify.yaml.hbs +19 -0
  37. package/templates/add/auth/docs/provider-exchange.yaml.hbs +40 -0
  38. package/templates/add/auth/docs/provider-login.yaml.hbs +31 -0
  39. package/templates/add/auth/docs/refresh.yaml.hbs +7 -0
  40. package/templates/add/auth/docs/register.yaml.hbs +7 -0
  41. package/templates/add/auth/docs/reset-password.yaml.hbs +1 -1
  42. package/templates/add/auth/docs/schemas.yaml.hbs +59 -1
  43. package/templates/add/auth/docs/users-me-mfa-confirm.yaml.hbs +19 -0
  44. package/templates/add/auth/docs/users-me-mfa-disable.yaml.hbs +15 -0
  45. package/templates/add/auth/docs/users-me-mfa-setup.yaml.hbs +14 -0
  46. package/templates/add/auth/docs/users-me-mfa.yaml.hbs +12 -0
  47. package/templates/add/auth/internal/app/user/adapters/inbound/http/browser_policy.go.hbs +98 -0
  48. package/templates/add/auth/internal/app/user/adapters/inbound/http/dto.go.hbs +159 -0
  49. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler.go.hbs +228 -0
  50. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_local.go.hbs +76 -0
  51. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_mfa.go.hbs +83 -0
  52. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_oauth.go.hbs +70 -0
  53. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_recovery.go.hbs +49 -0
  54. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_test.go.hbs +311 -0
  55. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_user.go.hbs +41 -0
  56. package/templates/add/auth/internal/app/user/adapters/inbound/http/session_cookie.go.hbs +35 -0
  57. package/templates/add/auth/internal/app/user/adapters/outbound/password/bcrypt.go.hbs +35 -0
  58. package/templates/add/auth/internal/app/user/adapters/outbound/password/bcrypt_test.go.hbs +20 -0
  59. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store.go.hbs +129 -0
  60. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store_test.go.hbs +174 -0
  61. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/model.go.hbs +84 -0
  62. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +211 -0
  63. package/templates/add/auth/internal/app/user/{repository_test.go.hbs → adapters/outbound/postgres/repository_test.go.hbs} +18 -19
  64. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg.go.hbs +213 -0
  65. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg_test.go.hbs +103 -0
  66. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_recovery.go.hbs +84 -0
  67. package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore.go.hbs +228 -0
  68. package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore_test.go.hbs +196 -0
  69. package/templates/add/auth/internal/app/user/application/contracts.go.hbs +52 -0
  70. package/templates/add/auth/internal/app/user/application/dto.go.hbs +75 -0
  71. package/templates/add/auth/internal/app/user/application/errors.go.hbs +62 -0
  72. package/templates/add/auth/internal/app/user/application/external_login.go.hbs +198 -0
  73. package/templates/add/auth/internal/app/user/application/jwt.go.hbs +58 -0
  74. package/templates/add/auth/internal/app/user/application/local_auth.go.hbs +96 -0
  75. package/templates/add/auth/internal/app/user/application/mfa_service.go.hbs +449 -0
  76. package/templates/add/auth/internal/app/user/application/mfa_service_test.go.hbs +200 -0
  77. package/templates/add/auth/internal/app/user/application/oauth.go.hbs +132 -0
  78. package/templates/add/auth/internal/app/user/application/provider_test.go.hbs +285 -0
  79. package/templates/add/auth/internal/app/user/application/recovery.go.hbs +82 -0
  80. package/templates/add/auth/internal/app/user/application/recovery_service.go.hbs +112 -0
  81. package/templates/add/auth/internal/app/user/application/service.go.hbs +145 -0
  82. package/templates/add/auth/internal/app/user/application/service_test.go.hbs +891 -0
  83. package/templates/add/auth/internal/app/user/application/sessions.go.hbs +99 -0
  84. package/templates/add/auth/internal/app/user/application/tokenstore_ports.go.hbs +14 -0
  85. package/templates/add/auth/internal/app/user/application/user_query.go.hbs +65 -0
  86. package/templates/add/auth/internal/app/user/composition.go.hbs +168 -0
  87. package/templates/add/auth/internal/app/user/domain/entity.go.hbs +41 -0
  88. package/templates/add/auth/internal/app/user/domain/errors.go.hbs +32 -0
  89. package/templates/add/auth/internal/app/user/ports/password.go.hbs +9 -0
  90. package/templates/add/auth/internal/app/user/ports/repository.go.hbs +90 -0
  91. package/templates/add/auth/internal/platform/authprovider/google/google.go.hbs +389 -0
  92. package/templates/add/auth/internal/platform/authprovider/google/google_test.go.hbs +312 -0
  93. package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +10 -5
  94. package/templates/add/auth/migrations/create_identities.up.sql.hbs +1 -1
  95. package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +1 -1
  96. package/templates/add/auth/migrations/create_mfa.down.sql.hbs +3 -0
  97. package/templates/add/auth/migrations/create_mfa.up.sql.hbs +29 -0
  98. package/templates/add/auth/migrations/create_users.up.sql.hbs +4 -3
  99. package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler.go.hbs +142 -0
  100. package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler_test.go.hbs +19 -0
  101. package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/model.go.hbs +48 -0
  102. package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/repository.go.hbs +127 -0
  103. package/templates/add/rbac/internal/app/role/{repository_test.go.hbs → adapters/outbound/postgres/repository_test.go.hbs} +8 -8
  104. package/templates/add/rbac/internal/app/role/application/dto.go.hbs +47 -0
  105. package/templates/add/rbac/internal/app/role/application/errors.go.hbs +19 -0
  106. package/templates/add/rbac/internal/app/role/application/service.go.hbs +157 -0
  107. package/templates/add/rbac/internal/app/role/{service_test.go.hbs → application/service_test.go.hbs} +26 -19
  108. package/templates/add/rbac/internal/app/role/composition.go.hbs +48 -0
  109. package/templates/add/rbac/internal/app/role/domain/entity.go.hbs +23 -0
  110. package/templates/add/rbac/internal/app/role/domain/errors.go.hbs +26 -0
  111. package/templates/add/rbac/internal/app/role/ports/repository.go.hbs +25 -0
  112. package/templates/add/rbac/migrations/add_roles.down.sql.hbs +3 -11
  113. package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -6
  114. package/templates/add/worker/internal/platform/queue/river_test.go.hbs +84 -0
  115. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +358 -121
  116. package/templates/create/base/.env.example.hbs +0 -1
  117. package/templates/create/base/.golangci.yml.hbs +2 -2
  118. package/templates/create/base/AGENTS.md.hbs +279 -67
  119. package/templates/create/base/Makefile.hbs +2 -1
  120. package/templates/create/base/README.md.hbs +115 -32
  121. package/templates/create/base/cmd/api/wiring.go.hbs +13 -9
  122. package/templates/create/base/internal/composition/doc.go.hbs +7 -0
  123. package/templates/create/base/internal/platform/database/database.go.hbs +3 -3
  124. package/templates/create/base/internal/shared/apperror/apperror.go.hbs +15 -2
  125. package/templates/create/base/internal/shared/config/config.go.hbs +0 -8
  126. package/templates/create/base/internal/shared/middleware/cors_test.go.hbs +40 -0
  127. package/templates/create/base/internal/shared/middleware/error.go.hbs +15 -5
  128. package/templates/create/features/docs/architecture.md.hbs +92 -32
  129. package/templates/create/features/docs/patterns.md.hbs +137 -91
  130. package/templates/create/features/docs/techstack.md.hbs +18 -3
  131. package/templates/generate/module/hexagonal/adapters/inbound/http/dto.go.hbs +45 -0
  132. package/templates/generate/module/hexagonal/adapters/inbound/http/dto.minimal.go.hbs +28 -0
  133. package/templates/generate/module/hexagonal/adapters/inbound/http/handler.go.hbs +182 -0
  134. package/templates/generate/module/hexagonal/adapters/inbound/http/handler.minimal.go.hbs +83 -0
  135. package/templates/generate/module/hexagonal/adapters/inbound/http/handler_crud_test.go.hbs +18 -0
  136. package/templates/generate/module/hexagonal/adapters/inbound/http/handler_test.go.hbs +30 -0
  137. package/templates/generate/module/hexagonal/adapters/outbound/postgres/model.go.hbs +37 -0
  138. package/templates/generate/module/hexagonal/adapters/outbound/postgres/repository.go.hbs +95 -0
  139. package/templates/generate/module/{repository_test.go.hbs → hexagonal/adapters/outbound/postgres/repository_test.go.hbs} +8 -8
  140. package/templates/generate/module/hexagonal/application/commands.crud.go.hbs +54 -0
  141. package/templates/generate/module/hexagonal/application/commands.go.hbs +25 -0
  142. package/templates/generate/module/hexagonal/application/cqrs_test.go.hbs +66 -0
  143. package/templates/generate/module/hexagonal/application/dto.go.hbs +35 -0
  144. package/templates/generate/module/hexagonal/application/dto.minimal.go.hbs +25 -0
  145. package/templates/generate/module/hexagonal/application/queries.crud.go.hbs +33 -0
  146. package/templates/generate/module/hexagonal/application/queries.go.hbs +25 -0
  147. package/templates/generate/module/hexagonal/application/service.crud.go.hbs +73 -0
  148. package/templates/generate/module/hexagonal/application/service.go.hbs +29 -0
  149. package/templates/generate/module/hexagonal/application/service_test.go.hbs +62 -0
  150. package/templates/generate/module/hexagonal/composition.go.hbs +27 -0
  151. package/templates/generate/module/hexagonal/domain/entity.go.hbs +20 -0
  152. package/templates/generate/module/hexagonal/domain/errors.go.hbs +11 -0
  153. package/templates/generate/module/hexagonal/ports/repository.go.hbs +38 -0
  154. package/templates/generate/module/migration.up.sql.hbs +1 -1
  155. package/dist/utils/method-patcher.js +0 -357
  156. package/templates/add/auth/docs/google-callback.yaml.hbs +0 -22
  157. package/templates/add/auth/docs/google-login.yaml.hbs +0 -7
  158. package/templates/add/auth/internal/app/user/dto.go.hbs +0 -77
  159. package/templates/add/auth/internal/app/user/errors.go.hbs +0 -43
  160. package/templates/add/auth/internal/app/user/handler.go.hbs +0 -276
  161. package/templates/add/auth/internal/app/user/jwt.go.hbs +0 -108
  162. package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +0 -39
  163. package/templates/add/auth/internal/app/user/model/identity.go.hbs +0 -31
  164. package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +0 -26
  165. package/templates/add/auth/internal/app/user/model/user.go.hbs +0 -30
  166. package/templates/add/auth/internal/app/user/repository.go.hbs +0 -137
  167. package/templates/add/auth/internal/app/user/service.go.hbs +0 -531
  168. package/templates/add/auth/internal/app/user/service_test.go.hbs +0 -316
  169. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +0 -30
  170. package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +0 -144
  171. package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +0 -147
  172. package/templates/add/rbac/internal/app/role/dto.go.hbs +0 -45
  173. package/templates/add/rbac/internal/app/role/errors.go.hbs +0 -39
  174. package/templates/add/rbac/internal/app/role/handler.go.hbs +0 -104
  175. package/templates/add/rbac/internal/app/role/model/permission.go.hbs +0 -12
  176. package/templates/add/rbac/internal/app/role/model/role.go.hbs +0 -22
  177. package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +0 -11
  178. package/templates/add/rbac/internal/app/role/repository.go.hbs +0 -97
  179. package/templates/add/rbac/internal/app/role/service.go.hbs +0 -217
  180. package/templates/generate/module/dto.go.hbs +0 -36
  181. package/templates/generate/module/errors.go.hbs +0 -33
  182. package/templates/generate/module/handler.go.hbs +0 -134
  183. package/templates/generate/module/handler_test.go.hbs +0 -174
  184. package/templates/generate/module/minimal/dto.go.hbs +0 -28
  185. package/templates/generate/module/minimal/handler.go.hbs +0 -48
  186. package/templates/generate/module/minimal/handler_test.go.hbs +0 -10
  187. package/templates/generate/module/minimal/service.go.hbs +0 -45
  188. package/templates/generate/module/minimal/service_test.go.hbs +0 -77
  189. package/templates/generate/module/model/model.go.hbs +0 -36
  190. package/templates/generate/module/repository.go.hbs +0 -103
  191. package/templates/generate/module/service.go.hbs +0 -108
  192. package/templates/generate/module/service_test.go.hbs +0 -161
@@ -1,7 +1,7 @@
1
1
  # Coding Patterns: {{projectName}}
2
2
 
3
- > Status: generated by `@nakedev/go-scaffold`
4
- > Last updated: scaffold creation time
3
+ > Status: generated by `@nakedev/go-scaffold`; current generated-project conventions
4
+ > Last reviewed: generated at project creation and refreshed with feature docs
5
5
 
6
6
  ## Domain Package Shape
7
7
 
@@ -10,56 +10,92 @@ like this — copy the shape by hand if you ever add one without the CLI:
10
10
 
11
11
  ```text
12
12
  internal/app/<domain>/
13
- ├── model/ # domain model(s) + GORM table(s) — a folder, one file per table
14
- └── model.go
15
- ├── dto.go # request/response structs + mapping (never leak the model directly)
16
- ├── errors.go # domain error catalog (<DOMAIN>_NOT_FOUND, ...)
17
- ├── repository.go # the only place that touches the DB for this domain, every method takes ctx
18
- ├── service.go # business logic; declares the repository interface it needs (mockable in tests)
19
- ├── handler.go # HTTP: routing, bind, delegate, respond
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
13
+ ├── domain/
14
+ ├── entity.go # business state and invariants; no Gin/GORM/HTTP
15
+ │ └── errors.go # domain error catalog (<DOMAIN>_NOT_FOUND, ...)
16
+ ├── ports/
17
+ │ └── repository.go # consumer-owned persistence/application interfaces
18
+ ├── application/
19
+ ├── dto.go # transport-neutral use-case input/output types
20
+ ├── service.go # service-style use-case orchestration
21
+ ├── commands.go # CQRS writes, when selected
22
+ │ ├── queries.go # CQRS reads, when selected
23
+ │ └── *_test.go # application unit tests, no DB
24
+ ├── adapters/
25
+ │ ├── inbound/http/
26
+ │ │ ├── dto.go # JSON/binding DTOs and application/HTTP mapping
27
+ │ │ ├── handler.go # Gin routing, error/response mapping
28
+ │ │ └── handler_test.go # HTTP adapter tests, no DB
29
+ │ └── outbound/postgres/
30
+ │ ├── model.go # persistence model + explicit domain mapping
31
+ │ ├── repository.go # GORM adapter; the only DB access in the module
32
+ │ └── repository_test.go # Postgres integration tests
33
+ └── composition.go # feature-local adapter → application → handler wiring
23
34
  ```
24
35
 
25
- ### Model
26
-
27
- - Lives in its own `model` subpackage (`internal/app/<domain>/model`), imported
28
- by the rest of the domain as `model.<Type>`
29
- - A folder, not a single file, so a domain with more than one table (e.g.
30
- `order` + `order_item`) adds one file per table instead of growing a single
31
- file — `generate module` only ever creates the first one
32
- - Every consumer inside `cmd/api/wiring.go` imports each domain's `model`
33
- package under an alias (`ordermodel`, `usermodel`, ...) since they all share
34
- the package name `model`
36
+ `service.go` and `commands.go`/`queries.go` are mutually exclusive inside a
37
+ module. Service and CQRS can coexist across different modules in the same
38
+ modular monolith. The root package contains only `composition.go`; other code
39
+ must import the specific boundary package it belongs to.
40
+
41
+ ### Domain and persistence model
42
+
43
+ - `domain/entity.go` owns business state and invariants. It must not import
44
+ Gin, GORM, SQL drivers, Redis, HTTP, or environment configuration.
45
+ - `adapters/outbound/postgres/model.go` owns persistence-only fields and
46
+ explicit `toDomain`/`fromDomain` mapping. A database column or GORM tag must
47
+ not silently become part of the business API.
48
+ - A domain with more than one table should add one persistence model per table
49
+ in the outbound adapter, with the corresponding domain type or value object
50
+ kept in `domain/`.
35
51
  - `TableName()` returns a schema-qualified name (`order_svc.orders`, not
36
52
  `orders`) — every domain gets its own Postgres schema, created by its own
37
53
  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.
54
+ the development table bootstrap runs. A cross-domain FK is still fine see
55
+ the FK rules belowthis only stops one domain's table from silently
56
+ colliding with another's.
43
57
 
44
58
  ## Layer Conventions
45
59
 
46
60
  ### Handler
47
- - Owns routing, request binding, and calling the service
61
+ - Owns routing, request binding, and calling the application service or
62
+ command/query port
48
63
  - No business logic — `c.Error(err); return` on failure, nothing more
49
64
  - Reads pagination via `pagination.Parse(c)`, wraps list responses with
50
65
  `p.Response(out)`
51
66
  - Reads the `:id` param via `httpx.ParseID(c)`
52
67
 
68
+ ### Commands and queries (opt-in CQRS)
69
+
70
+ Use the project default recorded in `go-scaffold.config.json`, or override it
71
+ per module with `--cqrs`, when the feature has distinct write invariants, read
72
+ projections, consistency requirements, or scaling needs. `commands.go` owns
73
+ state-changing application ports and handlers; `queries.go` owns read-only
74
+ ports and handlers. `composition.go` constructs both, and the HTTP adapter
75
+ selects the matching port for each route. They may share the same
76
+ repository/database in this modular monolith — separate storage or event
77
+ sourcing is not implied.
78
+
79
+ The generated CQRS application has separate `CommandPort` and `QueryPort`
80
+ interfaces and separate handlers. Its HTTP adapter receives both through
81
+ `NewHandler`; there is no generated `application/service.go` facade to hide the
82
+ split. New feature code should depend on the narrower port it actually needs.
83
+ `generate module --defaults` uses the project default; choose the single service
84
+ path for simple CRUD and CQRS only when the feature earns the extra boundary.
85
+
53
86
  ### Service
54
87
  - Contains the business logic, knows nothing about HTTP
55
- - Declares a `repository` interface for what it needs from the data layer —
56
- this is what makes it mockable in `service_test.go` without a DB
88
+ - Depends on consumer-owned interfaces from `ports/` for what it needs from
89
+ the data layer — this is what makes it mockable in `application/*_test.go`
90
+ without a DB
57
91
  - Generates the ID itself via `id.New()` before calling `repository.Create`
58
- - Maps DB errors to domain errors with `dberr.IsDuplicate` /
59
- `dberr.IsForeignKey`, never lets a raw DB error escape
92
+ - Returns domain errors and never constructs an HTTP response. The outbound
93
+ adapter maps driver errors with `dberr.IsDuplicate` /
94
+ `dberr.IsForeignKey` before they reach the application boundary.
60
95
 
61
- ### Repository
62
- - The only file per domain that talks to GORM
96
+ ### Repository adapter
97
+ - `adapters/outbound/postgres/repository.go` is the only module code that talks
98
+ to GORM
63
99
  - Every method takes `ctx context.Context` first, so a cancelled request
64
100
  cancels the query
65
101
  - Every query starts from `tx.From(ctx, r.db).WithContext(ctx)`, never from
@@ -75,16 +111,18 @@ internal/app/<domain>/
75
111
  and GORM can misinterpret a bare struct arg
76
112
 
77
113
  ### DTOs
78
- - `createInput`/`updateInput` (request, `binding:` tags) and `response`
79
- (what's actually sent back) are separate types from the model — adding a
80
- DB column later doesn't leak it to clients until you decide to
114
+ - `application/dto.go` owns transport-neutral use-case inputs and output read
115
+ models. `adapters/inbound/http/dto.go` owns JSON names, binding/validation
116
+ tags, and explicit request/response mapping at the HTTP boundary.
117
+ - When `generate method` adds a POST input, it adds both the application input
118
+ and an inbound HTTP DTO/mapping seam. Fill both deliberately; do not bind
119
+ Gin JSON directly into an application type.
81
120
 
82
121
  ### Error Catalog
83
- - One function per error, not a shared `var` the error middleware writes
84
- the request ID onto the returned pointer, so a shared instance would race
85
- across concurrent requests
86
- - Codes are domain-specific (`ORDER_NOT_FOUND`), never the generic
87
- `apperror.NewNotFound()` directly from a handler
122
+ - Domain sentinels live in `domain/errors.go`; they are values used with
123
+ `errors.Is`, not HTTP errors or mutable request state.
124
+ - The inbound adapter maps those sentinels to domain-specific HTTP codes
125
+ (`ORDER_NOT_FOUND`); domain/application code never constructs an HTTP error.
88
126
 
89
127
  ## Optimistic Locking
90
128
 
@@ -103,13 +141,13 @@ and `response` echoes it back: the client round-trips the value, and an
103
141
  update that arrives without one is rejected rather than treated as a blind
104
142
  overwrite.
105
143
 
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.
144
+ The inbound adapter maps `ErrStaleVersion` to the domain's `<DOMAIN>_STALE`
145
+ HTTP 409 response a lost update is a client problem to retry, not a 500. A
146
+ minimal module has the same `Repository.Update` guard waiting for its first
147
+ update use case. Keep the chain intact when you add one by hand:
148
+ `errors.Is(err, domain.ErrStaleVersion)` adapter HTTP mapping, and never set
149
+ `Version` on the persistence model yourself `Repository.Update` owns the
150
+ bump.
113
151
 
114
152
  ## Domains With a Foreign Key (Relations) — 3 Rules
115
153
 
@@ -121,20 +159,21 @@ if this project has one):
121
159
  associations / belongs-to — that's what keeps one domain package from
122
160
  importing another.
123
161
  2. **Declare the FK constraint in migration SQL**
124
- (`REFERENCES ... ON DELETE ...`), not a GORM tag — AutoMigrate doesn't
125
- create the constraint, which would make dev and prod schemas diverge.
126
- 3. **Map the FK error to the right status** via `dberr.IsForeignKey`
127
- inserting a reference to a missing parent, or deleting a parent that
128
- still has children, is a client error (409/422), not a 500. Don't
129
- pre-check existence before insert; let the DB enforce it atomically and
130
- catch the error — a pre-check has a TOCTOU race.
162
+ (`REFERENCES ... ON DELETE ...`), not a GORM tag — the development bootstrap
163
+ doesn't create the constraint, which would make dev and prod schemas diverge.
164
+ 3. **Map the FK error to the right status** in the outbound adapter with
165
+ `dberr.IsForeignKey`, then let the inbound adapter render it inserting a
166
+ reference to a missing parent, or deleting a parent that still has children,
167
+ is a client error (409/422), not a 500. Don't pre-check existence before
168
+ insert; let the DB enforce it atomically and catch the error — a pre-check
169
+ has a TOCTOU race.
131
170
 
132
171
  ## Calling Another Domain's Logic
133
172
 
134
173
  For behavior, not just a data reference (e.g. `order` needs `user`'s email
135
- to put on a receipt) — the caller's `service.go` declares its own narrow
136
- interface for exactly what it needs, the same way it already declares a
137
- `repository` interface.
174
+ to put on a receipt) — the caller's application boundary (`application/service.go`,
175
+ `application/commands.go`, or `application/queries.go`) declares its own narrow interface for exactly
176
+ what it needs, the same way it already declares a `repository` interface.
138
177
 
139
178
  **The interface speaks in the caller's own terms.** No domain package ever
140
179
  imports another domain package — `golangci-lint`'s `depguard` rules enforce
@@ -142,33 +181,33 @@ this, and it is the same rule that makes rule 1 above work. So the interface
142
181
  names primitives, or types the caller owns, never `user.Response`:
143
182
 
144
183
  ```go
145
- // order/service.go
184
+ // order/application/service.go
146
185
  type userLookup interface {
147
186
  EmailOf(ctx context.Context, id uuid.UUID) (string, error)
148
187
  }
149
188
  ```
150
189
 
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:
190
+ The feature keeps its own repository/application/handler composition in
191
+ `composition.go`; `cmd/api/wiring.go` supplies the DB and registers routes.
192
+ Process-level adapters for cross-feature capabilities live in
193
+ `internal/composition/`. For example, auth and RBAC are composed locally and
194
+ the root passes only the role feature's public capabilities:
153
195
 
154
196
  ```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
- ))
197
+ roleComposition := role.NewCompositionFromDB(db, cfg.JWTSecret, cfg.AuthzCacheTTL)
198
+ user.NewHandlerFromDB(
199
+ db, cfg, roleComposition.Service, roleComposition.Authz,
200
+ ).Register(api)
165
201
  ```
166
202
 
167
- where `order` provides the usual func-to-interface shim next to its
168
- interface:
203
+ For ordinary cross-domain behaviour, the caller still declares a narrow
204
+ interface next to its application boundary. Its local composition accepts that
205
+ port, while `internal/composition/` supplies an adapter; the process root never
206
+ reaches into the callee's repository or handler. A func-to-interface shim is
207
+ one small option:
169
208
 
170
209
  ```go
171
- // order/service.go
210
+ // order/application/service.go
172
211
  type UserLookupFunc func(ctx context.Context, id uuid.UUID) (string, error)
173
212
 
174
213
  func (f UserLookupFunc) EmailOf(ctx context.Context, id uuid.UUID) (string, error) {
@@ -183,6 +222,10 @@ can never silently ripple into `order`. The adapter is also the honest place
183
222
  to notice you are reaching for something that should have been the caller's
184
223
  own data.
185
224
 
225
+ Cross-feature integration tests belong under `internal/integration`, where
226
+ they may compose real feature services and adapters. A feature's own unit and
227
+ repository tests stay inside its package and must not import another feature.
228
+
186
229
  - **One direction only.** If `user` would need to call back into `order`,
187
230
  don't wire it both ways — either the two belong in one domain, or the
188
231
  callback needs an event/queue, not a direct call.
@@ -198,30 +241,33 @@ own data.
198
241
  go-scaffold generate method <domain> <name> --type <get|post|put|patch|delete> [--get-mode all|one] [--field <name>]
199
242
  ```
200
243
 
201
- Patches `handler.go`/`service.go` (and `repository.go` + the `repository`
202
- interface + its `repositoryStub` test stub, for a `get --get-mode one --field`
203
- lookup) in place, at the `// go-scaffold:*` marker comments near the end of
204
- each file. **Don't delete those markers** they're where the next
205
- `generate method` call inserts. The method body is always left as a `TODO`
206
- that compiles and returns a clean `500` (`apperror.NewInternal()`) rather
207
- than guessing at business logic same spirit as `generate module`'s
208
- placeholder fields.
244
+ Patches `adapters/inbound/http/handler.go`, its request DTO seam, and the application boundary (or
245
+ `application/commands.go`/`application/queries.go` for a CQRS module; plus
246
+ `ports/repository.go` and the outbound repository adapter/test seam for a
247
+ `get --get-mode one --field` lookup) in place, at the
248
+ `// go-scaffold:*` marker comments near the end of each file. **Don't delete
249
+ those markers** they're where the next `generate method` call inserts. The
250
+ method body is always left as a compiling `TODO` rather than guessing at
251
+ business logic — same spirit as `generate module`'s placeholder fields.
209
252
 
210
253
  ## Testing Conventions
211
254
 
212
255
  - Unit and integration tests live in the same directory as the code under
213
256
  test (Go convention) — never a separate `test/` folder. `test/` is only
214
257
  for e2e black-box suites or fixtures.
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 stub — HTTP tests never need Postgres.
218
- - `repository_test.go` runs against a migrated Postgres database inside a
258
+ - `application/service_test.go` uses a function-backed repository stub so each
259
+ dependency method has independent behavior and argument assertions.
260
+ - `adapters/inbound/http/handler_test.go` checks route composition; focused
261
+ HTTP tests can inject an application port and never need Postgres.
262
+ - `adapters/outbound/postgres/repository_test.go` runs against a migrated
263
+ Postgres database inside a
219
264
  transaction that's rolled back after each test. Local runs may skip when
220
265
  `TEST_DB_DSN` is unset; CI sets `REQUIRE_TEST_DB=true` so it must run.
221
266
 
222
267
  ## Docs Maintenance
223
268
 
224
- - This document describes the output produced by `go-scaffold create` for
225
- the current configuration
226
- - If new domains are generated or the architecture changes, update this doc
227
- to match the real project
269
+ - This document describes the conventions used by generated domains and the
270
+ canonical split emitted by `go-scaffold`.
271
+ - When a shared generator contract changes, update the templates and add a
272
+ regression test. Generated projects with hand-edited docs are left alone by
273
+ incremental feature refreshes and must be updated by their owners.
@@ -1,6 +1,6 @@
1
1
  # Tech Stack: {{projectName}}
2
2
 
3
- > Status: generated by `@nakedev/go-scaffold`
3
+ > Status: generated by `@nakedev/go-scaffold`; refreshes as optional features are added
4
4
 
5
5
  ## Stack Overview
6
6
 
@@ -15,6 +15,9 @@
15
15
  | Logging | `log/slog`, JSON handler |
16
16
  | Migrations | [golang-migrate](https://github.com/golang-migrate/migrate) |
17
17
  | Testing | stdlib `testing`, real Postgres for integration tests |
18
+ | Authentication | {{#if auth}}JWT access/refresh sessions, password recovery, email verification, provider login, and MFA{{else}}optional via `go-scaffold add auth`{{/if}} |
19
+ | Authorization | {{#if rbac}}RBAC role/permission middleware{{else}}optional RBAC via `go-scaffold add rbac`{{/if}} |
20
+ | Background jobs | {{#if worker}}{{queue}} queue backend with SMTP mail and `cmd/worker`{{else}}optional via `go-scaffold add worker`{{/if}} |
18
21
 
19
22
  ## Scaffolded Capabilities
20
23
 
@@ -24,6 +27,9 @@
24
27
  - Docker Compose (local Postgres): `{{#if docker}}enabled{{else}}disabled{{/if}}`
25
28
  - OpenAPI docs (`docs/openapi.yaml`, working-copy only — never served over HTTP): `{{#if openapiDocs}}enabled{{else}}disabled{{/if}}`
26
29
  - Metrics + tracing (Prometheus `/metrics`, OpenTelemetry for Gin + GORM, `cmd/api` only): `{{#if observability}}enabled{{else}}disabled{{/if}}`
30
+ - Authentication and browser OAuth: `{{#if auth}}enabled{{else}}disabled{{/if}}`
31
+ - Background worker and mail: `{{#if worker}}enabled{{else}}disabled{{/if}}`
32
+ - RBAC roles, permissions, and authorization: `{{#if rbac}}enabled{{else}}disabled{{/if}}`
27
33
  - API route prefix: `{{#if apiPrefix}}/{{apiPrefix}}{{else}}(none){{/if}}`
28
34
  - CI (`.github/workflows/ci.yml` — build, vet, gofmt check, golangci-lint, `go test` with a real Postgres service): always enabled
29
35
 
@@ -31,8 +37,14 @@
31
37
 
32
38
  {{#if docker}}- Local Postgres comes from `docker-compose.yml`
33
39
  {{else}}- No Docker scaffolding included — bring your own Postgres and point `DB_DSN` at it
34
- {{/if}}- `AUTO_MIGRATE=true` (dev default) runs GORM AutoMigrate on boot; set it to
35
- `false` in prod and run `migrate up` as a deploy step instead
40
+ {{/if}}{{#if worker}}{{#if (eq queue "river")}}- Run `make river-migrate` before relying on queued jobs in a fresh database;
41
+ run `make river-migrate-test` as well when `REQUIRE_TEST_DB=true` runs the
42
+ River worker round-trip test.{{/if}}{{/if}}
43
+ - `APP_ENV=development` enables the convenience table bootstrap;
44
+ `APP_ENV=production` requires `migrate up` as a deploy step instead
45
+ {{#if worker}}{{#if (eq queue "river")}}- River uses the database/sql driver and polls for jobs instead of receiving
46
+ PostgreSQL LISTEN/NOTIFY wakeups; this is deliberate for a shared GORM pool.
47
+ {{/if}}{{/if}}
36
48
  {{#if openapiDocs}}- `docs/openapi.yaml` is hand-written, not generated — update it whenever an
37
49
  endpoint or DTO changes
38
50
  {{/if}}{{#if observability}}- `OTEL_EXPORTER_OTLP_ENDPOINT` unset (dev default) means tracing exports
@@ -44,3 +56,6 @@
44
56
  resolves them to the versions that feature was built against instead of
45
57
  whatever happened to be newest that day. The full list is `go.mod`; this
46
58
  table only covers what `create` itself brings in
59
+ - Auth, worker, and RBAC are opt-in. Their `add` commands update this snapshot
60
+ when the generated file is untouched; hand-edited docs are left alone and
61
+ require a manual update.
@@ -0,0 +1,45 @@
1
+ package httpadapter
2
+
3
+ import (
4
+ "time"
5
+
6
+ "{{goModule}}/internal/app/{{modulePath}}/application"
7
+
8
+ "github.com/google/uuid"
9
+ )
10
+
11
+ // response is the HTTP representation of the application output. Keep JSON
12
+ // names and protocol-only fields at this inbound boundary.
13
+ type response struct {
14
+ ID uuid.UUID `json:"id"`
15
+ CreatedAt time.Time `json:"created_at"`
16
+ Version int `json:"version"`
17
+ }
18
+
19
+ func toResponse(out application.Response) response {
20
+ return response{ID: out.ID, CreatedAt: out.CreatedAt, Version: out.Version}
21
+ }
22
+
23
+ // Request DTOs belong to the inbound adapter. Keep JSON names, validation
24
+ // tags, and request-to-command mapping here; application inputs stay usable
25
+ // from jobs, tests, and other non-HTTP callers.
26
+ type createInput struct {
27
+ // TODO: mirror the request fields added to application.CreateInput.
28
+ }
29
+
30
+ type updateInput struct {
31
+ Version int `json:"version" binding:"required,min=1"`
32
+ // TODO: mirror the update fields added to application.UpdateInput.
33
+ }
34
+
35
+ func toCreateInput(in createInput) application.CreateInput {
36
+ // TODO: map request fields explicitly as the application contract evolves.
37
+ _ = in
38
+ return application.CreateInput{}
39
+ }
40
+
41
+ func toUpdateInput(in updateInput) application.UpdateInput {
42
+ return application.UpdateInput{Version: in.Version}
43
+ }
44
+
45
+ // go-scaffold:dto
@@ -0,0 +1,28 @@
1
+ package httpadapter
2
+
3
+ import (
4
+ "time"
5
+
6
+ "{{goModule}}/internal/app/{{modulePath}}/application"
7
+
8
+ "github.com/google/uuid"
9
+ )
10
+
11
+ // response is the HTTP representation of the application output. Keep JSON
12
+ // names and protocol-only fields at this inbound boundary.
13
+ //nolint:unused // generate method uses this seam when the first endpoint is added.
14
+ type response struct {
15
+ ID uuid.UUID `json:"id"`
16
+ CreatedAt time.Time `json:"created_at"`
17
+ Version int `json:"version"`
18
+ }
19
+
20
+ //nolint:unused // generate method uses this seam when the first endpoint is added.
21
+ func toResponse(out application.Response) response {
22
+ return response{ID: out.ID, CreatedAt: out.CreatedAt, Version: out.Version}
23
+ }
24
+
25
+ // A lean module has no HTTP body endpoints yet. The marker gives
26
+ // `generate method` a stable place to add request DTOs when a POST is earned.
27
+
28
+ // go-scaffold:dto
@@ -0,0 +1,182 @@
1
+ // Package httpadapter is the Gin inbound adapter for {{pkg}}.
2
+ package httpadapter
3
+
4
+ import (
5
+ "errors"
6
+ "net/http"
7
+
8
+ "{{goModule}}/internal/app/{{modulePath}}/application"
9
+ "{{goModule}}/internal/app/{{modulePath}}/domain"
10
+ "{{goModule}}/internal/shared/apperror"
11
+ "{{goModule}}/internal/shared/httpx"
12
+ {{#if auth}} "{{goModule}}/internal/shared/middleware"
13
+ {{/if}}
14
+ "{{goModule}}/internal/shared/pagination"
15
+
16
+ "github.com/gin-gonic/gin"
17
+ )
18
+
19
+ type Handler struct {
20
+ {{#if cqrs}}
21
+ commands application.CommandPort
22
+ queries application.QueryPort
23
+ {{else}}
24
+ svc application.ServicePort
25
+ {{/if}}
26
+ {{#if auth}}
27
+ jwtSecret string
28
+ {{/if}}
29
+ {{#if permission}}
30
+ authz *middleware.Authz
31
+ {{/if}}
32
+ }
33
+
34
+ {{#if cqrs}}
35
+ func NewHandler(commands application.CommandPort, queries application.QueryPort{{#if auth}}, jwtSecret string{{/if}}{{#if permission}}, authz *middleware.Authz{{/if}}) *Handler {
36
+ h := &Handler{commands: commands, queries: queries}
37
+ {{#if auth}}
38
+ h.jwtSecret = jwtSecret
39
+ {{/if}}
40
+ {{#if permission}}
41
+ h.authz = authz
42
+ {{/if}}
43
+ return h
44
+ }
45
+ {{else}}
46
+ func NewHandler(svc application.ServicePort{{#if auth}}, jwtSecret string{{/if}}{{#if permission}}, authz *middleware.Authz{{/if}}) *Handler {
47
+ h := &Handler{svc: svc}
48
+ {{#if auth}}
49
+ h.jwtSecret = jwtSecret
50
+ {{/if}}
51
+ {{#if permission}}
52
+ h.authz = authz
53
+ {{/if}}
54
+ return h
55
+ }
56
+ {{/if}}
57
+
58
+ func (h *Handler) Register(rg gin.IRouter) {
59
+ g := rg.Group("/{{plural}}"{{#if auth}}, middleware.RequireAuth(h.jwtSecret){{/if}}{{#if permission}}, h.authz.Require("{{permission}}"){{/if}})
60
+ g.POST("", h.create)
61
+ g.GET("", h.list)
62
+ g.GET("/:id", h.get)
63
+ g.PUT("/:id", h.update)
64
+ g.DELETE("/:id", h.delete)
65
+ // go-scaffold:handler-routes
66
+ }
67
+
68
+ func appError(err error) error {
69
+ switch {
70
+ case errors.Is(err, domain.ErrNotFound):
71
+ return applicationError(http.StatusNotFound, "{{errorPrefix}}_NOT_FOUND", "{{pkg}} not found")
72
+ case errors.Is(err, domain.ErrConflict):
73
+ return applicationError(http.StatusConflict, "{{errorPrefix}}_CONFLICT", "{{pkg}} already exists")
74
+ case errors.Is(err, domain.ErrStaleVersion):
75
+ return applicationError(http.StatusConflict, "{{errorPrefix}}_STALE", "{{pkg}} was modified by someone else — reload and try again")
76
+ case errors.Is(err, domain.ErrHasReferences):
77
+ return applicationError(http.StatusConflict, "{{errorPrefix}}_HAS_REFERENCES", "{{pkg}} still has related records")
78
+ case errors.Is(err, domain.ErrNotImplemented):
79
+ return applicationError(http.StatusNotImplemented, "NOT_IMPLEMENTED", "not implemented")
80
+ default:
81
+ return apperror.NewInternal(err)
82
+ }
83
+ }
84
+
85
+ func applicationError(status int, code, message string) *apperror.AppError {
86
+ // The HTTP adapter is the only layer allowed to create an HTTP error.
87
+ return apperror.New(status, code, message)
88
+ }
89
+
90
+ func (h *Handler) create(c *gin.Context) {
91
+ var in createInput
92
+ if err := c.ShouldBindJSON(&in); err != nil {
93
+ c.Error(httpx.BindErr(err))
94
+ return
95
+ }
96
+ {{#if cqrs}}
97
+ m, err := h.commands.Create(c.Request.Context(), toCreateInput(in))
98
+ {{else}}
99
+ m, err := h.svc.Create(c.Request.Context(), toCreateInput(in))
100
+ {{/if}}
101
+ if err != nil {
102
+ c.Error(appError(err))
103
+ return
104
+ }
105
+ c.JSON(http.StatusCreated, toResponse(application.ToResponse(m)))
106
+ }
107
+
108
+ func (h *Handler) list(c *gin.Context) {
109
+ p := pagination.Parse(c)
110
+ {{#if cqrs}}
111
+ items, err := h.queries.List(c.Request.Context(), p.Limit, p.Offset)
112
+ {{else}}
113
+ items, err := h.svc.List(c.Request.Context(), p.Limit, p.Offset)
114
+ {{/if}}
115
+ if err != nil {
116
+ c.Error(appError(err))
117
+ return
118
+ }
119
+ out := make([]response, len(items))
120
+ for i := range items {
121
+ out[i] = toResponse(application.ToResponse(&items[i]))
122
+ }
123
+ c.JSON(http.StatusOK, p.Response(out))
124
+ }
125
+
126
+ func (h *Handler) get(c *gin.Context) {
127
+ id, ok := httpx.ParseID(c)
128
+ if !ok {
129
+ return
130
+ }
131
+ {{#if cqrs}}
132
+ m, err := h.queries.Get(c.Request.Context(), id)
133
+ {{else}}
134
+ m, err := h.svc.Get(c.Request.Context(), id)
135
+ {{/if}}
136
+ if err != nil {
137
+ c.Error(appError(err))
138
+ return
139
+ }
140
+ c.JSON(http.StatusOK, toResponse(application.ToResponse(m)))
141
+ }
142
+
143
+ func (h *Handler) update(c *gin.Context) {
144
+ id, ok := httpx.ParseID(c)
145
+ if !ok {
146
+ return
147
+ }
148
+ var in updateInput
149
+ if err := c.ShouldBindJSON(&in); err != nil {
150
+ c.Error(httpx.BindErr(err))
151
+ return
152
+ }
153
+ {{#if cqrs}}
154
+ m, err := h.commands.Update(c.Request.Context(), id, toUpdateInput(in))
155
+ {{else}}
156
+ m, err := h.svc.Update(c.Request.Context(), id, toUpdateInput(in))
157
+ {{/if}}
158
+ if err != nil {
159
+ c.Error(appError(err))
160
+ return
161
+ }
162
+ c.JSON(http.StatusOK, toResponse(application.ToResponse(m)))
163
+ }
164
+
165
+ func (h *Handler) delete(c *gin.Context) {
166
+ id, ok := httpx.ParseID(c)
167
+ if !ok {
168
+ return
169
+ }
170
+ {{#if cqrs}}
171
+ err := h.commands.Delete(c.Request.Context(), id)
172
+ {{else}}
173
+ err := h.svc.Delete(c.Request.Context(), id)
174
+ {{/if}}
175
+ if err != nil {
176
+ c.Error(appError(err))
177
+ return
178
+ }
179
+ c.Status(http.StatusNoContent)
180
+ }
181
+
182
+ // go-scaffold:handler-funcs