ga-plugins-cli 0.1.0 → 0.1.1
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/dist/config-patcher.d.ts +20 -50
- package/dist/config-patcher.d.ts.map +1 -1
- package/dist/config-patcher.js +138 -102
- package/dist/config-patcher.js.map +1 -1
- package/dist/index.js +41 -5
- package/dist/index.js.map +1 -1
- package/dist/installer.d.ts +0 -18
- package/dist/installer.d.ts.map +1 -1
- package/dist/installer.js +19 -39
- package/dist/installer.js.map +1 -1
- package/dist/types.d.ts +10 -6
- package/dist/types.d.ts.map +1 -1
- package/dist/uninstaller.d.ts +0 -23
- package/dist/uninstaller.d.ts.map +1 -1
- package/dist/uninstaller.js +22 -68
- package/dist/uninstaller.js.map +1 -1
- package/package.json +3 -2
- package/plugins/go-reviewer/.claude-plugin/plugin.json +12 -0
- package/plugins/go-reviewer/commands/go-review.md +424 -0
- package/plugins/go-reviewer/mcp-servers/go-reviewer-mcp/README.md +236 -0
- package/plugins/go-reviewer/mcp-servers/go-reviewer-mcp/main.go +678 -0
- package/plugins/go-scaffolder/.claude-plugin/plugin.json +12 -0
- package/plugins/go-scaffolder/commands/scaffold-service.md +802 -0
- package/plugins/go-scaffolder/reference-service/.env.example +27 -0
- package/plugins/go-scaffolder/reference-service/Dockerfile +55 -0
- package/plugins/go-scaffolder/reference-service/REFERENCE-SERVICE-NOTICE.md +104 -0
- package/plugins/go-scaffolder/reference-service/cmd/server/main.go +266 -0
- package/plugins/go-scaffolder/reference-service/config/config.go +67 -0
- package/plugins/go-scaffolder/reference-service/go.mod +17 -0
- package/plugins/go-scaffolder/reference-service/internal/domain/booking.go +118 -0
- package/plugins/go-scaffolder/reference-service/internal/handler/booking.go +242 -0
- package/plugins/go-scaffolder/reference-service/internal/handler/booking_test.go +451 -0
- package/plugins/go-scaffolder/reference-service/internal/repository/booking_postgres.go +124 -0
- package/plugins/go-scaffolder/reference-service/internal/usecase/booking.go +181 -0
- package/plugins/go-standards/.claude-plugin/plugin.json +22 -0
- package/plugins/go-standards/commands/go-standards-check.md +232 -0
- package/plugins/go-standards/skills/concurrency.md +336 -0
- package/plugins/go-standards/skills/config.md +267 -0
- package/plugins/go-standards/skills/error-handling.md +286 -0
- package/plugins/go-standards/skills/http-chi.md +390 -0
- package/plugins/go-standards/skills/logging-observability.md +340 -0
- package/plugins/go-standards/skills/naming-and-style.md +315 -0
- package/plugins/go-standards/skills/project-layout.md +313 -0
- package/plugins/go-standards/skills/testing.md +366 -0
- package/plugins/java2go-porter/.claude-plugin/plugin.json +21 -0
- package/plugins/java2go-porter/agents/analyzer.md +232 -0
- package/plugins/java2go-porter/agents/reviewer.md +241 -0
- package/plugins/java2go-porter/agents/test-pairer.md +365 -0
- package/plugins/java2go-porter/agents/translator.md +419 -0
- package/plugins/java2go-porter/commands/port-java-service.md +149 -0
- package/plugins/java2go-porter/skills/idiom-mapping.md +75 -0
- package/plugins/migration-safety/.claude-plugin/plugin.json +20 -0
- package/plugins/migration-safety/commands/gen-characterization-test.md +452 -0
- package/plugins/migration-safety/commands/strangler-plan.md +356 -0
- package/plugins/migration-safety/skills/strangler-fig.md +382 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
# Concurrency Standard
|
|
2
|
+
|
|
3
|
+
Go's concurrency primitives are powerful and easy to misuse. These rules
|
|
4
|
+
prevent goroutine leaks, data races, and deadlocks — the three most common
|
|
5
|
+
concurrency bugs in migrated Java services.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Rule 1 — Always pass `context.Context` as the first argument
|
|
10
|
+
|
|
11
|
+
Every function that does I/O, blocks, or can be cancelled must accept
|
|
12
|
+
`context.Context` as its first parameter. Never store a context in a struct.
|
|
13
|
+
|
|
14
|
+
### DO
|
|
15
|
+
|
|
16
|
+
```go
|
|
17
|
+
// Every layer propagates the context
|
|
18
|
+
func (uc *UserUsecase) Create(ctx context.Context, input CreateUserInput) (*User, error) {
|
|
19
|
+
return uc.repo.Create(ctx, &User{Email: input.Email})
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
func (r *userRepository) Create(ctx context.Context, u *User) (*User, error) {
|
|
23
|
+
_, err := r.db.ExecContext(ctx, insertUser, u.Email, u.Name)
|
|
24
|
+
if err != nil {
|
|
25
|
+
return nil, fmt.Errorf("insert user: %w", err)
|
|
26
|
+
}
|
|
27
|
+
return u, nil
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### DO NOT
|
|
32
|
+
|
|
33
|
+
```go
|
|
34
|
+
// WRONG: context stored in struct
|
|
35
|
+
type UserUsecase struct {
|
|
36
|
+
ctx context.Context // WRONG: contexts belong to calls, not structs
|
|
37
|
+
repo UserRepository
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// WRONG: context.Background() created inside a function — ignores cancellation
|
|
41
|
+
func (r *userRepository) Create(u *User) (*User, error) {
|
|
42
|
+
// WRONG: creates a new root context, ignoring the caller's deadline/cancellation
|
|
43
|
+
_, err := r.db.ExecContext(context.Background(), insertUser, u.Email)
|
|
44
|
+
return u, err
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// WRONG: context not threaded through — downstream cannot be cancelled
|
|
48
|
+
func (uc *UserUsecase) BulkCreate(users []User) error {
|
|
49
|
+
for _, u := range users {
|
|
50
|
+
uc.repo.Create(&u) // WRONG: no context
|
|
51
|
+
}
|
|
52
|
+
return nil
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## Rule 2 — Cancel contexts; always `defer cancel()`
|
|
59
|
+
|
|
60
|
+
Every call to `context.WithCancel`, `context.WithTimeout`, or
|
|
61
|
+
`context.WithDeadline` returns a cancel function. Always call it, always
|
|
62
|
+
with defer, to release resources.
|
|
63
|
+
|
|
64
|
+
### DO
|
|
65
|
+
|
|
66
|
+
```go
|
|
67
|
+
func (uc *UserUsecase) FetchExternal(ctx context.Context, id string) (*User, error) {
|
|
68
|
+
// Enforce a maximum wait time for the external call
|
|
69
|
+
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
70
|
+
defer cancel() // releases timer resources whether we return early or not
|
|
71
|
+
|
|
72
|
+
return uc.externalClient.Get(ctx, id)
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
```go
|
|
77
|
+
// In main — cancel on shutdown
|
|
78
|
+
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
79
|
+
defer stop() // always called, even if we panic-recover
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### DO NOT
|
|
83
|
+
|
|
84
|
+
```go
|
|
85
|
+
// WRONG: cancel never called — goroutine/timer leak
|
|
86
|
+
func (uc *UserUsecase) FetchExternal(ctx context.Context, id string) (*User, error) {
|
|
87
|
+
ctx, _ = context.WithTimeout(ctx, 5*time.Second) // WRONG: _ discards cancel
|
|
88
|
+
return uc.externalClient.Get(ctx, id)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// WRONG: cancel called only in the happy path
|
|
92
|
+
func process(ctx context.Context) error {
|
|
93
|
+
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
94
|
+
result, err := doWork(ctx)
|
|
95
|
+
if err != nil {
|
|
96
|
+
return err // WRONG: cancel not called on error path
|
|
97
|
+
}
|
|
98
|
+
cancel()
|
|
99
|
+
return use(result)
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## Rule 3 — Goroutines must always have a termination path
|
|
106
|
+
|
|
107
|
+
Every goroutine you launch must have a clear way to stop: either a
|
|
108
|
+
`context.Context` that will be cancelled, a done channel, or a fixed
|
|
109
|
+
lifetime (e.g., handles one request then exits). Goroutines that run
|
|
110
|
+
forever without a stop signal are leaks.
|
|
111
|
+
|
|
112
|
+
### DO
|
|
113
|
+
|
|
114
|
+
```go
|
|
115
|
+
// Background worker with context-based cancellation
|
|
116
|
+
func (w *Worker) Start(ctx context.Context) {
|
|
117
|
+
go func() {
|
|
118
|
+
ticker := time.NewTicker(30 * time.Second)
|
|
119
|
+
defer ticker.Stop()
|
|
120
|
+
|
|
121
|
+
for {
|
|
122
|
+
select {
|
|
123
|
+
case <-ctx.Done():
|
|
124
|
+
return // clean exit when context is cancelled
|
|
125
|
+
case <-ticker.C:
|
|
126
|
+
if err := w.runJob(ctx); err != nil {
|
|
127
|
+
w.logger.Error("job failed", zap.Error(err))
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}()
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
```go
|
|
136
|
+
// Fire-and-forget with timeout — not truly fire-and-forget, has bounded lifetime
|
|
137
|
+
func sendNotificationAsync(ctx context.Context, userID string, client NotificationClient, logger *zap.Logger) {
|
|
138
|
+
go func() {
|
|
139
|
+
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
140
|
+
defer cancel()
|
|
141
|
+
|
|
142
|
+
if err := client.Notify(ctx, userID); err != nil {
|
|
143
|
+
logger.Error("async notification failed",
|
|
144
|
+
zap.Error(err),
|
|
145
|
+
zap.String("user_id", userID),
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
}()
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### DO NOT
|
|
153
|
+
|
|
154
|
+
```go
|
|
155
|
+
// WRONG: goroutine with no termination path
|
|
156
|
+
go func() {
|
|
157
|
+
for {
|
|
158
|
+
processQueue() // WRONG: runs forever, cannot be stopped for graceful shutdown
|
|
159
|
+
time.Sleep(time.Second)
|
|
160
|
+
}
|
|
161
|
+
}()
|
|
162
|
+
|
|
163
|
+
// WRONG: goroutine inherits a context that will be cancelled before it finishes
|
|
164
|
+
func (h *UserHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|
165
|
+
user, _ := h.uc.Create(r.Context(), input)
|
|
166
|
+
go func() {
|
|
167
|
+
// WRONG: r.Context() is cancelled when the HTTP response is sent
|
|
168
|
+
// This goroutine will see context cancellation immediately after the handler returns
|
|
169
|
+
h.auditLogger.Log(r.Context(), user)
|
|
170
|
+
}()
|
|
171
|
+
}
|
|
172
|
+
// Fix: pass context.WithoutCancel(r.Context()) or a new background context
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## Rule 4 — `sync.WaitGroup` for fan-out patterns
|
|
178
|
+
|
|
179
|
+
When launching multiple goroutines that must all complete before proceeding,
|
|
180
|
+
use `sync.WaitGroup`. Always call `wg.Add(n)` before launching goroutines,
|
|
181
|
+
never inside them.
|
|
182
|
+
|
|
183
|
+
### DO
|
|
184
|
+
|
|
185
|
+
```go
|
|
186
|
+
func (uc *UserUsecase) BulkEnrich(ctx context.Context, users []*User) error {
|
|
187
|
+
var wg sync.WaitGroup
|
|
188
|
+
errs := make(chan error, len(users)) // buffered so sends never block
|
|
189
|
+
|
|
190
|
+
for _, u := range users {
|
|
191
|
+
wg.Add(1)
|
|
192
|
+
go func(user *User) {
|
|
193
|
+
defer wg.Done()
|
|
194
|
+
if err := uc.enrich(ctx, user); err != nil {
|
|
195
|
+
errs <- fmt.Errorf("enrich user %s: %w", user.ID, err)
|
|
196
|
+
}
|
|
197
|
+
}(u)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
wg.Wait()
|
|
201
|
+
close(errs)
|
|
202
|
+
|
|
203
|
+
// Collect first error (or use errgroup for cleaner multi-error handling)
|
|
204
|
+
for err := range errs {
|
|
205
|
+
if err != nil {
|
|
206
|
+
return err
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return nil
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### DO NOT
|
|
214
|
+
|
|
215
|
+
```go
|
|
216
|
+
// WRONG: wg.Add inside goroutine — race condition
|
|
217
|
+
for _, u := range users {
|
|
218
|
+
go func(user *User) {
|
|
219
|
+
wg.Add(1) // WRONG: may race with wg.Wait()
|
|
220
|
+
defer wg.Done()
|
|
221
|
+
enrich(ctx, user)
|
|
222
|
+
}(u)
|
|
223
|
+
}
|
|
224
|
+
wg.Wait()
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
---
|
|
228
|
+
|
|
229
|
+
## Rule 5 — `errgroup` for concurrent tasks with error collection
|
|
230
|
+
|
|
231
|
+
Prefer `golang.org/x/sync/errgroup` over manual WaitGroup + error channel
|
|
232
|
+
for fan-out where you care about errors.
|
|
233
|
+
|
|
234
|
+
```go
|
|
235
|
+
import "golang.org/x/sync/errgroup"
|
|
236
|
+
|
|
237
|
+
func (uc *UserUsecase) CreateWithAudit(ctx context.Context, input CreateUserInput) (*User, error) {
|
|
238
|
+
g, ctx := errgroup.WithContext(ctx)
|
|
239
|
+
|
|
240
|
+
var user *User
|
|
241
|
+
g.Go(func() error {
|
|
242
|
+
var err error
|
|
243
|
+
user, err = uc.repo.Create(ctx, &User{Email: input.Email})
|
|
244
|
+
return err
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
g.Go(func() error {
|
|
248
|
+
return uc.auditLog.Record(ctx, "user.create", input.Email)
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
if err := g.Wait(); err != nil {
|
|
252
|
+
return nil, fmt.Errorf("create with audit: %w", err)
|
|
253
|
+
}
|
|
254
|
+
return user, nil
|
|
255
|
+
}
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
260
|
+
## Rule 6 — Protecting shared state
|
|
261
|
+
|
|
262
|
+
Prefer communicating over sharing. When shared state is necessary, protect
|
|
263
|
+
it with `sync.Mutex`. Use `sync.RWMutex` when reads are far more frequent
|
|
264
|
+
than writes.
|
|
265
|
+
|
|
266
|
+
```go
|
|
267
|
+
// Cache with RWMutex — many concurrent readers, occasional writer
|
|
268
|
+
type UserCache struct {
|
|
269
|
+
mu sync.RWMutex
|
|
270
|
+
store map[string]*domain.User
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
func (c *UserCache) Get(id string) (*domain.User, bool) {
|
|
274
|
+
c.mu.RLock()
|
|
275
|
+
defer c.mu.RUnlock()
|
|
276
|
+
u, ok := c.store[id]
|
|
277
|
+
return u, ok
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
func (c *UserCache) Set(id string, u *domain.User) {
|
|
281
|
+
c.mu.Lock()
|
|
282
|
+
defer c.mu.Unlock()
|
|
283
|
+
c.store[id] = u
|
|
284
|
+
}
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
### DO NOT
|
|
288
|
+
|
|
289
|
+
```go
|
|
290
|
+
// WRONG: unprotected map access from multiple goroutines — data race
|
|
291
|
+
type BadCache struct {
|
|
292
|
+
store map[string]*User // WRONG: no mutex
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
func (c *BadCache) Get(id string) *User { return c.store[id] } // WRONG
|
|
296
|
+
func (c *BadCache) Set(id string, u *User) { c.store[id] = u } // WRONG
|
|
297
|
+
|
|
298
|
+
// WRONG: holding lock across I/O
|
|
299
|
+
func (c *UserCache) Fetch(ctx context.Context, id string) (*User, error) {
|
|
300
|
+
c.mu.Lock()
|
|
301
|
+
defer c.mu.Unlock()
|
|
302
|
+
// WRONG: lock held while doing a network call — blocks all other readers
|
|
303
|
+
return c.client.Get(ctx, id)
|
|
304
|
+
}
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
---
|
|
308
|
+
|
|
309
|
+
## Rule 7 — Never pass a loop variable to a goroutine by closure in Go < 1.22
|
|
310
|
+
|
|
311
|
+
In Go 1.22+ the loop variable is per-iteration and this is safe. For Go
|
|
312
|
+
1.21 and earlier, always capture loop variables explicitly.
|
|
313
|
+
|
|
314
|
+
```go
|
|
315
|
+
// Safe in Go 1.22+ (this project uses Go 1.23 — but be explicit for clarity)
|
|
316
|
+
for _, u := range users {
|
|
317
|
+
u := u // explicit capture — safe in all versions, documents intent
|
|
318
|
+
go func() {
|
|
319
|
+
process(u)
|
|
320
|
+
}()
|
|
321
|
+
}
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
---
|
|
325
|
+
|
|
326
|
+
## Quick Reference: Java → Go Concurrency Mapping
|
|
327
|
+
|
|
328
|
+
| Java | Go |
|
|
329
|
+
|------|----|
|
|
330
|
+
| `ExecutorService` / `ThreadPoolExecutor` | `errgroup` or goroutines + WaitGroup |
|
|
331
|
+
| `CompletableFuture` | goroutine + channel or `errgroup` |
|
|
332
|
+
| `synchronized` method | `sync.Mutex` wrapping the method body |
|
|
333
|
+
| `ConcurrentHashMap` | `sync.Map` or `map` + `sync.RWMutex` |
|
|
334
|
+
| `Thread.sleep()` in tests | channel receive with timeout or `time.After` |
|
|
335
|
+
| `InterruptedException` | `ctx.Done()` / `context.Canceled` |
|
|
336
|
+
| `@Async` Spring annotation | `go func() { ... }()` with context propagation |
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# Config Management Standard
|
|
2
|
+
|
|
3
|
+
All configuration comes from environment variables. This is the 12-factor
|
|
4
|
+
app method and is mandatory for every service in this fleet.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Rule 1 — Environment variables only; no config files at runtime
|
|
9
|
+
|
|
10
|
+
No YAML, TOML, or JSON config files read at runtime. No `application.properties`.
|
|
11
|
+
Environment variables are injected by the container orchestrator (Kubernetes
|
|
12
|
+
ConfigMaps and Secrets) and are the single source of truth at runtime.
|
|
13
|
+
|
|
14
|
+
### Java equivalent
|
|
15
|
+
Spring Boot's `application.properties` / `@Value` / `@ConfigurationProperties`
|
|
16
|
+
→ Go struct with env tags, loaded once in `main`.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Config Struct with env Tags
|
|
21
|
+
|
|
22
|
+
Use `github.com/caarlos0/env/v11` (or the team-agreed env loader). Define
|
|
23
|
+
one `Config` struct per service. Tag every field.
|
|
24
|
+
|
|
25
|
+
```go
|
|
26
|
+
// config/config.go
|
|
27
|
+
package config
|
|
28
|
+
|
|
29
|
+
import (
|
|
30
|
+
"fmt"
|
|
31
|
+
|
|
32
|
+
"github.com/caarlos0/env/v11"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
// Config holds all configuration for this service.
|
|
36
|
+
// Fields tagged `required` cause Load() to return an error if absent.
|
|
37
|
+
// Fields with `envDefault` use that value when the variable is unset.
|
|
38
|
+
type Config struct {
|
|
39
|
+
// Server
|
|
40
|
+
Port string `env:"PORT" envDefault:"8080"`
|
|
41
|
+
ShutdownTimeout int `env:"SHUTDOWN_TIMEOUT" envDefault:"10"` // seconds
|
|
42
|
+
|
|
43
|
+
// Database
|
|
44
|
+
DBHost string `env:"DB_HOST" required:"true"`
|
|
45
|
+
DBPort string `env:"DB_PORT" envDefault:"5432"`
|
|
46
|
+
DBName string `env:"DB_NAME" required:"true"`
|
|
47
|
+
DBUser string `env:"DB_USER" required:"true"`
|
|
48
|
+
DBPassword string `env:"DB_PASSWORD" required:"true"`
|
|
49
|
+
DBSSLMode string `env:"DB_SSLMODE" envDefault:"require"`
|
|
50
|
+
|
|
51
|
+
// Observability
|
|
52
|
+
LogLevel string `env:"LOG_LEVEL" envDefault:"info"`
|
|
53
|
+
OTELEndpoint string `env:"OTEL_ENDPOINT" envDefault:"http://otel-collector:4318"`
|
|
54
|
+
ServiceName string `env:"SERVICE_NAME" required:"true"`
|
|
55
|
+
ServiceVersion string `env:"SERVICE_VERSION" envDefault:"dev"`
|
|
56
|
+
|
|
57
|
+
// Auth
|
|
58
|
+
JWTSecret string `env:"JWT_SECRET" required:"true"`
|
|
59
|
+
|
|
60
|
+
// External services (example)
|
|
61
|
+
NotificationServiceURL string `env:"NOTIFICATION_SERVICE_URL" required:"true"`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Load parses environment variables into Config.
|
|
65
|
+
// Returns an error if any required variable is missing or has an invalid type.
|
|
66
|
+
func Load() (*Config, error) {
|
|
67
|
+
cfg := &Config{}
|
|
68
|
+
if err := env.Parse(cfg); err != nil {
|
|
69
|
+
return nil, fmt.Errorf("loading config: %w", err)
|
|
70
|
+
}
|
|
71
|
+
return cfg, nil
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// DSN builds the postgres connection string from individual fields.
|
|
75
|
+
// Never expose this method's output in logs.
|
|
76
|
+
func (c *Config) DSN() string {
|
|
77
|
+
return fmt.Sprintf(
|
|
78
|
+
"host=%s port=%s dbname=%s user=%s password=%s sslmode=%s",
|
|
79
|
+
c.DBHost, c.DBPort, c.DBName, c.DBUser, c.DBPassword, c.DBSSLMode,
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## Rule 2 — Load once at startup; pass as struct
|
|
87
|
+
|
|
88
|
+
Call `config.Load()` exactly once in `main.go`. Pass the populated struct
|
|
89
|
+
(or specific fields) to constructors. Never call `os.Getenv` anywhere else
|
|
90
|
+
in the codebase.
|
|
91
|
+
|
|
92
|
+
### DO
|
|
93
|
+
|
|
94
|
+
```go
|
|
95
|
+
// cmd/server/main.go
|
|
96
|
+
cfg, err := config.Load()
|
|
97
|
+
if err != nil {
|
|
98
|
+
log.Fatalf("loading config: %v", err)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Pass the config struct — or specific fields — to each constructor
|
|
102
|
+
db, err := repository.OpenDB(cfg.DSN())
|
|
103
|
+
if err != nil {
|
|
104
|
+
log.Fatalf("opening db: %v", err)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
notifClient := notification.NewClient(cfg.NotificationServiceURL)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### DO NOT
|
|
111
|
+
|
|
112
|
+
```go
|
|
113
|
+
// WRONG: reading env vars scattered across the codebase
|
|
114
|
+
func NewUserRepository(db *sql.DB) *UserRepository {
|
|
115
|
+
timeout, _ := strconv.Atoi(os.Getenv("DB_TIMEOUT")) // WRONG
|
|
116
|
+
return &UserRepository{db: db, timeout: timeout}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// WRONG: global config variable
|
|
120
|
+
var GlobalConfig *config.Config // WRONG: hidden dependency, untestable
|
|
121
|
+
|
|
122
|
+
// WRONG: reading config in a handler
|
|
123
|
+
func (h *UserHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|
124
|
+
maxUsers, _ := strconv.Atoi(os.Getenv("MAX_USERS")) // WRONG
|
|
125
|
+
...
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## Rule 3 — Secrets: never commit, never log
|
|
132
|
+
|
|
133
|
+
Secrets are values in `DBPassword`, `JWTSecret`, API keys, etc.
|
|
134
|
+
|
|
135
|
+
Rules:
|
|
136
|
+
1. Never commit real secret values to git — `.env` is in `.gitignore`
|
|
137
|
+
2. Never log a secret or a struct that contains one
|
|
138
|
+
3. Never include secrets in error messages returned to clients
|
|
139
|
+
4. Implement `fmt.Stringer` with redaction if the Config struct is ever logged
|
|
140
|
+
|
|
141
|
+
```go
|
|
142
|
+
// config/config.go — safe String() prevents accidental logging of secrets
|
|
143
|
+
func (c *Config) String() string {
|
|
144
|
+
return fmt.Sprintf(
|
|
145
|
+
"Config{Port:%s, DBHost:%s, DBName:%s, LogLevel:%s, ServiceName:%s}",
|
|
146
|
+
c.Port, c.DBHost, c.DBName, c.LogLevel, c.ServiceName,
|
|
147
|
+
// NOTE: DBPassword, JWTSecret intentionally omitted
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
```go
|
|
153
|
+
// DO: log the safe representation
|
|
154
|
+
logger.Info("service starting", zap.String("config", cfg.String()))
|
|
155
|
+
|
|
156
|
+
// DO NOT: log the raw struct
|
|
157
|
+
logger.Info("service starting", zap.Any("config", cfg)) // WRONG: leaks secrets
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
## `.env.example` — Documents all variables
|
|
163
|
+
|
|
164
|
+
Every service ships a `.env.example` that documents every variable with a
|
|
165
|
+
description but uses only placeholder values. This is committed to git and
|
|
166
|
+
is the primary operator documentation for deployment.
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
# .env.example
|
|
170
|
+
# Copy to .env for local development. Never commit .env.
|
|
171
|
+
|
|
172
|
+
# --- Server ---
|
|
173
|
+
PORT=8080
|
|
174
|
+
SHUTDOWN_TIMEOUT=10 # seconds to wait for in-flight requests on SIGTERM
|
|
175
|
+
|
|
176
|
+
# --- Database ---
|
|
177
|
+
DB_HOST=localhost # required — PostgreSQL host
|
|
178
|
+
DB_PORT=5432
|
|
179
|
+
DB_NAME=myservice_db # required
|
|
180
|
+
DB_USER=myservice # required
|
|
181
|
+
DB_PASSWORD=CHANGEME # required — use a strong random value in production
|
|
182
|
+
DB_SSLMODE=disable # use 'require' in production
|
|
183
|
+
|
|
184
|
+
# --- Observability ---
|
|
185
|
+
LOG_LEVEL=info # debug | info | warn | error
|
|
186
|
+
OTEL_ENDPOINT=http://otel-collector:4318
|
|
187
|
+
SERVICE_NAME=my-service # required — used as Prometheus/Loki label
|
|
188
|
+
SERVICE_VERSION=dev
|
|
189
|
+
|
|
190
|
+
# --- Auth ---
|
|
191
|
+
JWT_SECRET=CHANGEME # required — minimum 32 bytes of entropy in production
|
|
192
|
+
|
|
193
|
+
# --- External services ---
|
|
194
|
+
NOTIFICATION_SERVICE_URL=http://notification-svc:8080 # required
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
`.gitignore` entry (must be present):
|
|
198
|
+
|
|
199
|
+
```
|
|
200
|
+
.env
|
|
201
|
+
*.env.local
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## Rule 4 — Type-safe default handling
|
|
207
|
+
|
|
208
|
+
Numeric and boolean env vars must be declared with the correct Go type in
|
|
209
|
+
the Config struct so the env loader validates them automatically.
|
|
210
|
+
|
|
211
|
+
```go
|
|
212
|
+
type Config struct {
|
|
213
|
+
Port int `env:"PORT" envDefault:"8080"`
|
|
214
|
+
ShutdownTimeout time.Duration `env:"SHUTDOWN_TIMEOUT" envDefault:"10s"`
|
|
215
|
+
EnableProfiling bool `env:"ENABLE_PROFILING" envDefault:"false"`
|
|
216
|
+
MaxDBConns int `env:"MAX_DB_CONNS" envDefault:"25"`
|
|
217
|
+
}
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### DO NOT
|
|
221
|
+
|
|
222
|
+
```go
|
|
223
|
+
// WRONG: everything as string, manual conversion at use site
|
|
224
|
+
type Config struct {
|
|
225
|
+
Port string `env:"PORT"` // WRONG: forces strconv everywhere
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
port, _ := strconv.Atoi(cfg.Port) // WRONG: error ignored, repeated across codebase
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
---
|
|
232
|
+
|
|
233
|
+
## Validation Beyond Required Tags
|
|
234
|
+
|
|
235
|
+
For cross-field validation or range checks, validate in `Load()` after
|
|
236
|
+
parsing:
|
|
237
|
+
|
|
238
|
+
```go
|
|
239
|
+
func Load() (*Config, error) {
|
|
240
|
+
cfg := &Config{}
|
|
241
|
+
if err := env.Parse(cfg); err != nil {
|
|
242
|
+
return nil, fmt.Errorf("loading config: %w", err)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if cfg.MaxDBConns < 1 || cfg.MaxDBConns > 100 {
|
|
246
|
+
return nil, fmt.Errorf("MAX_DB_CONNS must be between 1 and 100, got %d", cfg.MaxDBConns)
|
|
247
|
+
}
|
|
248
|
+
if len(cfg.JWTSecret) < 32 {
|
|
249
|
+
return nil, fmt.Errorf("JWT_SECRET must be at least 32 characters")
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return cfg, nil
|
|
253
|
+
}
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
## Quick Reference: Java → Go Config Mapping
|
|
259
|
+
|
|
260
|
+
| Spring Boot | Go |
|
|
261
|
+
|-------------|----|
|
|
262
|
+
| `application.properties` | environment variables |
|
|
263
|
+
| `@Value("${server.port}")` | `env:"PORT"` tag on struct field |
|
|
264
|
+
| `@ConfigurationProperties(prefix="db")` | nested struct or flat `Config` struct |
|
|
265
|
+
| `spring.datasource.url` | `DB_HOST`, `DB_PORT`, `DB_NAME` separate vars |
|
|
266
|
+
| Vault / AWS Secrets Manager | Env vars injected by K8s Secrets (same end result) |
|
|
267
|
+
| Profile-based config (`dev`, `prod`) | Env vars differ per deployment; no profile concept |
|