@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
@@ -0,0 +1,83 @@
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
+ {{#if auth}}
12
+ "{{goModule}}/internal/shared/middleware"
13
+ {{/if}}
14
+
15
+ "github.com/gin-gonic/gin"
16
+ )
17
+
18
+ type Handler struct {
19
+ {{#if cqrs}}
20
+ commands application.CommandPort
21
+ queries application.QueryPort
22
+ {{else}}
23
+ svc application.ServicePort
24
+ {{/if}}
25
+ {{#if auth}}
26
+ jwtSecret string
27
+ {{/if}}
28
+ {{#if permission}}
29
+ authz *middleware.Authz
30
+ {{/if}}
31
+ }
32
+
33
+ {{#if cqrs}}
34
+ func NewHandler(commands application.CommandPort, queries application.QueryPort{{#if auth}}, jwtSecret string{{/if}}{{#if permission}}, authz *middleware.Authz{{/if}}) *Handler {
35
+ h := &Handler{commands: commands, queries: queries}
36
+ {{#if auth}}
37
+ h.jwtSecret = jwtSecret
38
+ {{/if}}
39
+ {{#if permission}}
40
+ h.authz = authz
41
+ {{/if}}
42
+ return h
43
+ }
44
+ {{else}}
45
+ func NewHandler(svc application.ServicePort{{#if auth}}, jwtSecret string{{/if}}{{#if permission}}, authz *middleware.Authz{{/if}}) *Handler {
46
+ h := &Handler{svc: svc}
47
+ {{#if auth}}
48
+ h.jwtSecret = jwtSecret
49
+ {{/if}}
50
+ {{#if permission}}
51
+ h.authz = authz
52
+ {{/if}}
53
+ return h
54
+ }
55
+ {{/if}}
56
+
57
+ // Register wires the module's route group. Add endpoints with
58
+ // `go-scaffold generate method {{pkg}} <name> --type ...`.
59
+ func (h *Handler) Register(rg gin.IRouter) {
60
+ g := rg.Group("/{{plural}}"{{#if auth}}, middleware.RequireAuth(h.jwtSecret){{/if}}{{#if permission}}, h.authz.Require("{{permission}}"){{/if}})
61
+ _ = g
62
+ _ = appError
63
+ // go-scaffold:handler-routes
64
+ }
65
+
66
+ func appError(err error) error {
67
+ switch {
68
+ case errors.Is(err, domain.ErrNotFound):
69
+ return apperror.New(http.StatusNotFound, "{{errorPrefix}}_NOT_FOUND", "{{pkg}} not found")
70
+ case errors.Is(err, domain.ErrConflict):
71
+ return apperror.New(http.StatusConflict, "{{errorPrefix}}_CONFLICT", "{{pkg}} already exists")
72
+ case errors.Is(err, domain.ErrStaleVersion):
73
+ return apperror.New(http.StatusConflict, "{{errorPrefix}}_STALE", "{{pkg}} was modified by someone else — reload and try again")
74
+ case errors.Is(err, domain.ErrHasReferences):
75
+ return apperror.New(http.StatusConflict, "{{errorPrefix}}_HAS_REFERENCES", "{{pkg}} still has related records")
76
+ case errors.Is(err, domain.ErrNotImplemented):
77
+ return apperror.New(http.StatusNotImplemented, "NOT_IMPLEMENTED", "not implemented")
78
+ default:
79
+ return apperror.NewInternal(err)
80
+ }
81
+ }
82
+
83
+ // go-scaffold:handler-funcs
@@ -0,0 +1,18 @@
1
+ package httpadapter
2
+
3
+ import (
4
+ "testing"
5
+
6
+ "github.com/gin-gonic/gin"
7
+ )
8
+
9
+ func TestHandler_RegisterCRUD(t *testing.T) {
10
+ gin.SetMode(gin.TestMode)
11
+ r := gin.New()
12
+ {{#if cqrs}}
13
+ h := NewHandler(nil, nil{{#if auth}}, "test-secret"{{/if}}{{#if permission}}, nil{{/if}})
14
+ {{else}}
15
+ h := NewHandler(nil{{#if auth}}, "test-secret"{{/if}}{{#if permission}}, nil{{/if}})
16
+ {{/if}}
17
+ h.Register(r)
18
+ }
@@ -0,0 +1,30 @@
1
+ package httpadapter
2
+
3
+ import (
4
+ {{#if permission}}
5
+ "context"
6
+ "time"
7
+ {{/if}}
8
+ "testing"
9
+
10
+ {{#if permission}}
11
+ "{{goModule}}/internal/shared/middleware"
12
+ {{/if}}
13
+ "github.com/gin-gonic/gin"
14
+ )
15
+
16
+ func TestHandler_Register(t *testing.T) {
17
+ gin.SetMode(gin.TestMode)
18
+ r := gin.New()
19
+ {{#if permission}}
20
+ authz := middleware.NewAuthz(func(context.Context, string) (map[string]struct{}, error) {
21
+ return map[string]struct{}{"{{permission}}": {}}, nil
22
+ }, time.Minute)
23
+ {{/if}}
24
+ {{#if cqrs}}
25
+ h := NewHandler(nil, nil{{#if auth}}, "test-secret"{{/if}}{{#if permission}}, authz{{/if}})
26
+ {{else}}
27
+ h := NewHandler(nil{{#if auth}}, "test-secret"{{/if}}{{#if permission}}, authz{{/if}})
28
+ {{/if}}
29
+ h.Register(r)
30
+ }
@@ -0,0 +1,37 @@
1
+ // Package postgres is the {{pkg}} outbound adapter. Its GORM model is not
2
+ // exported as a domain type; conversion happens at this boundary.
3
+ package postgres
4
+
5
+ import (
6
+ "time"
7
+
8
+ "{{goModule}}/internal/app/{{modulePath}}/domain"
9
+
10
+ "github.com/google/uuid"
11
+ )
12
+
13
+ type {{pascalName}}Model struct {
14
+ ID uuid.UUID `gorm:"type:uuid;primaryKey"`
15
+ CreatedAt time.Time
16
+ UpdatedAt time.Time
17
+ Version int `gorm:"not null;default:1"`
18
+ // TODO: keep persistence-only fields here and map them explicitly.
19
+ }
20
+
21
+ func ({{pascalName}}Model) TableName() string {
22
+ return "{{schemaName}}.{{tableName}}"
23
+ }
24
+
25
+ func toDomain(m *{{pascalName}}Model) *domain.{{pascalName}} {
26
+ if m == nil {
27
+ return nil
28
+ }
29
+ return &domain.{{pascalName}}{ID: m.ID, CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt, Version: m.Version}
30
+ }
31
+
32
+ func fromDomain(m *domain.{{pascalName}}) *{{pascalName}}Model {
33
+ if m == nil {
34
+ return nil
35
+ }
36
+ return &{{pascalName}}Model{ID: m.ID, CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt, Version: m.Version}
37
+ }
@@ -0,0 +1,95 @@
1
+ package postgres
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+
7
+ "{{goModule}}/internal/app/{{modulePath}}/domain"
8
+ "{{goModule}}/internal/app/{{modulePath}}/ports"
9
+ "{{goModule}}/internal/shared/dberr"
10
+ "{{goModule}}/internal/shared/tx"
11
+
12
+ "github.com/google/uuid"
13
+ "gorm.io/gorm"
14
+ )
15
+
16
+ var _ ports.Repository = (*Repository)(nil)
17
+
18
+ type Repository struct {
19
+ db *gorm.DB
20
+ }
21
+
22
+ func NewRepository(db *gorm.DB) *Repository {
23
+ return &Repository{db: db}
24
+ }
25
+
26
+ func (r *Repository) Create(ctx context.Context, m *domain.{{pascalName}}) error {
27
+ if err := tx.From(ctx, r.db).WithContext(ctx).Create(fromDomain(m)).Error; err != nil {
28
+ return mapDatabaseError(err)
29
+ }
30
+ return nil
31
+ }
32
+
33
+ func (r *Repository) FindAll(ctx context.Context, limit, offset int) ([]domain.{{pascalName}}, error) {
34
+ var rows []{{pascalName}}Model
35
+ if err := tx.From(ctx, r.db).WithContext(ctx).Order("id desc").Limit(limit).Offset(offset).Find(&rows).Error; err != nil {
36
+ return nil, mapDatabaseError(err)
37
+ }
38
+ items := make([]domain.{{pascalName}}, len(rows))
39
+ for i := range rows {
40
+ items[i] = *toDomain(&rows[i])
41
+ }
42
+ return items, nil
43
+ }
44
+
45
+ func (r *Repository) FindByID(ctx context.Context, id uuid.UUID) (*domain.{{pascalName}}, error) {
46
+ var row {{pascalName}}Model
47
+ if err := tx.From(ctx, r.db).WithContext(ctx).First(&row, "id = ?", id).Error; err != nil {
48
+ return nil, mapDatabaseError(err)
49
+ }
50
+ return toDomain(&row), nil
51
+ }
52
+
53
+ func (r *Repository) Update(ctx context.Context, m *domain.{{pascalName}}) error {
54
+ expected := m.Version
55
+ row := fromDomain(m)
56
+ row.Version = expected + 1
57
+ result := tx.From(ctx, r.db).WithContext(ctx).
58
+ Model(&{{pascalName}}Model{}).
59
+ Where("id = ? AND version = ?", m.ID, expected).
60
+ Select("*").Omit("id", "created_at").Updates(row)
61
+ if result.Error != nil {
62
+ return mapDatabaseError(result.Error)
63
+ }
64
+ if result.RowsAffected == 0 {
65
+ return domain.ErrStaleVersion
66
+ }
67
+ m.Version = row.Version
68
+ return nil
69
+ }
70
+
71
+ func (r *Repository) Delete(ctx context.Context, id uuid.UUID) error {
72
+ result := tx.From(ctx, r.db).WithContext(ctx).Delete(&{{pascalName}}Model{}, "id = ?", id)
73
+ if result.Error != nil {
74
+ return mapDatabaseError(result.Error)
75
+ }
76
+ if result.RowsAffected == 0 {
77
+ return domain.ErrNotFound
78
+ }
79
+ return nil
80
+ }
81
+
82
+ func mapDatabaseError(err error) error {
83
+ if errors.Is(err, gorm.ErrRecordNotFound) {
84
+ return domain.ErrNotFound
85
+ }
86
+ if dberr.IsDuplicate(err) {
87
+ return domain.ErrConflict
88
+ }
89
+ if dberr.IsForeignKey(err) {
90
+ return domain.ErrHasReferences
91
+ }
92
+ return err
93
+ }
94
+
95
+ // go-scaffold:repository-methods
@@ -1,4 +1,4 @@
1
- package {{pkg}}
1
+ package postgres
2
2
 
3
3
  import (
4
4
  "context"
@@ -6,7 +6,7 @@ import (
6
6
  "os"
7
7
  "testing"
8
8
 
9
- "{{goModule}}/internal/app/{{modulePath}}/model"
9
+ "{{goModule}}/internal/app/{{modulePath}}/domain"
10
10
 
11
11
  "github.com/google/uuid"
12
12
  "gorm.io/driver/postgres"
@@ -15,7 +15,7 @@ import (
15
15
 
16
16
  // repositoryDBForTest expects the test database schema to come from the same
17
17
  // versioned SQL migrations used in production. Unit tests stay database-free;
18
- // CI sets REQUIRE_TEST_DB=true so an unavailable/unmigrated database fails
18
+ // CI sets REQUIRE_TEST_DB=true so an unavailable or unmigrated database fails
19
19
  // instead of becoming a false-green skip.
20
20
  func repositoryDBForTest(t *testing.T) *gorm.DB {
21
21
  t.Helper()
@@ -62,7 +62,7 @@ func TestRepository_CreateFindDelete(t *testing.T) {
62
62
  repo := NewRepository(repositoryDBForTest(t))
63
63
  ctx := context.Background()
64
64
  id := uuid.New()
65
- item := &model.{{pascalName}}{ID: id}
65
+ item := &domain.{{pascalName}}{ID: id, Version: 1}
66
66
 
67
67
  if err := repo.Create(ctx, item); err != nil {
68
68
  t.Fatalf("create: %v", err)
@@ -85,13 +85,13 @@ func TestRepository_CreateFindDelete(t *testing.T) {
85
85
  func TestRepository_Update_RefusesStaleVersion(t *testing.T) {
86
86
  repo := NewRepository(repositoryDBForTest(t))
87
87
  ctx := context.Background()
88
- item := &model.{{pascalName}}{ID: uuid.New()}
88
+ item := &domain.{{pascalName}}{ID: uuid.New(), Version: 1}
89
89
 
90
90
  if err := repo.Create(ctx, item); err != nil {
91
91
  t.Fatalf("create: %v", err)
92
92
  }
93
93
 
94
- first := &model.{{pascalName}}{ID: item.ID, Version: item.Version}
94
+ first := &domain.{{pascalName}}{ID: item.ID, Version: item.Version}
95
95
  if err := repo.Update(ctx, first); err != nil {
96
96
  t.Fatalf("first update: %v", err)
97
97
  }
@@ -99,8 +99,8 @@ func TestRepository_Update_RefusesStaleVersion(t *testing.T) {
99
99
  t.Fatalf("want version bumped to %d, got %d", item.Version+1, first.Version)
100
100
  }
101
101
 
102
- stale := &model.{{pascalName}}{ID: item.ID, Version: item.Version}
103
- if err := repo.Update(ctx, stale); !errors.Is(err, ErrStaleVersion) {
102
+ stale := &domain.{{pascalName}}{ID: item.ID, Version: item.Version}
103
+ if err := repo.Update(ctx, stale); !errors.Is(err, domain.ErrStaleVersion) {
104
104
  t.Fatalf("want ErrStaleVersion for the second writer, got %v", err)
105
105
  }
106
106
  if stale.Version != item.Version {
@@ -0,0 +1,54 @@
1
+ package application
2
+
3
+ import (
4
+ "context"
5
+ "{{goModule}}/internal/app/{{modulePath}}/domain"
6
+ "{{goModule}}/internal/app/{{modulePath}}/ports"
7
+ "{{goModule}}/internal/shared/id"
8
+
9
+ "github.com/google/uuid"
10
+ )
11
+
12
+ type CommandPort interface {
13
+ Create(context.Context, CreateInput) (*domain.{{pascalName}}, error)
14
+ Update(context.Context, uuid.UUID, UpdateInput) (*domain.{{pascalName}}, error)
15
+ Delete(context.Context, uuid.UUID) error
16
+ // go-scaffold:command-interface
17
+ }
18
+
19
+ type CommandHandler struct {
20
+ repo ports.CommandRepository
21
+ }
22
+
23
+ func NewCommandHandler(repo ports.CommandRepository) *CommandHandler {
24
+ return &CommandHandler{repo: repo}
25
+ }
26
+
27
+ func (h *CommandHandler) Create(ctx context.Context, in CreateInput) (*domain.{{pascalName}}, error) {
28
+ // TODO: map in to real domain fields and enforce invariants.
29
+ _ = in
30
+ m := &domain.{{pascalName}}{ID: id.New(), Version: 1}
31
+ if err := h.repo.Create(ctx, m); err != nil {
32
+ return nil, err
33
+ }
34
+ return m, nil
35
+ }
36
+
37
+ func (h *CommandHandler) Update(ctx context.Context, id uuid.UUID, in UpdateInput) (*domain.{{pascalName}}, error) {
38
+ m, err := h.repo.FindByID(ctx, id)
39
+ if err != nil {
40
+ return nil, err
41
+ }
42
+ // TODO: map in to real domain fields before saving.
43
+ m.Version = in.Version
44
+ if err := h.repo.Update(ctx, m); err != nil {
45
+ return nil, err
46
+ }
47
+ return m, nil
48
+ }
49
+
50
+ func (h *CommandHandler) Delete(ctx context.Context, id uuid.UUID) error {
51
+ return h.repo.Delete(ctx, id)
52
+ }
53
+
54
+ // go-scaffold:command-methods
@@ -0,0 +1,25 @@
1
+ package application
2
+
3
+ import (
4
+ "context"
5
+
6
+ "{{goModule}}/internal/app/{{modulePath}}/domain"
7
+ "{{goModule}}/internal/app/{{modulePath}}/ports"
8
+ )
9
+
10
+ type CommandPort interface {
11
+ // go-scaffold:command-interface
12
+ }
13
+
14
+ type CommandHandler struct {
15
+ repo ports.CommandRepository
16
+ }
17
+
18
+ func NewCommandHandler(repo ports.CommandRepository) *CommandHandler {
19
+ return &CommandHandler{repo: repo}
20
+ }
21
+
22
+ var _ context.Context
23
+ var _ *domain.{{pascalName}}
24
+
25
+ // go-scaffold:command-methods
@@ -0,0 +1,66 @@
1
+ package application
2
+
3
+ import (
4
+ "context"
5
+ "testing"
6
+
7
+ "{{goModule}}/internal/app/{{modulePath}}/domain"
8
+ "{{goModule}}/internal/app/{{modulePath}}/ports"
9
+
10
+ "github.com/google/uuid"
11
+ )
12
+
13
+ // repositoryStub is shared by command and query tests. It intentionally
14
+ // exposes one function per operation so a focused test can assert exactly
15
+ // which side of the CQRS boundary was called.
16
+ type repositoryStub struct {
17
+ createFn func(context.Context, *domain.{{pascalName}}) error
18
+ findAllFn func(context.Context, int, int) ([]domain.{{pascalName}}, error)
19
+ findByIDFn func(context.Context, uuid.UUID) (*domain.{{pascalName}}, error)
20
+ updateFn func(context.Context, *domain.{{pascalName}}) error
21
+ deleteFn func(context.Context, uuid.UUID) error
22
+ // go-scaffold:repository-stub-fields
23
+ }
24
+
25
+ func (s *repositoryStub) Create(ctx context.Context, m *domain.{{pascalName}}) error {
26
+ if s.createFn == nil {
27
+ panic("unexpected repository.Create call")
28
+ }
29
+ return s.createFn(ctx, m)
30
+ }
31
+ func (s *repositoryStub) FindAll(ctx context.Context, limit, offset int) ([]domain.{{pascalName}}, error) {
32
+ if s.findAllFn == nil {
33
+ panic("unexpected repository.FindAll call")
34
+ }
35
+ return s.findAllFn(ctx, limit, offset)
36
+ }
37
+ func (s *repositoryStub) FindByID(ctx context.Context, id uuid.UUID) (*domain.{{pascalName}}, error) {
38
+ if s.findByIDFn == nil {
39
+ panic("unexpected repository.FindByID call")
40
+ }
41
+ return s.findByIDFn(ctx, id)
42
+ }
43
+ func (s *repositoryStub) Update(ctx context.Context, m *domain.{{pascalName}}) error {
44
+ if s.updateFn == nil {
45
+ panic("unexpected repository.Update call")
46
+ }
47
+ return s.updateFn(ctx, m)
48
+ }
49
+ func (s *repositoryStub) Delete(ctx context.Context, id uuid.UUID) error {
50
+ if s.deleteFn == nil {
51
+ panic("unexpected repository.Delete call")
52
+ }
53
+ return s.deleteFn(ctx, id)
54
+ }
55
+
56
+ var _ ports.CommandRepository = (*repositoryStub)(nil)
57
+ var _ ports.QueryRepository = (*repositoryStub)(nil)
58
+
59
+ func TestCQRSHandlersCompose(t *testing.T) {
60
+ repo := &repositoryStub{}
61
+ if NewCommandHandler(repo) == nil || NewQueryHandler(repo) == nil {
62
+ t.Fatal("expected command and query handlers")
63
+ }
64
+ }
65
+
66
+ // go-scaffold:repository-stub-methods
@@ -0,0 +1,35 @@
1
+ package application
2
+
3
+ import (
4
+ "time"
5
+
6
+ "{{goModule}}/internal/app/{{modulePath}}/domain"
7
+
8
+ "github.com/google/uuid"
9
+ )
10
+
11
+ // CreateInput and UpdateInput are transport-neutral application inputs. JSON
12
+ // names and binding/validation tags stay in adapters/inbound/http/dto.go.
13
+ type CreateInput struct {
14
+ // TODO: add command fields and validate them in the application/domain.
15
+ }
16
+
17
+ type UpdateInput struct {
18
+ // Version is the optimistic-concurrency version the client last observed.
19
+ Version int
20
+ // TODO: add update fields.
21
+ }
22
+
23
+ // Response is a transport-neutral application output. The inbound adapter
24
+ // maps it to its protocol response before serialization.
25
+ type Response struct {
26
+ ID uuid.UUID
27
+ CreatedAt time.Time
28
+ Version int
29
+ }
30
+
31
+ func ToResponse(m *domain.{{pascalName}}) Response {
32
+ return Response{ID: m.ID, CreatedAt: m.CreatedAt, Version: m.Version}
33
+ }
34
+
35
+ // go-scaffold:dto
@@ -0,0 +1,25 @@
1
+ package application
2
+
3
+ import (
4
+ "time"
5
+
6
+ "{{goModule}}/internal/app/{{modulePath}}/domain"
7
+
8
+ "github.com/google/uuid"
9
+ )
10
+
11
+ // A lean module starts with no use-case input type. `generate method` adds
12
+ // the exact input beside the endpoint that needs it.
13
+ // Response is a transport-neutral application output. The inbound adapter
14
+ // maps it to its protocol response before serialization.
15
+ type Response struct {
16
+ ID uuid.UUID
17
+ CreatedAt time.Time
18
+ Version int
19
+ }
20
+
21
+ func ToResponse(m *domain.{{pascalName}}) Response {
22
+ return Response{ID: m.ID, CreatedAt: m.CreatedAt, Version: m.Version}
23
+ }
24
+
25
+ // go-scaffold:dto
@@ -0,0 +1,33 @@
1
+ package application
2
+
3
+ import (
4
+ "context"
5
+ "{{goModule}}/internal/app/{{modulePath}}/domain"
6
+ "{{goModule}}/internal/app/{{modulePath}}/ports"
7
+
8
+ "github.com/google/uuid"
9
+ )
10
+
11
+ type QueryPort interface {
12
+ List(context.Context, int, int) ([]domain.{{pascalName}}, error)
13
+ Get(context.Context, uuid.UUID) (*domain.{{pascalName}}, error)
14
+ // go-scaffold:query-interface
15
+ }
16
+
17
+ type QueryHandler struct {
18
+ repo ports.QueryRepository
19
+ }
20
+
21
+ func NewQueryHandler(repo ports.QueryRepository) *QueryHandler {
22
+ return &QueryHandler{repo: repo}
23
+ }
24
+
25
+ func (h *QueryHandler) List(ctx context.Context, limit, offset int) ([]domain.{{pascalName}}, error) {
26
+ return h.repo.FindAll(ctx, limit, offset)
27
+ }
28
+
29
+ func (h *QueryHandler) Get(ctx context.Context, id uuid.UUID) (*domain.{{pascalName}}, error) {
30
+ return h.repo.FindByID(ctx, id)
31
+ }
32
+
33
+ // go-scaffold:query-methods
@@ -0,0 +1,25 @@
1
+ package application
2
+
3
+ import (
4
+ "context"
5
+
6
+ "{{goModule}}/internal/app/{{modulePath}}/domain"
7
+ "{{goModule}}/internal/app/{{modulePath}}/ports"
8
+ )
9
+
10
+ type QueryPort interface {
11
+ // go-scaffold:query-interface
12
+ }
13
+
14
+ type QueryHandler struct {
15
+ repo ports.QueryRepository
16
+ }
17
+
18
+ func NewQueryHandler(repo ports.QueryRepository) *QueryHandler {
19
+ return &QueryHandler{repo: repo}
20
+ }
21
+
22
+ var _ context.Context
23
+ var _ *domain.{{pascalName}}
24
+
25
+ // go-scaffold:query-methods
@@ -0,0 +1,73 @@
1
+ package application
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+
7
+ "{{goModule}}/internal/app/{{modulePath}}/domain"
8
+ "{{goModule}}/internal/app/{{modulePath}}/ports"
9
+ "{{goModule}}/internal/shared/id"
10
+
11
+ "github.com/google/uuid"
12
+ )
13
+
14
+ type ServicePort interface {
15
+ Create(context.Context, CreateInput) (*domain.{{pascalName}}, error)
16
+ List(context.Context, int, int) ([]domain.{{pascalName}}, error)
17
+ Get(context.Context, uuid.UUID) (*domain.{{pascalName}}, error)
18
+ Update(context.Context, uuid.UUID, UpdateInput) (*domain.{{pascalName}}, error)
19
+ Delete(context.Context, uuid.UUID) error
20
+ // go-scaffold:service-interface
21
+ }
22
+
23
+ type Service struct {
24
+ repo ports.Repository
25
+ }
26
+
27
+ func NewService(repo ports.Repository) *Service {
28
+ return &Service{repo: repo}
29
+ }
30
+
31
+ func (s *Service) Create(ctx context.Context, in CreateInput) (*domain.{{pascalName}}, error) {
32
+ // TODO: map in to real domain fields and enforce invariants.
33
+ _ = in
34
+ m := &domain.{{pascalName}}{ID: id.New(), Version: 1}
35
+ if err := s.repo.Create(ctx, m); err != nil {
36
+ return nil, err
37
+ }
38
+ return m, nil
39
+ }
40
+
41
+ func (s *Service) List(ctx context.Context, limit, offset int) ([]domain.{{pascalName}}, error) {
42
+ return s.repo.FindAll(ctx, limit, offset)
43
+ }
44
+
45
+ func (s *Service) Get(ctx context.Context, id uuid.UUID) (*domain.{{pascalName}}, error) {
46
+ return s.repo.FindByID(ctx, id)
47
+ }
48
+
49
+ func (s *Service) Update(ctx context.Context, id uuid.UUID, in UpdateInput) (*domain.{{pascalName}}, error) {
50
+ m, err := s.repo.FindByID(ctx, id)
51
+ if err != nil {
52
+ return nil, err
53
+ }
54
+ // TODO: map in to real domain fields before saving.
55
+ m.Version = in.Version
56
+ if err := s.repo.Update(ctx, m); err != nil {
57
+ return nil, err
58
+ }
59
+ return m, nil
60
+ }
61
+
62
+ func (s *Service) Delete(ctx context.Context, id uuid.UUID) error {
63
+ if err := s.repo.Delete(ctx, id); err != nil {
64
+ return err
65
+ }
66
+ return nil
67
+ }
68
+
69
+ // IsNotFound is useful to non-HTTP callers without exposing persistence
70
+ // errors. The inbound HTTP adapter maps the same domain sentinel to 404.
71
+ func IsNotFound(err error) bool { return errors.Is(err, domain.ErrNotFound) }
72
+
73
+ // go-scaffold:service-methods