litclaude-ai 0.2.2
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/CHANGELOG.md +155 -0
- package/LICENSE +21 -0
- package/README.md +369 -0
- package/README_ko-KR.md +374 -0
- package/RELEASE_CHECKLIST.md +165 -0
- package/bin/litclaude-ai.js +643 -0
- package/cover.png +0 -0
- package/docs/agents.md +67 -0
- package/docs/hooks.md +134 -0
- package/docs/lsp.md +40 -0
- package/docs/migration.md +209 -0
- package/docs/workflow-compatibility-audit.md +119 -0
- package/generate_cover.py +123 -0
- package/package.json +48 -0
- package/plugins/litclaude/.claude-plugin/plugin.json +25 -0
- package/plugins/litclaude/.lsp.json +13 -0
- package/plugins/litclaude/.mcp.json +9 -0
- package/plugins/litclaude/agents/boulder-executor.md +12 -0
- package/plugins/litclaude/agents/librarian-researcher.md +15 -0
- package/plugins/litclaude/agents/oracle-verifier.md +16 -0
- package/plugins/litclaude/agents/prometheus-planner.md +13 -0
- package/plugins/litclaude/agents/qa-runner.md +16 -0
- package/plugins/litclaude/agents/quality-reviewer.md +17 -0
- package/plugins/litclaude/bin/litclaude-hook.js +110 -0
- package/plugins/litclaude/bin/litclaude-hud.js +271 -0
- package/plugins/litclaude/bin/litclaude-lsp-doctor.js +15 -0
- package/plugins/litclaude/bin/litclaude-mcp.js +70 -0
- package/plugins/litclaude/commands/deep-interview.md +21 -0
- package/plugins/litclaude/commands/dynamic-workflow.md +36 -0
- package/plugins/litclaude/commands/lit-loop.md +40 -0
- package/plugins/litclaude/commands/lit-plan.md +35 -0
- package/plugins/litclaude/commands/litgoal.md +30 -0
- package/plugins/litclaude/commands/review-work.md +35 -0
- package/plugins/litclaude/commands/start-work.md +36 -0
- package/plugins/litclaude/hooks/hooks.json +54 -0
- package/plugins/litclaude/lib/context-pressure.mjs +25 -0
- package/plugins/litclaude/lib/hud-accent-palette.mjs +58 -0
- package/plugins/litclaude/lib/litgoal/cli.mjs +266 -0
- package/plugins/litclaude/lib/litgoal/ledger.mjs +16 -0
- package/plugins/litclaude/lib/litgoal/paths.mjs +7 -0
- package/plugins/litclaude/lib/litgoal/state.mjs +67 -0
- package/plugins/litclaude/lib/mutated-file-paths.mjs +63 -0
- package/plugins/litclaude/lib/start-work-continuation.mjs +99 -0
- package/plugins/litclaude/lib/workflow-check.mjs +83 -0
- package/plugins/litclaude/skills/ai-slop-remover/SKILL.md +142 -0
- package/plugins/litclaude/skills/comment-checker/SKILL.md +55 -0
- package/plugins/litclaude/skills/debugging/SKILL.md +70 -0
- package/plugins/litclaude/skills/debugging/references/methodology/00-setup.md +108 -0
- package/plugins/litclaude/skills/debugging/references/methodology/02-investigate.md +126 -0
- package/plugins/litclaude/skills/debugging/references/methodology/04-oracle-triple.md +106 -0
- package/plugins/litclaude/skills/debugging/references/methodology/05-escalate.md +69 -0
- package/plugins/litclaude/skills/debugging/references/methodology/06-fix.md +116 -0
- package/plugins/litclaude/skills/debugging/references/methodology/08-qa.md +94 -0
- package/plugins/litclaude/skills/debugging/references/methodology/09-cleanup.md +164 -0
- package/plugins/litclaude/skills/debugging/references/methodology/partial-runtime-evidence.md +228 -0
- package/plugins/litclaude/skills/debugging/references/runtimes/bundled-js-binary.md +415 -0
- package/plugins/litclaude/skills/debugging/references/runtimes/go.md +252 -0
- package/plugins/litclaude/skills/debugging/references/runtimes/native-binary.md +484 -0
- package/plugins/litclaude/skills/debugging/references/runtimes/node.md +260 -0
- package/plugins/litclaude/skills/debugging/references/runtimes/python.md +248 -0
- package/plugins/litclaude/skills/debugging/references/runtimes/rust.md +234 -0
- package/plugins/litclaude/skills/debugging/references/tools/ghidra.md +212 -0
- package/plugins/litclaude/skills/debugging/references/tools/playwright-cli.md +194 -0
- package/plugins/litclaude/skills/debugging/references/tools/pwndbg.md +263 -0
- package/plugins/litclaude/skills/debugging/references/tools/pwntools.md +265 -0
- package/plugins/litclaude/skills/deep-interview/SKILL.md +323 -0
- package/plugins/litclaude/skills/deep-interview/scripts/render_progress.py +193 -0
- package/plugins/litclaude/skills/frontend-ui-ux/SKILL.md +62 -0
- package/plugins/litclaude/skills/lit-loop/SKILL.md +144 -0
- package/plugins/litclaude/skills/lit-plan/SKILL.md +125 -0
- package/plugins/litclaude/skills/litgoal/SKILL.md +219 -0
- package/plugins/litclaude/skills/lsp/SKILL.md +63 -0
- package/plugins/litclaude/skills/programming/SKILL.md +106 -0
- package/plugins/litclaude/skills/programming/references/go/README.md +90 -0
- package/plugins/litclaude/skills/programming/references/go/backend-stack.md +641 -0
- package/plugins/litclaude/skills/programming/references/go/bootstrap.md +328 -0
- package/plugins/litclaude/skills/programming/references/go/bubbletea-v2.md +360 -0
- package/plugins/litclaude/skills/programming/references/go/cobra-stack.md +468 -0
- package/plugins/litclaude/skills/programming/references/go/concurrency.md +362 -0
- package/plugins/litclaude/skills/programming/references/go/data-modeling.md +329 -0
- package/plugins/litclaude/skills/programming/references/go/error-handling.md +359 -0
- package/plugins/litclaude/skills/programming/references/go/golangci-strict.md +236 -0
- package/plugins/litclaude/skills/programming/references/go/grpc-connect.md +375 -0
- package/plugins/litclaude/skills/programming/references/go/libraries.md +337 -0
- package/plugins/litclaude/skills/programming/references/go/one-liners.md +202 -0
- package/plugins/litclaude/skills/programming/references/go/sqlc-pgx.md +471 -0
- package/plugins/litclaude/skills/programming/references/go/testing.md +467 -0
- package/plugins/litclaude/skills/programming/references/go/type-patterns.md +298 -0
- package/plugins/litclaude/skills/programming/references/python/README.md +314 -0
- package/plugins/litclaude/skills/programming/references/python/async-anyio.md +442 -0
- package/plugins/litclaude/skills/programming/references/python/data-modeling.md +233 -0
- package/plugins/litclaude/skills/programming/references/python/data-processing.md +133 -0
- package/plugins/litclaude/skills/programming/references/python/error-handling.md +218 -0
- package/plugins/litclaude/skills/programming/references/python/fastapi-stack.md +316 -0
- package/plugins/litclaude/skills/programming/references/python/httpx2-optimization.md +360 -0
- package/plugins/litclaude/skills/programming/references/python/libraries.md +307 -0
- package/plugins/litclaude/skills/programming/references/python/one-liners.md +268 -0
- package/plugins/litclaude/skills/programming/references/python/orjson-stack.md +378 -0
- package/plugins/litclaude/skills/programming/references/python/pydantic-ai.md +285 -0
- package/plugins/litclaude/skills/programming/references/python/pyproject-strict.md +232 -0
- package/plugins/litclaude/skills/programming/references/python/textual-tui.md +201 -0
- package/plugins/litclaude/skills/programming/references/python/type-patterns.md +176 -0
- package/plugins/litclaude/skills/programming/references/rust/README.md +317 -0
- package/plugins/litclaude/skills/programming/references/rust/async-tokio.md +299 -0
- package/plugins/litclaude/skills/programming/references/rust/axum-stack.md +467 -0
- package/plugins/litclaude/skills/programming/references/rust/cargo-strict.md +317 -0
- package/plugins/litclaude/skills/programming/references/rust/clap-stack.md +409 -0
- package/plugins/litclaude/skills/programming/references/rust/concurrency.md +375 -0
- package/plugins/litclaude/skills/programming/references/rust/libraries.md +439 -0
- package/plugins/litclaude/skills/programming/references/rust/one-liners.md +291 -0
- package/plugins/litclaude/skills/programming/references/rust/proptest-insta.md +429 -0
- package/plugins/litclaude/skills/programming/references/rust/type-state.md +354 -0
- package/plugins/litclaude/skills/programming/references/rust/unsafe-discipline.md +250 -0
- package/plugins/litclaude/skills/programming/references/rust/zero-cost-safety.md +527 -0
- package/plugins/litclaude/skills/programming/references/rust-ub/README.md +289 -0
- package/plugins/litclaude/skills/programming/references/rust-ub/miri-sanitizers-loom.md +411 -0
- package/plugins/litclaude/skills/programming/references/rust-ub/ub-taxonomy.md +269 -0
- package/plugins/litclaude/skills/programming/references/typescript/README.md +195 -0
- package/plugins/litclaude/skills/programming/references/typescript/backend-hono.md +672 -0
- package/plugins/litclaude/skills/programming/references/typescript/bootstrap.md +199 -0
- package/plugins/litclaude/skills/programming/references/typescript/data-modeling.md +202 -0
- package/plugins/litclaude/skills/programming/references/typescript/error-handling.md +169 -0
- package/plugins/litclaude/skills/programming/references/typescript/tsconfig-strict.md +152 -0
- package/plugins/litclaude/skills/programming/references/typescript/type-patterns.md +196 -0
- package/plugins/litclaude/skills/programming/scripts/go/check-no-excuse-rules.sh +173 -0
- package/plugins/litclaude/skills/programming/scripts/go/new-project.py +138 -0
- package/plugins/litclaude/skills/programming/scripts/go/templates/.editorconfig +13 -0
- package/plugins/litclaude/skills/programming/scripts/go/templates/.golangci.yml +95 -0
- package/plugins/litclaude/skills/programming/scripts/go/templates/AGENTS.md.tmpl +24 -0
- package/plugins/litclaude/skills/programming/scripts/go/templates/README.md.tmpl +12 -0
- package/plugins/litclaude/skills/programming/scripts/go/templates/Taskfile.yml +40 -0
- package/plugins/litclaude/skills/programming/scripts/go/templates/ci.yml +37 -0
- package/plugins/litclaude/skills/programming/scripts/go/templates/config.go +24 -0
- package/plugins/litclaude/skills/programming/scripts/go/templates/gitignore +15 -0
- package/plugins/litclaude/skills/programming/scripts/go/templates/main.go.tmpl +22 -0
- package/plugins/litclaude/skills/programming/scripts/go/templates/run.go +15 -0
- package/plugins/litclaude/skills/programming/scripts/python/check-no-excuse-rules.py +687 -0
- package/plugins/litclaude/skills/programming/scripts/python/new-project.py +172 -0
- package/plugins/litclaude/skills/programming/scripts/python/new-script.py +116 -0
- package/plugins/litclaude/skills/programming/scripts/rust/check-no-excuse-rules.py +296 -0
- package/plugins/litclaude/skills/programming/scripts/rust/check-no-excuse-rules.sh +158 -0
- package/plugins/litclaude/skills/programming/scripts/rust/new-project.py +175 -0
- package/plugins/litclaude/skills/programming/scripts/typescript/check-no-excuse-rules.ts +282 -0
- package/plugins/litclaude/skills/programming/scripts/typescript/new-project.ts +177 -0
- package/plugins/litclaude/skills/refactor/SKILL.md +73 -0
- package/plugins/litclaude/skills/remove-ai-slops/SKILL.md +52 -0
- package/plugins/litclaude/skills/review-work/SKILL.md +331 -0
- package/plugins/litclaude/skills/rules/SKILL.md +66 -0
- package/plugins/litclaude/skills/start-work/SKILL.md +132 -0
- package/scripts/audit-plan-checkboxes.mjs +37 -0
- package/scripts/doctor.mjs +41 -0
- package/scripts/inspect-agent-tools.mjs +27 -0
- package/scripts/postinstall.mjs +50 -0
- package/scripts/qa-claude-plugin-smoke.sh +60 -0
- package/scripts/qa-portable-install.sh +136 -0
- package/scripts/validate-plugin.mjs +72 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
# Concurrency
|
|
2
|
+
|
|
3
|
+
Goroutines, context, errgroup, channels, locks, and the discipline that keeps them from leaking. Go makes concurrency *easy to start* and *easy to get wrong*. This document is the boring rule set.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## The four non-negotiables
|
|
8
|
+
|
|
9
|
+
1. **`ctx context.Context` is the first parameter of every public function that does I/O or can be cancelled.**
|
|
10
|
+
2. **No goroutine without a shutdown path.** Every `go` keyword must answer "how does this stop?".
|
|
11
|
+
3. **`-race` on every test run.** The `Taskfile.yml` and CI both enforce it.
|
|
12
|
+
4. **`goleak` in `TestMain`** for every package that spawns goroutines. Catches leaks the race detector cannot.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## `context.Context` — the cancellation backbone
|
|
17
|
+
|
|
18
|
+
```go
|
|
19
|
+
// GOOD — ctx as first param, propagated through
|
|
20
|
+
func (s *UserService) Create(ctx context.Context, email Email) (User, error) {
|
|
21
|
+
user, err := s.store.Insert(ctx, email)
|
|
22
|
+
if err != nil {
|
|
23
|
+
return User{}, fmt.Errorf("insert: %w", err)
|
|
24
|
+
}
|
|
25
|
+
if err := s.notifier.Welcome(ctx, user); err != nil {
|
|
26
|
+
return User{}, fmt.Errorf("notify: %w", err)
|
|
27
|
+
}
|
|
28
|
+
return user, nil
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// BAD — creates a fresh ctx, breaks request cancellation
|
|
32
|
+
func (s *UserService) Create(email Email) (User, error) {
|
|
33
|
+
ctx := context.Background() // ← contextcheck linter rejects this
|
|
34
|
+
// ...
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The `contextcheck` linter (enabled in `golangci-strict.md`) refuses any function that has `ctx context.Context` available but uses `context.Background()` instead.
|
|
39
|
+
|
|
40
|
+
### `context.Value` — use sparingly
|
|
41
|
+
|
|
42
|
+
```go
|
|
43
|
+
// Typed key — never use a bare string
|
|
44
|
+
type ctxKey struct{ name string }
|
|
45
|
+
var requestIDKey = ctxKey{"request_id"}
|
|
46
|
+
|
|
47
|
+
func WithRequestID(ctx context.Context, id string) context.Context {
|
|
48
|
+
return context.WithValue(ctx, requestIDKey, id)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
func RequestID(ctx context.Context) string {
|
|
52
|
+
v, _ := ctx.Value(requestIDKey).(string)
|
|
53
|
+
return v
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**Rules**:
|
|
58
|
+
- Keys are unexported struct types, not strings. Prevents collisions across packages.
|
|
59
|
+
- `context.Value` is for *request-scoped metadata* (request ID, auth subject, trace span), NEVER for application-scoped dependencies.
|
|
60
|
+
- Dependencies (loggers, DB pools, config) go in your service struct, not in `context.Value`.
|
|
61
|
+
|
|
62
|
+
### `WithTimeout` / `WithCancel` — always pair with `defer cancel()`
|
|
63
|
+
|
|
64
|
+
```go
|
|
65
|
+
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
66
|
+
defer cancel() // ← MUST be deferred. fatcontext linter catches misses.
|
|
67
|
+
|
|
68
|
+
if err := slow(ctx); err != nil { ... }
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Forgetting `defer cancel()` leaks a context goroutine until the parent expires — the `lostcancel` vet check catches it.
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## `errgroup` — the structured concurrency primitive
|
|
76
|
+
|
|
77
|
+
`golang.org/x/sync/errgroup` is Go's answer to Python's `asyncio.TaskGroup` or Rust's `JoinSet`. Use it instead of raw `go` for any group of related goroutines.
|
|
78
|
+
|
|
79
|
+
```go
|
|
80
|
+
import "golang.org/x/sync/errgroup"
|
|
81
|
+
|
|
82
|
+
func FetchAll(ctx context.Context, urls []string) ([][]byte, error) {
|
|
83
|
+
g, ctx := errgroup.WithContext(ctx)
|
|
84
|
+
g.SetLimit(8) // concurrency cap — leave unbounded = production outage
|
|
85
|
+
|
|
86
|
+
results := make([][]byte, len(urls))
|
|
87
|
+
for i, u := range urls {
|
|
88
|
+
g.Go(func() error {
|
|
89
|
+
body, err := fetch(ctx, u)
|
|
90
|
+
if err != nil {
|
|
91
|
+
return fmt.Errorf("fetch %s: %w", u, err)
|
|
92
|
+
}
|
|
93
|
+
results[i] = body
|
|
94
|
+
return nil
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
if err := g.Wait(); err != nil {
|
|
98
|
+
return nil, err
|
|
99
|
+
}
|
|
100
|
+
return results, nil
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Properties:
|
|
105
|
+
|
|
106
|
+
- `WithContext(parent)` returns a child ctx that gets cancelled on **first non-nil error**. All in-flight goroutines see `ctx.Done()` and bail.
|
|
107
|
+
- `SetLimit(n)` blocks `g.Go(...)` when the in-flight count hits `n`. **Always set this.** Unbounded fan-out is how services die.
|
|
108
|
+
- `g.Wait()` returns the **first** non-nil error. Others are dropped. If you need all errors, accumulate them manually:
|
|
109
|
+
```go
|
|
110
|
+
var mu sync.Mutex
|
|
111
|
+
var errs []error
|
|
112
|
+
// inside g.Go:
|
|
113
|
+
// mu.Lock(); errs = append(errs, err); mu.Unlock()
|
|
114
|
+
// after Wait, errors.Join(errs...)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## Goroutine leaks — `goleak`
|
|
120
|
+
|
|
121
|
+
```go
|
|
122
|
+
package store_test
|
|
123
|
+
|
|
124
|
+
import (
|
|
125
|
+
"testing"
|
|
126
|
+
"go.uber.org/goleak"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
func TestMain(m *testing.M) {
|
|
130
|
+
goleak.VerifyTestMain(m)
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
This single line at the top of `*_test.go` runs goleak's check after every test in the package. If a test leaks a goroutine, the run fails — pointing at which goroutine.
|
|
135
|
+
|
|
136
|
+
**The bug it catches**: starting a goroutine in `setUp` and never joining it. Common in DB connection pools, background workers, ticker loops. The race detector does NOT catch this.
|
|
137
|
+
|
|
138
|
+
If you have a known long-lived goroutine (a singleton background worker, a metrics exporter), use `goleak.IgnoreTopFunction`:
|
|
139
|
+
|
|
140
|
+
```go
|
|
141
|
+
goleak.VerifyTestMain(m,
|
|
142
|
+
goleak.IgnoreTopFunction("github.com/prometheus/client_golang/prometheus.(*Registry).Push"),
|
|
143
|
+
)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## Channels — the rules that hold
|
|
149
|
+
|
|
150
|
+
### Direction
|
|
151
|
+
|
|
152
|
+
```go
|
|
153
|
+
// GOOD — direction in signatures
|
|
154
|
+
func produce(out chan<- Item)
|
|
155
|
+
func consume(in <-chan Item)
|
|
156
|
+
func pipeline(in <-chan Item, out chan<- Item)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Direction restricts misuse. A consumer cannot close the producer's channel.
|
|
160
|
+
|
|
161
|
+
### Closing
|
|
162
|
+
|
|
163
|
+
- **The sender closes.** Always. Never the receiver, never multiple senders.
|
|
164
|
+
- **Multiple senders → use a `sync.WaitGroup` + one closer.**
|
|
165
|
+
- **Closing a closed channel panics.** Closing a `nil` channel panics. Sending on a closed channel panics. Receiving from a closed channel returns zero value with `ok = false`.
|
|
166
|
+
|
|
167
|
+
```go
|
|
168
|
+
// Canonical fan-in: multiple producers, one closer
|
|
169
|
+
func fanIn(ctx context.Context, sources ...<-chan Item) <-chan Item {
|
|
170
|
+
out := make(chan Item)
|
|
171
|
+
var wg sync.WaitGroup
|
|
172
|
+
wg.Add(len(sources))
|
|
173
|
+
for _, src := range sources {
|
|
174
|
+
go func() {
|
|
175
|
+
defer wg.Done()
|
|
176
|
+
for item := range src {
|
|
177
|
+
select {
|
|
178
|
+
case out <- item:
|
|
179
|
+
case <-ctx.Done():
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}()
|
|
184
|
+
}
|
|
185
|
+
go func() { wg.Wait(); close(out) }()
|
|
186
|
+
return out
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### Selecting
|
|
191
|
+
|
|
192
|
+
```go
|
|
193
|
+
select {
|
|
194
|
+
case msg := <-incoming:
|
|
195
|
+
handle(msg)
|
|
196
|
+
case <-ctx.Done():
|
|
197
|
+
return ctx.Err()
|
|
198
|
+
case <-time.After(5 * time.Second):
|
|
199
|
+
return ErrTimeout
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
- `time.After` allocates a timer each call — fine for occasional selects, **NOT for hot loops**. Use `time.NewTimer` + `timer.Reset` for repeat selects.
|
|
204
|
+
- A `default:` case makes `select` non-blocking. Use deliberately, not by accident.
|
|
205
|
+
|
|
206
|
+
### Buffered vs unbuffered
|
|
207
|
+
|
|
208
|
+
- **Unbuffered** (`make(chan T)`) = synchronous handoff. Sender blocks until receiver is ready. Use for *coordination*.
|
|
209
|
+
- **Buffered** (`make(chan T, n)`) = asynchronous up to `n`. Use for *decoupling producer rate from consumer rate*.
|
|
210
|
+
|
|
211
|
+
A buffered channel of size 1 acts as a **non-blocking signal**:
|
|
212
|
+
|
|
213
|
+
```go
|
|
214
|
+
ready := make(chan struct{}, 1)
|
|
215
|
+
// Producer
|
|
216
|
+
select {
|
|
217
|
+
case ready <- struct{}{}: // signal once, non-blocking
|
|
218
|
+
default: // already signaled, skip
|
|
219
|
+
}
|
|
220
|
+
// Consumer
|
|
221
|
+
<-ready
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
## Locks — the pyramid
|
|
227
|
+
|
|
228
|
+
```
|
|
229
|
+
Highest level (preferred)
|
|
230
|
+
channels (message passing — "share memory by communicating")
|
|
231
|
+
errgroup / wait group
|
|
232
|
+
|
|
233
|
+
sync.RWMutex (many readers, occasional writer)
|
|
234
|
+
sync.Mutex (mutual exclusion)
|
|
235
|
+
|
|
236
|
+
atomic.Int64 / atomic.Pointer (single-word lock-free)
|
|
237
|
+
|
|
238
|
+
Lowest level (rare)
|
|
239
|
+
unsafe.Pointer + barriers (custom lock-free; needs -race AND review)
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### `sync.Mutex` — embed, don't expose
|
|
243
|
+
|
|
244
|
+
```go
|
|
245
|
+
type Cache struct {
|
|
246
|
+
mu sync.RWMutex
|
|
247
|
+
items map[string]Entry
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
func (c *Cache) Get(key string) (Entry, bool) {
|
|
251
|
+
c.mu.RLock()
|
|
252
|
+
defer c.mu.RUnlock()
|
|
253
|
+
e, ok := c.items[key]
|
|
254
|
+
return e, ok
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
func (c *Cache) Set(key string, e Entry) {
|
|
258
|
+
c.mu.Lock()
|
|
259
|
+
defer c.mu.Unlock()
|
|
260
|
+
c.items[key] = e
|
|
261
|
+
}
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
- `sync.Mutex` is **not** copyable. The `copylocks` vet check catches `var c2 = c1` where `c1` has a mutex.
|
|
265
|
+
- Always `defer mu.Unlock()` immediately after `Lock()`. Forgetting is the #1 deadlock cause.
|
|
266
|
+
- Never call user code (callbacks, listener notifications) while holding the lock. Drop the lock, snapshot the data, release, then call out.
|
|
267
|
+
|
|
268
|
+
### `sync.OnceValue` / `sync.OnceFunc` (Go 1.21+)
|
|
269
|
+
|
|
270
|
+
Replacement for `sync.Once` for typed lazy init:
|
|
271
|
+
|
|
272
|
+
```go
|
|
273
|
+
var loadConfig = sync.OnceValue(func() Config {
|
|
274
|
+
var cfg Config
|
|
275
|
+
if err := env.Parse(&cfg); err != nil { panic(err) }
|
|
276
|
+
return cfg
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
func handler() { cfg := loadConfig(); ... }
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
Type-safe, no `sync.Once` + global variable boilerplate.
|
|
283
|
+
|
|
284
|
+
### Atomics — the typed API only
|
|
285
|
+
|
|
286
|
+
```go
|
|
287
|
+
// Go 1.19+ — use the typed atomic.* family
|
|
288
|
+
var counter atomic.Int64
|
|
289
|
+
counter.Add(1)
|
|
290
|
+
n := counter.Load()
|
|
291
|
+
|
|
292
|
+
// NEVER — the old function-style is type-unsafe
|
|
293
|
+
atomic.AddInt64(&counter, 1) // ← rejected
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
---
|
|
297
|
+
|
|
298
|
+
## Time — inject a clock for testability
|
|
299
|
+
|
|
300
|
+
```go
|
|
301
|
+
type Clock interface {
|
|
302
|
+
Now() time.Time
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
type realClock struct{}
|
|
306
|
+
func (realClock) Now() time.Time { return time.Now() }
|
|
307
|
+
|
|
308
|
+
type Service struct {
|
|
309
|
+
clock Clock
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Tests
|
|
313
|
+
import "github.com/benbjohnson/clock"
|
|
314
|
+
fake := clock.NewMock()
|
|
315
|
+
fake.Set(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
|
|
316
|
+
svc := &Service{clock: fake}
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
**Never call `time.Now()` in domain or service code.** The `time` package becomes a hidden dependency — tests become flaky, retries become time-of-day-dependent, expirations cannot be tested.
|
|
320
|
+
|
|
321
|
+
`time.Sleep` in production code is a code smell. Use:
|
|
322
|
+
- `time.NewTicker` for periodic work (and a `<-ctx.Done()` exit).
|
|
323
|
+
- `time.NewTimer` for one-shot delays.
|
|
324
|
+
- `time.After` ONLY in select statements, ONLY in non-hot paths.
|
|
325
|
+
|
|
326
|
+
---
|
|
327
|
+
|
|
328
|
+
## Race detector — non-negotiable in CI
|
|
329
|
+
|
|
330
|
+
```bash
|
|
331
|
+
go test -race -shuffle=on -count=1 ./...
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
- `-race` instruments memory accesses; catches data races at runtime. ~10x slow-down — acceptable for tests, not production.
|
|
335
|
+
- `-shuffle=on` randomizes test order; catches hidden ordering dependencies.
|
|
336
|
+
- `-count=1` defeats the test cache. Without it, "passing" might mean "ran 3 weeks ago".
|
|
337
|
+
|
|
338
|
+
If a test ONLY fails under `-race`, the bug is real. Don't disable the test; fix the race.
|
|
339
|
+
|
|
340
|
+
---
|
|
341
|
+
|
|
342
|
+
## Common antipatterns
|
|
343
|
+
|
|
344
|
+
| Bad | Why | Good |
|
|
345
|
+
|---|---|---|
|
|
346
|
+
| `go func() { ... }()` with no `ctx` plumbing | Leaks on shutdown | `errgroup.WithContext` or pass ctx |
|
|
347
|
+
| Bare `time.Sleep(d)` in production | Untestable, blocks | `time.NewTimer` + select with `ctx.Done()` |
|
|
348
|
+
| Channel of `interface{}` | Loses type | Typed channel; use sealed interface if variants needed |
|
|
349
|
+
| `sync.Mutex` in a struct passed by value | Locked copies, undefined behavior | Embed in pointer-receiver type; copylocks catches it |
|
|
350
|
+
| Locking around an entire request handler | Serializes the whole API | Lock only the smallest critical section |
|
|
351
|
+
| `for { select { ... } }` without `<-ctx.Done()` | Cannot stop | Add ctx case in every long-lived select |
|
|
352
|
+
| `sync.WaitGroup.Add(1)` inside the goroutine | Race: Wait can return before Add | Add **before** `go` |
|
|
353
|
+
|
|
354
|
+
---
|
|
355
|
+
|
|
356
|
+
## Sources
|
|
357
|
+
|
|
358
|
+
- Go memory model: https://go.dev/ref/mem
|
|
359
|
+
- `errgroup` package: https://pkg.go.dev/golang.org/x/sync/errgroup
|
|
360
|
+
- `goleak`: https://github.com/uber-go/goleak
|
|
361
|
+
- "Go concurrency patterns" (Pike): https://go.dev/blog/pipelines
|
|
362
|
+
- Sync.OnceValue blog: https://go.dev/blog/synctest (1.24+ note: `testing/synctest` for time-controlled tests is now experimental)
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
# Data Modeling — Three Layers of Validation
|
|
2
|
+
|
|
3
|
+
Go has no Pydantic. Go has no Zod. **You do not need them**, but only if you wire three layers correctly. This document is the canonical pattern.
|
|
4
|
+
|
|
5
|
+
## The three layers
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
9
|
+
│ HTTP / RPC / CLI │
|
|
10
|
+
│ Raw bytes, strings, untrusted input │
|
|
11
|
+
│ │
|
|
12
|
+
│ Layer 1: validator/v10 (struct tags) ◄── parse-once │
|
|
13
|
+
│ OR protovalidate (proto) │
|
|
14
|
+
│ │
|
|
15
|
+
└──────────────────────────┬──────────────────────────────────┘
|
|
16
|
+
│ raw req → domain.X
|
|
17
|
+
▼
|
|
18
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
19
|
+
│ Domain (internal/domain) │
|
|
20
|
+
│ │
|
|
21
|
+
│ Layer 2: Smart constructors + unexported fields │
|
|
22
|
+
│ NewEmail(s) → (Email, error) │
|
|
23
|
+
│ NewUserID(s) → (UserID, error) │
|
|
24
|
+
│ │
|
|
25
|
+
│ Once inside this layer, NO further validation. │
|
|
26
|
+
│ The types prove correctness. │
|
|
27
|
+
└──────────────────────────┬──────────────────────────────────┘
|
|
28
|
+
│ domain.X (proven valid)
|
|
29
|
+
▼
|
|
30
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
31
|
+
│ Storage (internal/store) │
|
|
32
|
+
│ │
|
|
33
|
+
│ Layer 3: sqlc-generated row structs ↔ domain types │
|
|
34
|
+
│ Hand-written mappers, NOT struct tags │
|
|
35
|
+
└─────────────────────────────────────────────────────────────┘
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Each layer parses once, into the next layer's types. **A function in the domain layer should never receive a raw string and validate it.** If it does, the boundary above failed.
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Layer 1: HTTP boundary — `go-playground/validator/v10`
|
|
43
|
+
|
|
44
|
+
```go
|
|
45
|
+
package handlers
|
|
46
|
+
|
|
47
|
+
import (
|
|
48
|
+
"github.com/gin-gonic/gin"
|
|
49
|
+
"github.com/go-playground/validator/v10"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
// CreateUserRequest is the wire format. Tags drive validation.
|
|
53
|
+
type CreateUserRequest struct {
|
|
54
|
+
Email string `json:"email" binding:"required,email"`
|
|
55
|
+
Username string `json:"username" binding:"required,alphanum,min=3,max=32"`
|
|
56
|
+
Age int `json:"age" binding:"required,gte=13,lte=130"`
|
|
57
|
+
Country string `json:"country" binding:"required,iso3166_1_alpha2"`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
func (h *Handler) CreateUser(c *gin.Context) {
|
|
61
|
+
var req CreateUserRequest
|
|
62
|
+
if err := c.ShouldBindJSON(&req); err != nil {
|
|
63
|
+
// validator returns ValidationErrors with field-by-field detail
|
|
64
|
+
var vErr validator.ValidationErrors
|
|
65
|
+
if errors.As(err, &vErr) {
|
|
66
|
+
c.JSON(400, gin.H{"errors": fieldErrors(vErr)})
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
c.JSON(400, gin.H{"error": "invalid json"})
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Cross into domain — single point of failure
|
|
74
|
+
email, err := domain.NewEmail(req.Email)
|
|
75
|
+
if err != nil {
|
|
76
|
+
c.JSON(400, gin.H{"error": err.Error()})
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
username, err := domain.NewUsername(req.Username)
|
|
80
|
+
if err != nil {
|
|
81
|
+
c.JSON(400, gin.H{"error": err.Error()})
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
user, err := h.svc.Create(c.Request.Context(), email, username, req.Age)
|
|
86
|
+
if err != nil {
|
|
87
|
+
h.writeServiceError(c, err)
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
c.JSON(201, user)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
func fieldErrors(vErr validator.ValidationErrors) map[string]string {
|
|
94
|
+
out := make(map[string]string, len(vErr))
|
|
95
|
+
for _, fe := range vErr {
|
|
96
|
+
out[fe.Field()] = fe.Tag() + "(" + fe.Param() + ")"
|
|
97
|
+
}
|
|
98
|
+
return out
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
**Tag reference — the tags you actually use**:
|
|
103
|
+
|
|
104
|
+
| Tag | Meaning |
|
|
105
|
+
|---|---|
|
|
106
|
+
| `required` | Non-zero value |
|
|
107
|
+
| `omitempty` (json) | Skip if zero |
|
|
108
|
+
| `min=N` / `max=N` | Length (strings/slices) or value (numbers) |
|
|
109
|
+
| `gte=N` / `lte=N` / `gt=N` / `lt=N` | Numeric comparison |
|
|
110
|
+
| `email` | RFC 5322-ish email |
|
|
111
|
+
| `url` | Valid URL |
|
|
112
|
+
| `uuid` / `uuid4` / `uuid7` | UUID format |
|
|
113
|
+
| `alphanum` / `alpha` / `numeric` | Character class |
|
|
114
|
+
| `iso3166_1_alpha2` | Country code (US, KR, JP) |
|
|
115
|
+
| `iso4217` | Currency code (USD, KRW) |
|
|
116
|
+
| `oneof=a b c` | Enum of literal values |
|
|
117
|
+
| `dive` | Apply rules to each element of slice/map |
|
|
118
|
+
| `eqfield=Field` | Cross-field equality (e.g., password confirm) |
|
|
119
|
+
|
|
120
|
+
### Custom validators — register at startup
|
|
121
|
+
|
|
122
|
+
```go
|
|
123
|
+
func init() {
|
|
124
|
+
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
|
|
125
|
+
_ = v.RegisterValidation("strongpassword", validateStrongPassword)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
func validateStrongPassword(fl validator.FieldLevel) bool {
|
|
130
|
+
s := fl.Field().String()
|
|
131
|
+
return len(s) >= 12 && hasUpper(s) && hasDigit(s) && hasSymbol(s)
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Use sparingly. Most domain rules belong in smart constructors, not validators.
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## Layer 2: Domain — smart constructors
|
|
140
|
+
|
|
141
|
+
Covered in detail in `type-patterns.md`. Recap:
|
|
142
|
+
|
|
143
|
+
```go
|
|
144
|
+
package domain
|
|
145
|
+
|
|
146
|
+
type Username struct{ raw string }
|
|
147
|
+
|
|
148
|
+
func NewUsername(s string) (Username, error) {
|
|
149
|
+
s = strings.TrimSpace(s)
|
|
150
|
+
if len(s) < 3 || len(s) > 32 {
|
|
151
|
+
return Username{}, ErrInvalidUsername
|
|
152
|
+
}
|
|
153
|
+
if !isAlphanum(s) {
|
|
154
|
+
return Username{}, ErrInvalidUsername
|
|
155
|
+
}
|
|
156
|
+
return Username{raw: s}, nil
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
func (u Username) String() string { return u.raw }
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
**Rule**: every domain type that has invariants has:
|
|
163
|
+
|
|
164
|
+
1. An unexported field holding the raw form.
|
|
165
|
+
2. A `New<Type>(raw) (<Type>, error)` constructor as the sole entry point.
|
|
166
|
+
3. A `String() string` for printing.
|
|
167
|
+
4. `MarshalJSON` / `UnmarshalJSON` if it crosses a JSON boundary outside HTTP handlers (e.g., logging payloads, queue messages).
|
|
168
|
+
5. Optionally: `Scan` and `Value` for `database/sql` interop (rare with sqlc).
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## Layer 3: Storage — sqlc rows ↔ domain types
|
|
173
|
+
|
|
174
|
+
sqlc generates row structs from `.sql` files. **Do not put validation tags on them.** Map between sqlc rows and domain types explicitly:
|
|
175
|
+
|
|
176
|
+
```go
|
|
177
|
+
// internal/store/user_store.go
|
|
178
|
+
package store
|
|
179
|
+
|
|
180
|
+
import "myservice/internal/domain"
|
|
181
|
+
|
|
182
|
+
func (s *UserStore) Get(ctx context.Context, id domain.UserID) (domain.User, error) {
|
|
183
|
+
row, err := s.q.GetUser(ctx, string(id))
|
|
184
|
+
if err != nil {
|
|
185
|
+
return domain.User{}, err
|
|
186
|
+
}
|
|
187
|
+
return rowToUser(row)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
func rowToUser(r sqlc.UserRow) (domain.User, error) {
|
|
191
|
+
email, err := domain.NewEmail(r.Email)
|
|
192
|
+
if err != nil {
|
|
193
|
+
// DB invariant broken — this is a programmer error, not a user error
|
|
194
|
+
return domain.User{}, fmt.Errorf("db invariant: invalid email for user %s: %w", r.ID, err)
|
|
195
|
+
}
|
|
196
|
+
username, err := domain.NewUsername(r.Username)
|
|
197
|
+
if err != nil {
|
|
198
|
+
return domain.User{}, fmt.Errorf("db invariant: invalid username: %w", err)
|
|
199
|
+
}
|
|
200
|
+
return domain.User{
|
|
201
|
+
ID: domain.UserID(r.ID),
|
|
202
|
+
Email: email,
|
|
203
|
+
Username: username,
|
|
204
|
+
Created: r.CreatedAt,
|
|
205
|
+
}, nil
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
The mapping is verbose. **That is the point.** Each field is a deliberate choice; refactors flag every site.
|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
## Discriminated unions (sum types) at the boundary
|
|
214
|
+
|
|
215
|
+
When a wire payload has variants (e.g., `{"type": "user.created", ...}` vs `{"type": "user.deleted", ...}`):
|
|
216
|
+
|
|
217
|
+
```go
|
|
218
|
+
// Wire DTO with raw discriminator
|
|
219
|
+
type EventDTO struct {
|
|
220
|
+
Type string `json:"type" binding:"required,oneof=created deleted updated"`
|
|
221
|
+
Payload json.RawMessage `json:"payload" binding:"required"`
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Parse into the sealed domain type
|
|
225
|
+
func ParseEvent(dto EventDTO) (event.Event, error) {
|
|
226
|
+
switch dto.Type {
|
|
227
|
+
case "created":
|
|
228
|
+
var c event.Created
|
|
229
|
+
if err := json.Unmarshal(dto.Payload, &c); err != nil {
|
|
230
|
+
return nil, fmt.Errorf("decode created: %w", err)
|
|
231
|
+
}
|
|
232
|
+
return c, nil
|
|
233
|
+
case "deleted":
|
|
234
|
+
var d event.Deleted
|
|
235
|
+
if err := json.Unmarshal(dto.Payload, &d); err != nil {
|
|
236
|
+
return nil, fmt.Errorf("decode deleted: %w", err)
|
|
237
|
+
}
|
|
238
|
+
return d, nil
|
|
239
|
+
case "updated":
|
|
240
|
+
var u event.Updated
|
|
241
|
+
if err := json.Unmarshal(dto.Payload, &u); err != nil {
|
|
242
|
+
return nil, fmt.Errorf("decode updated: %w", err)
|
|
243
|
+
}
|
|
244
|
+
return u, nil
|
|
245
|
+
default:
|
|
246
|
+
return nil, fmt.Errorf("unknown event type %q", dto.Type)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
The `exhaustive` linter on the switch + the `oneof` validation tag together cover both "unknown type" and "unhandled variant".
|
|
252
|
+
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
## Enums — typed string consts, not iota
|
|
256
|
+
|
|
257
|
+
```go
|
|
258
|
+
// GOOD — string-based, JSON-serializes correctly, debuggable
|
|
259
|
+
type Status string
|
|
260
|
+
|
|
261
|
+
const (
|
|
262
|
+
StatusPending Status = "pending"
|
|
263
|
+
StatusActive Status = "active"
|
|
264
|
+
StatusClosed Status = "closed"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
func (s Status) IsValid() bool {
|
|
268
|
+
switch s {
|
|
269
|
+
case StatusPending, StatusActive, StatusClosed:
|
|
270
|
+
return true
|
|
271
|
+
}
|
|
272
|
+
return false
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
func (s *Status) UnmarshalJSON(data []byte) error {
|
|
276
|
+
var raw string
|
|
277
|
+
if err := json.Unmarshal(data, &raw); err != nil { return err }
|
|
278
|
+
parsed := Status(raw)
|
|
279
|
+
if !parsed.IsValid() { return fmt.Errorf("invalid status %q", raw) }
|
|
280
|
+
*s = parsed
|
|
281
|
+
return nil
|
|
282
|
+
}
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
**Never use `iota` enums for anything that crosses a wire boundary.** They serialize as integers, which (a) breaks debuggability, (b) makes reordering enum values a silent breaking change.
|
|
286
|
+
|
|
287
|
+
Use the validator tag `binding:"oneof=pending active closed"` to enforce at the HTTP boundary.
|
|
288
|
+
|
|
289
|
+
---
|
|
290
|
+
|
|
291
|
+
## Nullable fields — `*T` vs sentinel
|
|
292
|
+
|
|
293
|
+
Three choices, in order of preference:
|
|
294
|
+
|
|
295
|
+
1. **Sentinel zero value**: `Age int` with `0` meaning "unknown". Works when zero is genuinely unreachable as a valid value.
|
|
296
|
+
2. **`sql.Null<T>`** for DB columns: `sql.NullString`, `sql.NullInt64`, `sql.NullTime`. sqlc generates these for nullable columns.
|
|
297
|
+
3. **`*T`**: only when you need to distinguish "not provided" from "set to zero" in a JSON payload (PATCH semantics).
|
|
298
|
+
|
|
299
|
+
```go
|
|
300
|
+
// PATCH payload — `*string` discriminates absent vs empty
|
|
301
|
+
type UpdateUserRequest struct {
|
|
302
|
+
Email *string `json:"email,omitempty"`
|
|
303
|
+
Username *string `json:"username,omitempty"`
|
|
304
|
+
}
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
Avoid `*T` in domain types — it bloats every consumer with nil checks. Keep `*T` at the boundary, unwrap on the way in.
|
|
308
|
+
|
|
309
|
+
---
|
|
310
|
+
|
|
311
|
+
## Common AI-generated antipatterns this rejects
|
|
312
|
+
|
|
313
|
+
| Bad | Why | Good |
|
|
314
|
+
|---|---|---|
|
|
315
|
+
| `func handle(req map[string]any)` | No types, no validation | Define a struct, parse with `validator` |
|
|
316
|
+
| `if email != "" { ... }` inside domain | Validation in the wrong layer | Make `email Email`, no check needed |
|
|
317
|
+
| `type Status int` with `iota` for wire field | Silent breaking on reorder | `type Status string` with const literals |
|
|
318
|
+
| Struct tags `json:"email,string"` (the `,string` coercion) | Magic coercion hides bad input | Strict parsing, fail-fast |
|
|
319
|
+
| `json.Unmarshal` then range-check after | Two-step "validate after parse" | Use `validator` tags or custom `UnmarshalJSON` |
|
|
320
|
+
| Reusing handler DTO as the domain type | Couples wire format to business logic | Two distinct types, explicit mapping |
|
|
321
|
+
|
|
322
|
+
---
|
|
323
|
+
|
|
324
|
+
## Sources
|
|
325
|
+
|
|
326
|
+
- go-playground/validator: https://github.com/go-playground/validator
|
|
327
|
+
- gin binding internals: https://github.com/gin-gonic/gin/blob/master/binding/json.go
|
|
328
|
+
- Parse, don't validate: https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/
|
|
329
|
+
- sqlc with custom types: https://docs.sqlc.dev/en/latest/howto/overrides.html
|