@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.
- package/LICENSE +21 -0
- package/README.md +133 -44
- package/dist/commands/auth.js +116 -11
- package/dist/commands/create.js +13 -1
- package/dist/commands/generate.js +21 -11
- package/dist/commands/method.js +32 -3
- package/dist/commands/observability.js +114 -0
- package/dist/commands/rbac.js +19 -2
- package/dist/commands/undo.js +331 -0
- package/dist/commands/worker.js +92 -32
- package/dist/index.js +366 -63
- package/dist/prompts/auth-wizard.js +29 -0
- package/dist/prompts/create-wizard.js +5 -2
- package/dist/prompts/generate-wizard.js +57 -0
- package/dist/prompts/worker-wizard.js +25 -0
- package/dist/templates/auth-manifest.js +20 -3
- package/dist/templates/create-manifest.js +13 -20
- package/dist/templates/observability-manifest.js +24 -0
- package/dist/templates/rbac-manifest.js +1 -0
- package/dist/templates/worker-manifest.js +23 -6
- package/dist/utils/auth-patcher.js +96 -21
- package/dist/utils/config.js +58 -10
- package/dist/utils/gocheck.js +57 -5
- package/dist/utils/golangci-patcher.js +73 -0
- package/dist/utils/gomod-patcher.js +53 -0
- package/dist/utils/main-patcher.js +58 -4
- package/dist/utils/marker-patch.js +125 -3
- package/dist/utils/method-patcher.js +17 -2
- package/dist/utils/module-location.js +37 -1
- package/dist/utils/naming.js +50 -2
- package/dist/utils/observability-patcher.js +107 -0
- package/dist/utils/platform-patcher.js +98 -12
- package/dist/utils/rbac-patcher.js +60 -10
- package/package.json +3 -5
- package/templates/add/auth/internal/app/user/errors.go.hbs +7 -0
- package/templates/add/auth/internal/app/user/handler.go.hbs +64 -23
- package/templates/add/auth/internal/app/user/jwt.go.hbs +31 -7
- package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +39 -0
- package/templates/add/auth/internal/app/user/model/identity.go.hbs +3 -0
- package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +26 -0
- package/templates/add/auth/internal/app/user/model/user.go.hbs +9 -1
- package/templates/add/auth/internal/app/user/repository.go.hbs +64 -11
- package/templates/add/auth/internal/app/user/repository_test.go.hbs +192 -0
- package/templates/add/auth/internal/app/user/service.go.hbs +105 -21
- package/templates/add/auth/internal/app/user/service_test.go.hbs +81 -2
- package/templates/add/auth/internal/app/user/tokenstore.go.hbs +9 -138
- package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +144 -0
- package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +147 -0
- package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +21 -19
- package/templates/add/auth/internal/shared/middleware/ratelimit_memory.go.hbs +63 -0
- package/templates/add/auth/internal/shared/middleware/ratelimit_redis.go.hbs +32 -0
- package/templates/add/auth/migrations/create_auth_tokens.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +16 -0
- package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_identities.up.sql.hbs +9 -5
- package/templates/add/auth/migrations/create_login_throttle.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +10 -0
- package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_users.up.sql.hbs +13 -2
- package/templates/add/rbac/internal/app/role/dto.go.hbs +8 -3
- package/templates/add/rbac/internal/app/role/handler.go.hbs +4 -1
- package/templates/add/rbac/internal/app/role/model/permission.go.hbs +3 -0
- package/templates/add/rbac/internal/app/role/model/role.go.hbs +4 -0
- package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +3 -0
- package/templates/add/rbac/internal/app/role/repository.go.hbs +12 -11
- package/templates/add/rbac/internal/app/role/repository_test.go.hbs +176 -0
- package/templates/add/rbac/internal/app/role/service.go.hbs +7 -0
- package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +19 -0
- package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +1 -1
- package/templates/add/rbac/migrations/add_roles.down.sql.hbs +5 -5
- package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -11
- package/templates/add/worker/cmd/worker/main.go.hbs +30 -24
- package/templates/add/worker/internal/platform/mail/mail.go.hbs +21 -0
- package/templates/add/worker/internal/platform/mail/task.go.hbs +31 -34
- package/templates/add/worker/internal/platform/queue/asynq.go.hbs +140 -0
- package/templates/add/worker/internal/platform/queue/queue.go.hbs +87 -0
- package/templates/add/worker/internal/platform/queue/river.go.hbs +148 -0
- package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +59 -12
- package/templates/create/base/.dockerignore.hbs +13 -0
- package/templates/create/base/.env.example.hbs +18 -9
- package/templates/create/base/.github/dependabot.yml.hbs +20 -0
- package/templates/create/base/.github/workflows/ci.yml.hbs +12 -2
- package/templates/create/base/.golangci.yml.hbs +27 -0
- package/templates/create/base/AGENTS.md.hbs +8 -4
- package/templates/create/base/Dockerfile.hbs +42 -0
- package/templates/create/base/Makefile.hbs +43 -13
- package/templates/create/base/README.md.hbs +45 -8
- package/templates/create/base/cmd/api/main.go.hbs +17 -130
- package/templates/create/base/cmd/api/wiring.go.hbs +161 -0
- package/templates/create/base/go.mod.hbs +4 -4
- package/templates/create/base/internal/platform/database/database.go.hbs +30 -11
- package/templates/create/base/internal/shared/config/config.go.hbs +13 -7
- package/templates/create/base/internal/shared/pagination/pagination.go.hbs +11 -0
- package/templates/create/base/internal/shared/tx/tx.go.hbs +47 -0
- package/templates/create/base/redocly.yaml.hbs +21 -0
- package/templates/create/features/docs/architecture.md.hbs +32 -11
- package/templates/create/features/docs/openapi.yaml.hbs +0 -4
- package/templates/create/features/docs/patterns.md.hbs +82 -8
- package/templates/create/features/docs/techstack.md.hbs +8 -3
- package/templates/generate/module/dto.go.hbs +8 -1
- package/templates/generate/module/errors.go.hbs +5 -0
- package/templates/generate/module/field-column.down.sql.hbs +2 -0
- package/templates/generate/module/field-column.up.sql.hbs +15 -0
- package/templates/generate/module/handler_test.go.hbs +8 -1
- package/templates/generate/module/migration.down.sql.hbs +3 -1
- package/templates/generate/module/migration.up.sql.hbs +7 -2
- package/templates/generate/module/minimal/dto.go.hbs +3 -1
- package/templates/generate/module/model/model.go.hbs +10 -1
- package/templates/generate/module/permission.up.sql.hbs +3 -1
- package/templates/generate/module/repository.go.hbs +60 -6
- package/templates/generate/module/repository_test.go.hbs +30 -0
- package/templates/generate/module/service.go.hbs +10 -1
- package/templates/generate/module/service_test.go.hbs +45 -0
- package/dist/commands/remove.js +0 -88
- package/scripts/smoke-test.mjs +0 -2058
- package/templates/add/worker/internal/platform/queue/client.go.hbs +0 -31
- package/templates/add/worker/internal/platform/queue/server.go.hbs +0 -68
- package/tests/integration/default-module.test.mjs +0 -46
- package/tests/integration/generator-naming.test.mjs +0 -81
- package/tests/integration/generator-unit-test-seams.test.mjs +0 -91
- package/tests/integration/legacy-method-compat.test.mjs +0 -222
- package/tests/integration/remove-module.test.mjs +0 -58
- package/tests/unit/naming.test.mjs +0 -94
- 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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
{
|
|
28
|
-
|
|
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
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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/
|
|
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
|
|
106
|
-
|
|
107
|
-
`internal/
|
|
108
|
-
`internal/platform/
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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/
|
|
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
|
|
@@ -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/
|
|
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
|
-
|
|
147
|
+
EmailOf(ctx context.Context, id uuid.UUID) (string, error)
|
|
101
148
|
}
|
|
102
149
|
```
|
|
103
150
|
|
|
104
|
-
`cmd/api/
|
|
105
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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 `
|
|
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.
|
|
10
|
+
| HTTP framework | Gin (`github.com/gin-gonic/gin` v1.10.1) |
|
|
11
11
|
| Database | PostgreSQL |
|
|
12
|
-
| ORM | GORM (`gorm.io/gorm` v1.
|
|
13
|
-
| Validation | `go-playground/validator/v10` v10.
|
|
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,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,5 +1,10 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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;
|