@corbat-tech/coding-standards-mcp 1.0.3 → 2.0.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/README.md +233 -337
- package/dist/agent.d.ts +5 -6
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +95 -217
- package/dist/agent.js.map +1 -1
- package/dist/analysis/code-analyzer.d.ts +44 -0
- package/dist/analysis/code-analyzer.d.ts.map +1 -0
- package/dist/analysis/code-analyzer.js +528 -0
- package/dist/analysis/code-analyzer.js.map +1 -0
- package/dist/errors.d.ts +58 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +112 -0
- package/dist/errors.js.map +1 -0
- package/dist/guardrails.d.ts +35 -0
- package/dist/guardrails.d.ts.map +1 -0
- package/dist/guardrails.js +303 -0
- package/dist/guardrails.js.map +1 -0
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/logger.d.ts +36 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +63 -0
- package/dist/logger.js.map +1 -0
- package/dist/metrics.d.ts +40 -0
- package/dist/metrics.d.ts.map +1 -0
- package/dist/metrics.js +97 -0
- package/dist/metrics.js.map +1 -0
- package/dist/profiles.d.ts +1 -1
- package/dist/profiles.d.ts.map +1 -1
- package/dist/profiles.js +239 -108
- package/dist/profiles.js.map +1 -1
- package/dist/prompts.js +1 -1
- package/dist/prompts.js.map +1 -1
- package/dist/tools/definitions.d.ts +143 -0
- package/dist/tools/definitions.d.ts.map +1 -0
- package/dist/tools/definitions.js +229 -0
- package/dist/tools/definitions.js.map +1 -0
- package/dist/tools/handlers/get-context.d.ts +12 -0
- package/dist/tools/handlers/get-context.d.ts.map +1 -0
- package/dist/tools/handlers/get-context.js +233 -0
- package/dist/tools/handlers/get-context.js.map +1 -0
- package/dist/tools/handlers/health.d.ts +11 -0
- package/dist/tools/handlers/health.d.ts.map +1 -0
- package/dist/tools/handlers/health.js +57 -0
- package/dist/tools/handlers/health.js.map +1 -0
- package/dist/tools/handlers/index.d.ts +12 -0
- package/dist/tools/handlers/index.d.ts.map +1 -0
- package/dist/tools/handlers/index.js +12 -0
- package/dist/tools/handlers/index.js.map +1 -0
- package/dist/tools/handlers/init.d.ts +12 -0
- package/dist/tools/handlers/init.d.ts.map +1 -0
- package/dist/tools/handlers/init.js +102 -0
- package/dist/tools/handlers/init.js.map +1 -0
- package/dist/tools/handlers/profiles.d.ts +11 -0
- package/dist/tools/handlers/profiles.d.ts.map +1 -0
- package/dist/tools/handlers/profiles.js +25 -0
- package/dist/tools/handlers/profiles.js.map +1 -0
- package/dist/tools/handlers/search.d.ts +12 -0
- package/dist/tools/handlers/search.d.ts.map +1 -0
- package/dist/tools/handlers/search.js +58 -0
- package/dist/tools/handlers/search.js.map +1 -0
- package/dist/tools/handlers/validate.d.ts +15 -0
- package/dist/tools/handlers/validate.d.ts.map +1 -0
- package/dist/tools/handlers/validate.js +71 -0
- package/dist/tools/handlers/validate.js.map +1 -0
- package/dist/tools/handlers/verify.d.ts +38 -0
- package/dist/tools/handlers/verify.d.ts.map +1 -0
- package/dist/tools/handlers/verify.js +172 -0
- package/dist/tools/handlers/verify.js.map +1 -0
- package/dist/tools/index.d.ts +22 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +75 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/schemas.d.ts +29 -0
- package/dist/tools/schemas.d.ts.map +1 -0
- package/dist/tools/schemas.js +20 -0
- package/dist/tools/schemas.js.map +1 -0
- package/dist/tools.js +2 -2
- package/dist/tools.js.map +1 -1
- package/dist/types.d.ts +141 -71
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +92 -40
- package/dist/types.js.map +1 -1
- package/package.json +2 -2
- package/profiles/examples/microservice-kafka.yaml +122 -0
- package/profiles/examples/startup-fast.yaml +67 -0
- package/profiles/examples/strict-enterprise.yaml +62 -0
- package/profiles/templates/angular.yaml +614 -0
- package/profiles/templates/csharp-dotnet.yaml +529 -0
- package/profiles/templates/flutter.yaml +547 -0
- package/profiles/templates/go.yaml +1276 -0
- package/profiles/templates/java-spring-backend.yaml +326 -0
- package/profiles/templates/kotlin-spring.yaml +417 -0
- package/profiles/templates/nextjs.yaml +536 -0
- package/profiles/templates/nodejs.yaml +594 -0
- package/profiles/templates/python.yaml +546 -0
- package/profiles/templates/react.yaml +456 -0
- package/profiles/templates/rust.yaml +508 -0
- package/profiles/templates/vue.yaml +483 -0
|
@@ -0,0 +1,1276 @@
|
|
|
1
|
+
# ============================================================================
|
|
2
|
+
# CORBAT MCP - Go Profile
|
|
3
|
+
# ============================================================================
|
|
4
|
+
# Production-ready standards for Go backend applications and microservices.
|
|
5
|
+
# Based on Effective Go, Go Code Review Comments, and idiomatic Go patterns.
|
|
6
|
+
# ============================================================================
|
|
7
|
+
|
|
8
|
+
name: "Go Backend Standards"
|
|
9
|
+
description: "Production-ready standards for Go microservices with idiomatic patterns, clean architecture, and comprehensive testing"
|
|
10
|
+
|
|
11
|
+
# ----------------------------------------------------------------------------
|
|
12
|
+
# ARCHITECTURE
|
|
13
|
+
# ----------------------------------------------------------------------------
|
|
14
|
+
architecture:
|
|
15
|
+
type: clean
|
|
16
|
+
enforceLayerDependencies: true
|
|
17
|
+
layers:
|
|
18
|
+
- name: domain
|
|
19
|
+
description: "Core business logic. Pure Go with no external dependencies. Contains entities, value objects, and domain interfaces."
|
|
20
|
+
allowedDependencies: []
|
|
21
|
+
packages:
|
|
22
|
+
- "internal/domain"
|
|
23
|
+
- "internal/domain/entity"
|
|
24
|
+
- "internal/domain/valueobject"
|
|
25
|
+
- "internal/domain/repository"
|
|
26
|
+
- "internal/domain/service"
|
|
27
|
+
|
|
28
|
+
- name: usecase
|
|
29
|
+
description: "Application use cases. Orchestrates domain objects. Contains business workflows and application services."
|
|
30
|
+
allowedDependencies:
|
|
31
|
+
- domain
|
|
32
|
+
packages:
|
|
33
|
+
- "internal/usecase"
|
|
34
|
+
- "internal/application"
|
|
35
|
+
|
|
36
|
+
- name: interface
|
|
37
|
+
description: "Delivery mechanisms: HTTP handlers, gRPC servers, CLI commands. Adapts external input to use cases."
|
|
38
|
+
allowedDependencies:
|
|
39
|
+
- domain
|
|
40
|
+
- usecase
|
|
41
|
+
packages:
|
|
42
|
+
- "internal/handler"
|
|
43
|
+
- "internal/transport"
|
|
44
|
+
- "internal/api"
|
|
45
|
+
- "cmd"
|
|
46
|
+
|
|
47
|
+
- name: infrastructure
|
|
48
|
+
description: "External adapters: database repositories, external APIs, messaging. Implements domain interfaces."
|
|
49
|
+
allowedDependencies:
|
|
50
|
+
- domain
|
|
51
|
+
- usecase
|
|
52
|
+
packages:
|
|
53
|
+
- "internal/infrastructure"
|
|
54
|
+
- "internal/repository"
|
|
55
|
+
- "internal/gateway"
|
|
56
|
+
- "pkg"
|
|
57
|
+
|
|
58
|
+
# ----------------------------------------------------------------------------
|
|
59
|
+
# GO IDIOMS
|
|
60
|
+
# ----------------------------------------------------------------------------
|
|
61
|
+
goIdioms:
|
|
62
|
+
enabled: true
|
|
63
|
+
|
|
64
|
+
naming:
|
|
65
|
+
packages: "lowercase, single word preferred"
|
|
66
|
+
exported: "PascalCase (exported)"
|
|
67
|
+
unexported: "camelCase (unexported)"
|
|
68
|
+
interfaces: "er suffix for single-method (Reader, Writer, Closer)"
|
|
69
|
+
acronyms: "ALL_CAPS (URL, HTTP, ID)"
|
|
70
|
+
receivers: "short, 1-2 letters (c for Customer)"
|
|
71
|
+
variables: "short in small scope, descriptive in large scope"
|
|
72
|
+
constants: "PascalCase for exported, camelCase for unexported"
|
|
73
|
+
|
|
74
|
+
errorHandling:
|
|
75
|
+
returnErrorLast: true
|
|
76
|
+
checkImmediately: true
|
|
77
|
+
wrapWithContext: true
|
|
78
|
+
useErrorsIs: true
|
|
79
|
+
useErrorsAs: true
|
|
80
|
+
sentinelErrors: true
|
|
81
|
+
customErrorTypes: true
|
|
82
|
+
neverPanicInLibraries: true
|
|
83
|
+
examples:
|
|
84
|
+
- "if err != nil { return fmt.Errorf(\"failed to create order: %w\", err) }"
|
|
85
|
+
- "var ErrNotFound = errors.New(\"entity not found\")"
|
|
86
|
+
|
|
87
|
+
patterns:
|
|
88
|
+
functionalOptions: true
|
|
89
|
+
tableDrivenTests: true
|
|
90
|
+
deferForCleanup: true
|
|
91
|
+
contextPropagation: true
|
|
92
|
+
interfaceSegregation: true
|
|
93
|
+
acceptInterfacesReturnStructs: true
|
|
94
|
+
earlyReturn: true
|
|
95
|
+
examples:
|
|
96
|
+
- "Functional Options: func NewServer(opts ...Option) *Server"
|
|
97
|
+
- "Accept interfaces: func Process(r io.Reader)"
|
|
98
|
+
- "Return structs: func NewService() *Service"
|
|
99
|
+
|
|
100
|
+
# ----------------------------------------------------------------------------
|
|
101
|
+
# CODE QUALITY
|
|
102
|
+
# ----------------------------------------------------------------------------
|
|
103
|
+
codeQuality:
|
|
104
|
+
maxMethodLines: 30
|
|
105
|
+
maxClassLines: 300
|
|
106
|
+
maxFileLines: 500
|
|
107
|
+
maxMethodParameters: 4
|
|
108
|
+
maxCyclomaticComplexity: 10
|
|
109
|
+
requireDocumentation: true
|
|
110
|
+
requireTests: true
|
|
111
|
+
minimumTestCoverage: 70
|
|
112
|
+
|
|
113
|
+
principles:
|
|
114
|
+
- "Clear is better than clever"
|
|
115
|
+
- "A little copying is better than a little dependency"
|
|
116
|
+
- "Errors are values"
|
|
117
|
+
- "Don't panic"
|
|
118
|
+
- "Accept interfaces, return structs"
|
|
119
|
+
- "Make the zero value useful"
|
|
120
|
+
- "Concurrency is not parallelism"
|
|
121
|
+
|
|
122
|
+
linting:
|
|
123
|
+
tools:
|
|
124
|
+
- "go fmt"
|
|
125
|
+
- "go vet"
|
|
126
|
+
- "staticcheck"
|
|
127
|
+
- "golangci-lint"
|
|
128
|
+
golangciLintConfig:
|
|
129
|
+
enabled:
|
|
130
|
+
- "errcheck"
|
|
131
|
+
- "gosimple"
|
|
132
|
+
- "govet"
|
|
133
|
+
- "ineffassign"
|
|
134
|
+
- "staticcheck"
|
|
135
|
+
- "unused"
|
|
136
|
+
- "gocyclo"
|
|
137
|
+
- "gofmt"
|
|
138
|
+
- "goimports"
|
|
139
|
+
- "misspell"
|
|
140
|
+
- "unconvert"
|
|
141
|
+
- "unparam"
|
|
142
|
+
- "gocritic"
|
|
143
|
+
|
|
144
|
+
# ----------------------------------------------------------------------------
|
|
145
|
+
# NAMING CONVENTIONS
|
|
146
|
+
# ----------------------------------------------------------------------------
|
|
147
|
+
naming:
|
|
148
|
+
general:
|
|
149
|
+
package: lowercase_single_word
|
|
150
|
+
struct: PascalCase
|
|
151
|
+
interface: PascalCase_er_suffix
|
|
152
|
+
function: PascalCase_or_camelCase
|
|
153
|
+
variable: camelCase
|
|
154
|
+
constant: PascalCase_or_camelCase
|
|
155
|
+
file: snake_case.go
|
|
156
|
+
|
|
157
|
+
suffixes:
|
|
158
|
+
handler: "Handler"
|
|
159
|
+
service: "Service"
|
|
160
|
+
repository: "Repository"
|
|
161
|
+
usecase: "UseCase"
|
|
162
|
+
mock: "Mock"
|
|
163
|
+
test: "_test.go"
|
|
164
|
+
options: "Option"
|
|
165
|
+
|
|
166
|
+
testing:
|
|
167
|
+
testFile: "*_test.go"
|
|
168
|
+
testFunction: "Test_FunctionName_Scenario"
|
|
169
|
+
benchmarkFunction: "Benchmark_FunctionName"
|
|
170
|
+
exampleFunction: "Example_FunctionName"
|
|
171
|
+
|
|
172
|
+
# ----------------------------------------------------------------------------
|
|
173
|
+
# TESTING
|
|
174
|
+
# ----------------------------------------------------------------------------
|
|
175
|
+
testing:
|
|
176
|
+
framework: "testing (stdlib)"
|
|
177
|
+
assertionLibrary: "testify/assert (optional)"
|
|
178
|
+
mockingLibrary: "gomock or mockery"
|
|
179
|
+
|
|
180
|
+
types:
|
|
181
|
+
unit:
|
|
182
|
+
suffix: "_test.go"
|
|
183
|
+
location: "same package"
|
|
184
|
+
coverage: 70
|
|
185
|
+
parallel: true
|
|
186
|
+
|
|
187
|
+
integration:
|
|
188
|
+
suffix: "_integration_test.go"
|
|
189
|
+
location: "same package or _test package"
|
|
190
|
+
buildTag: "integration"
|
|
191
|
+
useTestcontainers: true
|
|
192
|
+
|
|
193
|
+
benchmark:
|
|
194
|
+
prefix: "Benchmark_"
|
|
195
|
+
location: "*_test.go"
|
|
196
|
+
|
|
197
|
+
patterns:
|
|
198
|
+
tableDrivenTests: true
|
|
199
|
+
subtests: true
|
|
200
|
+
parallelTests: true
|
|
201
|
+
testHelpers: true
|
|
202
|
+
testFixtures: true
|
|
203
|
+
examples:
|
|
204
|
+
- |
|
|
205
|
+
func TestCalculateTotal(t *testing.T) {
|
|
206
|
+
tests := []struct {
|
|
207
|
+
name string
|
|
208
|
+
input []int
|
|
209
|
+
expected int
|
|
210
|
+
}{
|
|
211
|
+
{"empty slice", []int{}, 0},
|
|
212
|
+
{"single item", []int{5}, 5},
|
|
213
|
+
{"multiple items", []int{1, 2, 3}, 6},
|
|
214
|
+
}
|
|
215
|
+
for _, tt := range tests {
|
|
216
|
+
t.Run(tt.name, func(t *testing.T) {
|
|
217
|
+
t.Parallel()
|
|
218
|
+
got := CalculateTotal(tt.input)
|
|
219
|
+
if got != tt.expected {
|
|
220
|
+
t.Errorf("got %d, want %d", got, tt.expected)
|
|
221
|
+
}
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
testcontainers:
|
|
227
|
+
enabled: true
|
|
228
|
+
containers:
|
|
229
|
+
- "postgres"
|
|
230
|
+
- "redis"
|
|
231
|
+
- "kafka"
|
|
232
|
+
- "mongodb"
|
|
233
|
+
|
|
234
|
+
# ----------------------------------------------------------------------------
|
|
235
|
+
# CONCURRENCY
|
|
236
|
+
# ----------------------------------------------------------------------------
|
|
237
|
+
concurrency:
|
|
238
|
+
patterns:
|
|
239
|
+
goroutines:
|
|
240
|
+
alwaysOwnLifecycle: true
|
|
241
|
+
useWaitGroups: true
|
|
242
|
+
useErrgroups: true
|
|
243
|
+
avoidLeaks: true
|
|
244
|
+
channels:
|
|
245
|
+
preferUnbuffered: true
|
|
246
|
+
closeByProducer: true
|
|
247
|
+
nilChannelBlocks: true
|
|
248
|
+
context:
|
|
249
|
+
propagateAlways: true
|
|
250
|
+
useForCancellation: true
|
|
251
|
+
useForTimeout: true
|
|
252
|
+
firstParameter: true
|
|
253
|
+
mutexes:
|
|
254
|
+
embedPrivately: true
|
|
255
|
+
deferUnlock: true
|
|
256
|
+
avoidCopying: true
|
|
257
|
+
|
|
258
|
+
examples:
|
|
259
|
+
- "Always pass context.Context as first parameter"
|
|
260
|
+
- "Use errgroup.Group for concurrent error handling"
|
|
261
|
+
- "Close channels from producer side only"
|
|
262
|
+
- "Prefer select with default for non-blocking operations"
|
|
263
|
+
|
|
264
|
+
# ----------------------------------------------------------------------------
|
|
265
|
+
# ERROR HANDLING
|
|
266
|
+
# ----------------------------------------------------------------------------
|
|
267
|
+
errorHandling:
|
|
268
|
+
format: "Wrapped errors with context"
|
|
269
|
+
|
|
270
|
+
patterns:
|
|
271
|
+
sentinel:
|
|
272
|
+
- "var ErrNotFound = errors.New(\"not found\")"
|
|
273
|
+
- "var ErrInvalidInput = errors.New(\"invalid input\")"
|
|
274
|
+
- "var ErrUnauthorized = errors.New(\"unauthorized\")"
|
|
275
|
+
|
|
276
|
+
custom:
|
|
277
|
+
example: |
|
|
278
|
+
type ValidationError struct {
|
|
279
|
+
Field string
|
|
280
|
+
Message string
|
|
281
|
+
}
|
|
282
|
+
func (e *ValidationError) Error() string {
|
|
283
|
+
return fmt.Sprintf("%s: %s", e.Field, e.Message)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
wrapping:
|
|
287
|
+
- "Always wrap with context: fmt.Errorf(\"failed to process order %s: %w\", orderID, err)"
|
|
288
|
+
- "Use errors.Is for sentinel errors"
|
|
289
|
+
- "Use errors.As for type assertions"
|
|
290
|
+
|
|
291
|
+
avoid:
|
|
292
|
+
- "panic in library code"
|
|
293
|
+
- "ignoring errors (even with _)"
|
|
294
|
+
- "error strings starting with capital or ending with punctuation"
|
|
295
|
+
- "naked returns in functions with named error return"
|
|
296
|
+
|
|
297
|
+
# ----------------------------------------------------------------------------
|
|
298
|
+
# OBSERVABILITY
|
|
299
|
+
# ----------------------------------------------------------------------------
|
|
300
|
+
observability:
|
|
301
|
+
enabled: true
|
|
302
|
+
|
|
303
|
+
logging:
|
|
304
|
+
framework: "slog (stdlib)"
|
|
305
|
+
format: "JSON"
|
|
306
|
+
structuredLogging: true
|
|
307
|
+
levels:
|
|
308
|
+
production: "Info"
|
|
309
|
+
development: "Debug"
|
|
310
|
+
contextual: true
|
|
311
|
+
avoid:
|
|
312
|
+
- "fmt.Println for logging"
|
|
313
|
+
- "log.Fatal in libraries"
|
|
314
|
+
- "logging sensitive data"
|
|
315
|
+
example: |
|
|
316
|
+
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
|
317
|
+
logger.Info("order processed",
|
|
318
|
+
slog.String("order_id", order.ID),
|
|
319
|
+
slog.Int("items", len(order.Items)),
|
|
320
|
+
slog.Duration("duration", elapsed),
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
metrics:
|
|
324
|
+
framework: "prometheus/client_golang"
|
|
325
|
+
registry: "prometheus"
|
|
326
|
+
types:
|
|
327
|
+
- "Counter: requests_total, errors_total"
|
|
328
|
+
- "Gauge: connections_active, queue_length"
|
|
329
|
+
- "Histogram: request_duration_seconds"
|
|
330
|
+
naming: "snake_case with _total, _seconds suffixes"
|
|
331
|
+
|
|
332
|
+
tracing:
|
|
333
|
+
framework: "OpenTelemetry"
|
|
334
|
+
propagation: "W3C Trace Context"
|
|
335
|
+
exporters:
|
|
336
|
+
- "Jaeger"
|
|
337
|
+
- "OTLP"
|
|
338
|
+
spanAttributes:
|
|
339
|
+
- "http.method"
|
|
340
|
+
- "http.route"
|
|
341
|
+
- "http.status_code"
|
|
342
|
+
- "db.operation"
|
|
343
|
+
|
|
344
|
+
healthChecks:
|
|
345
|
+
endpoints:
|
|
346
|
+
- "/health"
|
|
347
|
+
- "/health/live"
|
|
348
|
+
- "/health/ready"
|
|
349
|
+
customChecks:
|
|
350
|
+
- "database"
|
|
351
|
+
- "redis"
|
|
352
|
+
- "external APIs"
|
|
353
|
+
|
|
354
|
+
# ----------------------------------------------------------------------------
|
|
355
|
+
# HTTP/API
|
|
356
|
+
# ----------------------------------------------------------------------------
|
|
357
|
+
httpApi:
|
|
358
|
+
router: "chi, gorilla/mux, or stdlib (1.22+)"
|
|
359
|
+
middleware:
|
|
360
|
+
- "logging"
|
|
361
|
+
- "recovery"
|
|
362
|
+
- "requestID"
|
|
363
|
+
- "timeout"
|
|
364
|
+
- "cors"
|
|
365
|
+
|
|
366
|
+
patterns:
|
|
367
|
+
requestValidation: "encoding/json + custom validators"
|
|
368
|
+
responseFormat: "JSON"
|
|
369
|
+
errorResponses: "RFC 7807 Problem Details"
|
|
370
|
+
pagination: "cursor-based or offset"
|
|
371
|
+
versioning: "/api/v1/"
|
|
372
|
+
|
|
373
|
+
gracefulShutdown:
|
|
374
|
+
enabled: true
|
|
375
|
+
timeout: "30s"
|
|
376
|
+
example: |
|
|
377
|
+
srv := &http.Server{Addr: ":8080", Handler: router}
|
|
378
|
+
go func() {
|
|
379
|
+
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
|
|
380
|
+
log.Fatal(err)
|
|
381
|
+
}
|
|
382
|
+
}()
|
|
383
|
+
<-ctx.Done()
|
|
384
|
+
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
385
|
+
defer cancel()
|
|
386
|
+
srv.Shutdown(shutdownCtx)
|
|
387
|
+
|
|
388
|
+
# ----------------------------------------------------------------------------
|
|
389
|
+
# DATABASE
|
|
390
|
+
# ----------------------------------------------------------------------------
|
|
391
|
+
database:
|
|
392
|
+
driver: "database/sql or sqlx"
|
|
393
|
+
orm: "sqlc (generated) or GORM"
|
|
394
|
+
|
|
395
|
+
migrations:
|
|
396
|
+
tool: "golang-migrate or goose"
|
|
397
|
+
location: "db/migrations"
|
|
398
|
+
naming: "{timestamp}_{description}.up.sql"
|
|
399
|
+
|
|
400
|
+
patterns:
|
|
401
|
+
repository: true
|
|
402
|
+
preparedStatements: true
|
|
403
|
+
connectionPooling: true
|
|
404
|
+
transactionHandling: true
|
|
405
|
+
|
|
406
|
+
queryBuilder: "sqlc (type-safe generated code)"
|
|
407
|
+
|
|
408
|
+
# ----------------------------------------------------------------------------
|
|
409
|
+
# PROJECT STRUCTURE
|
|
410
|
+
# ----------------------------------------------------------------------------
|
|
411
|
+
projectStructure:
|
|
412
|
+
layout: "Standard Go Project Layout"
|
|
413
|
+
directories:
|
|
414
|
+
cmd: "Main applications (cmd/api/, cmd/worker/)"
|
|
415
|
+
internal: "Private application code"
|
|
416
|
+
pkg: "Public library code (if any)"
|
|
417
|
+
api: "API definitions (OpenAPI, protobuf)"
|
|
418
|
+
configs: "Configuration files"
|
|
419
|
+
scripts: "Build and automation scripts"
|
|
420
|
+
test: "Additional test data and utilities"
|
|
421
|
+
docs: "Documentation"
|
|
422
|
+
|
|
423
|
+
example: |
|
|
424
|
+
myapp/
|
|
425
|
+
├── cmd/
|
|
426
|
+
│ └── api/
|
|
427
|
+
│ └── main.go
|
|
428
|
+
├── internal/
|
|
429
|
+
│ ├── domain/
|
|
430
|
+
│ │ ├── entity/
|
|
431
|
+
│ │ └── repository/
|
|
432
|
+
│ ├── usecase/
|
|
433
|
+
│ ├── handler/
|
|
434
|
+
│ └── infrastructure/
|
|
435
|
+
├── pkg/
|
|
436
|
+
├── api/
|
|
437
|
+
├── configs/
|
|
438
|
+
└── go.mod
|
|
439
|
+
|
|
440
|
+
# ----------------------------------------------------------------------------
|
|
441
|
+
# DEPENDENCY INJECTION
|
|
442
|
+
# ----------------------------------------------------------------------------
|
|
443
|
+
dependencyInjection:
|
|
444
|
+
approach: "Constructor injection (manual)"
|
|
445
|
+
frameworks: "wire (optional), fx (optional)"
|
|
446
|
+
|
|
447
|
+
pattern: |
|
|
448
|
+
func NewOrderService(repo OrderRepository, logger *slog.Logger) *OrderService {
|
|
449
|
+
return &OrderService{
|
|
450
|
+
repo: repo,
|
|
451
|
+
logger: logger,
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
# ----------------------------------------------------------------------------
|
|
456
|
+
# TECHNOLOGIES
|
|
457
|
+
# ----------------------------------------------------------------------------
|
|
458
|
+
technologies:
|
|
459
|
+
- name: go
|
|
460
|
+
version: "1.22+"
|
|
461
|
+
specificRules:
|
|
462
|
+
useGoModules: true
|
|
463
|
+
minimumGoVersion: "1.22"
|
|
464
|
+
useGenerics: true
|
|
465
|
+
useRangeOverFunc: true
|
|
466
|
+
useSlicesPackage: true
|
|
467
|
+
useMapsPackage: true
|
|
468
|
+
|
|
469
|
+
- name: http
|
|
470
|
+
framework: "chi or stdlib mux"
|
|
471
|
+
specificRules:
|
|
472
|
+
useMiddleware: true
|
|
473
|
+
useGracefulShutdown: true
|
|
474
|
+
useContextTimeout: true
|
|
475
|
+
|
|
476
|
+
- name: database
|
|
477
|
+
driver: "pgx for PostgreSQL"
|
|
478
|
+
specificRules:
|
|
479
|
+
usePreparedStatements: true
|
|
480
|
+
useConnectionPool: true
|
|
481
|
+
handleTransactions: true
|
|
482
|
+
|
|
483
|
+
- name: testing
|
|
484
|
+
specificRules:
|
|
485
|
+
useTableDrivenTests: true
|
|
486
|
+
useParallelTests: true
|
|
487
|
+
useSubtests: true
|
|
488
|
+
useBenchmarks: true
|
|
489
|
+
useTestcontainers: true
|
|
490
|
+
|
|
491
|
+
# ----------------------------------------------------------------------------
|
|
492
|
+
# CODE EXAMPLES
|
|
493
|
+
# ----------------------------------------------------------------------------
|
|
494
|
+
codeExamples:
|
|
495
|
+
entity:
|
|
496
|
+
description: "Domain entity with business logic"
|
|
497
|
+
code: |
|
|
498
|
+
// internal/domain/entity/order.go
|
|
499
|
+
package entity
|
|
500
|
+
|
|
501
|
+
import (
|
|
502
|
+
"errors"
|
|
503
|
+
"time"
|
|
504
|
+
|
|
505
|
+
"github.com/google/uuid"
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
var (
|
|
509
|
+
ErrEmptyItems = errors.New("order must have at least one item")
|
|
510
|
+
ErrNotPending = errors.New("order is not pending")
|
|
511
|
+
ErrInvalidAmount = errors.New("amount must be positive")
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
type OrderStatus string
|
|
515
|
+
|
|
516
|
+
const (
|
|
517
|
+
OrderStatusPending OrderStatus = "PENDING"
|
|
518
|
+
OrderStatusConfirmed OrderStatus = "CONFIRMED"
|
|
519
|
+
OrderStatusCancelled OrderStatus = "CANCELLED"
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
type Order struct {
|
|
523
|
+
ID uuid.UUID
|
|
524
|
+
CustomerID uuid.UUID
|
|
525
|
+
Items []OrderItem
|
|
526
|
+
Status OrderStatus
|
|
527
|
+
CreatedAt time.Time
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
func NewOrder(customerID uuid.UUID, items []OrderItem) (*Order, error) {
|
|
531
|
+
if len(items) == 0 {
|
|
532
|
+
return nil, ErrEmptyItems
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return &Order{
|
|
536
|
+
ID: uuid.New(),
|
|
537
|
+
CustomerID: customerID,
|
|
538
|
+
Items: items,
|
|
539
|
+
Status: OrderStatusPending,
|
|
540
|
+
CreatedAt: time.Now(),
|
|
541
|
+
}, nil
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
func (o *Order) Total() Money {
|
|
545
|
+
total := Money{Amount: 0, Currency: "USD"}
|
|
546
|
+
for _, item := range o.Items {
|
|
547
|
+
total = total.Add(item.Subtotal())
|
|
548
|
+
}
|
|
549
|
+
return total
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
func (o *Order) Confirm() error {
|
|
553
|
+
if o.Status != OrderStatusPending {
|
|
554
|
+
return ErrNotPending
|
|
555
|
+
}
|
|
556
|
+
o.Status = OrderStatusConfirmed
|
|
557
|
+
return nil
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
func (o *Order) AddItem(item OrderItem) error {
|
|
561
|
+
if o.Status != OrderStatusPending {
|
|
562
|
+
return ErrNotPending
|
|
563
|
+
}
|
|
564
|
+
o.Items = append(o.Items, item)
|
|
565
|
+
return nil
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
valueObject:
|
|
569
|
+
description: "Immutable value object"
|
|
570
|
+
code: |
|
|
571
|
+
// internal/domain/valueobject/money.go
|
|
572
|
+
package valueobject
|
|
573
|
+
|
|
574
|
+
import "fmt"
|
|
575
|
+
|
|
576
|
+
type Money struct {
|
|
577
|
+
Amount int64 // cents
|
|
578
|
+
Currency string
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
func NewMoney(amount int64, currency string) (Money, error) {
|
|
582
|
+
if amount < 0 {
|
|
583
|
+
return Money{}, fmt.Errorf("amount cannot be negative: %d", amount)
|
|
584
|
+
}
|
|
585
|
+
return Money{Amount: amount, Currency: currency}, nil
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
func (m Money) Add(other Money) Money {
|
|
589
|
+
if m.Currency != other.Currency {
|
|
590
|
+
panic(fmt.Sprintf("currency mismatch: %s vs %s", m.Currency, other.Currency))
|
|
591
|
+
}
|
|
592
|
+
return Money{Amount: m.Amount + other.Amount, Currency: m.Currency}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
func (m Money) Multiply(factor int) Money {
|
|
596
|
+
return Money{Amount: m.Amount * int64(factor), Currency: m.Currency}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
func (m Money) String() string {
|
|
600
|
+
return fmt.Sprintf("%s %.2f", m.Currency, float64(m.Amount)/100)
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
func (m Money) Equals(other Money) bool {
|
|
604
|
+
return m.Amount == other.Amount && m.Currency == other.Currency
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
repositoryInterface:
|
|
608
|
+
description: "Repository interface (port)"
|
|
609
|
+
code: |
|
|
610
|
+
// internal/domain/repository/order.go
|
|
611
|
+
package repository
|
|
612
|
+
|
|
613
|
+
import (
|
|
614
|
+
"context"
|
|
615
|
+
|
|
616
|
+
"github.com/google/uuid"
|
|
617
|
+
"myapp/internal/domain/entity"
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
type OrderRepository interface {
|
|
621
|
+
Save(ctx context.Context, order *entity.Order) error
|
|
622
|
+
FindByID(ctx context.Context, id uuid.UUID) (*entity.Order, error)
|
|
623
|
+
FindByCustomerID(ctx context.Context, customerID uuid.UUID) ([]*entity.Order, error)
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
useCase:
|
|
627
|
+
description: "Application use case"
|
|
628
|
+
code: |
|
|
629
|
+
// internal/usecase/create_order.go
|
|
630
|
+
package usecase
|
|
631
|
+
|
|
632
|
+
import (
|
|
633
|
+
"context"
|
|
634
|
+
"log/slog"
|
|
635
|
+
|
|
636
|
+
"github.com/google/uuid"
|
|
637
|
+
"myapp/internal/domain/entity"
|
|
638
|
+
"myapp/internal/domain/repository"
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
type CreateOrderInput struct {
|
|
642
|
+
CustomerID uuid.UUID
|
|
643
|
+
Items []CreateOrderItemInput
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
type CreateOrderItemInput struct {
|
|
647
|
+
ProductID uuid.UUID
|
|
648
|
+
Quantity int
|
|
649
|
+
UnitPrice int64
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
type CreateOrderUseCase struct {
|
|
653
|
+
orderRepo repository.OrderRepository
|
|
654
|
+
publisher EventPublisher
|
|
655
|
+
logger *slog.Logger
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
func NewCreateOrderUseCase(
|
|
659
|
+
orderRepo repository.OrderRepository,
|
|
660
|
+
publisher EventPublisher,
|
|
661
|
+
logger *slog.Logger,
|
|
662
|
+
) *CreateOrderUseCase {
|
|
663
|
+
return &CreateOrderUseCase{
|
|
664
|
+
orderRepo: orderRepo,
|
|
665
|
+
publisher: publisher,
|
|
666
|
+
logger: logger,
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
func (uc *CreateOrderUseCase) Execute(ctx context.Context, input CreateOrderInput) (*entity.Order, error) {
|
|
671
|
+
uc.logger.Info("creating order", slog.String("customer_id", input.CustomerID.String()))
|
|
672
|
+
|
|
673
|
+
items := make([]entity.OrderItem, 0, len(input.Items))
|
|
674
|
+
for _, item := range input.Items {
|
|
675
|
+
orderItem, err := entity.NewOrderItem(item.ProductID, item.Quantity, item.UnitPrice)
|
|
676
|
+
if err != nil {
|
|
677
|
+
return nil, fmt.Errorf("invalid item: %w", err)
|
|
678
|
+
}
|
|
679
|
+
items = append(items, orderItem)
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
order, err := entity.NewOrder(input.CustomerID, items)
|
|
683
|
+
if err != nil {
|
|
684
|
+
return nil, fmt.Errorf("failed to create order: %w", err)
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
if err := uc.orderRepo.Save(ctx, order); err != nil {
|
|
688
|
+
return nil, fmt.Errorf("failed to save order: %w", err)
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
if err := uc.publisher.Publish(ctx, OrderCreatedEvent{OrderID: order.ID}); err != nil {
|
|
692
|
+
uc.logger.Error("failed to publish event", slog.Any("error", err))
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
uc.logger.Info("order created", slog.String("order_id", order.ID.String()))
|
|
696
|
+
|
|
697
|
+
return order, nil
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
handler:
|
|
701
|
+
description: "HTTP handler with chi router"
|
|
702
|
+
code: |
|
|
703
|
+
// internal/handler/order_handler.go
|
|
704
|
+
package handler
|
|
705
|
+
|
|
706
|
+
import (
|
|
707
|
+
"encoding/json"
|
|
708
|
+
"net/http"
|
|
709
|
+
|
|
710
|
+
"github.com/go-chi/chi/v5"
|
|
711
|
+
"github.com/google/uuid"
|
|
712
|
+
"myapp/internal/usecase"
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
type OrderHandler struct {
|
|
716
|
+
createOrderUC *usecase.CreateOrderUseCase
|
|
717
|
+
getOrderUC *usecase.GetOrderUseCase
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
func NewOrderHandler(
|
|
721
|
+
createOrderUC *usecase.CreateOrderUseCase,
|
|
722
|
+
getOrderUC *usecase.GetOrderUseCase,
|
|
723
|
+
) *OrderHandler {
|
|
724
|
+
return &OrderHandler{
|
|
725
|
+
createOrderUC: createOrderUC,
|
|
726
|
+
getOrderUC: getOrderUC,
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
func (h *OrderHandler) Routes() chi.Router {
|
|
731
|
+
r := chi.NewRouter()
|
|
732
|
+
r.Post("/", h.Create)
|
|
733
|
+
r.Get("/{id}", h.GetByID)
|
|
734
|
+
return r
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
type CreateOrderRequest struct {
|
|
738
|
+
CustomerID string `json:"customer_id"`
|
|
739
|
+
Items []OrderItemRequest `json:"items"`
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
type OrderItemRequest struct {
|
|
743
|
+
ProductID string `json:"product_id"`
|
|
744
|
+
Quantity int `json:"quantity"`
|
|
745
|
+
UnitPrice int64 `json:"unit_price"`
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
func (h *OrderHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|
749
|
+
var req CreateOrderRequest
|
|
750
|
+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
751
|
+
respondError(w, http.StatusBadRequest, "invalid request body")
|
|
752
|
+
return
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
customerID, err := uuid.Parse(req.CustomerID)
|
|
756
|
+
if err != nil {
|
|
757
|
+
respondError(w, http.StatusBadRequest, "invalid customer_id")
|
|
758
|
+
return
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
input := usecase.CreateOrderInput{
|
|
762
|
+
CustomerID: customerID,
|
|
763
|
+
Items: mapItemsToInput(req.Items),
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
order, err := h.createOrderUC.Execute(r.Context(), input)
|
|
767
|
+
if err != nil {
|
|
768
|
+
handleUseCaseError(w, err)
|
|
769
|
+
return
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
respondJSON(w, http.StatusCreated, mapOrderToResponse(order))
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
func (h *OrderHandler) GetByID(w http.ResponseWriter, r *http.Request) {
|
|
776
|
+
idStr := chi.URLParam(r, "id")
|
|
777
|
+
id, err := uuid.Parse(idStr)
|
|
778
|
+
if err != nil {
|
|
779
|
+
respondError(w, http.StatusBadRequest, "invalid order id")
|
|
780
|
+
return
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
order, err := h.getOrderUC.Execute(r.Context(), id)
|
|
784
|
+
if err != nil {
|
|
785
|
+
handleUseCaseError(w, err)
|
|
786
|
+
return
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
respondJSON(w, http.StatusOK, mapOrderToResponse(order))
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
repositoryImpl:
|
|
793
|
+
description: "Repository implementation with PostgreSQL"
|
|
794
|
+
code: |
|
|
795
|
+
// internal/infrastructure/postgres/order_repository.go
|
|
796
|
+
package postgres
|
|
797
|
+
|
|
798
|
+
import (
|
|
799
|
+
"context"
|
|
800
|
+
"database/sql"
|
|
801
|
+
"fmt"
|
|
802
|
+
|
|
803
|
+
"github.com/google/uuid"
|
|
804
|
+
"myapp/internal/domain/entity"
|
|
805
|
+
"myapp/internal/domain/repository"
|
|
806
|
+
)
|
|
807
|
+
|
|
808
|
+
type OrderRepository struct {
|
|
809
|
+
db *sql.DB
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
func NewOrderRepository(db *sql.DB) *OrderRepository {
|
|
813
|
+
return &OrderRepository{db: db}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
func (r *OrderRepository) Save(ctx context.Context, order *entity.Order) error {
|
|
817
|
+
tx, err := r.db.BeginTx(ctx, nil)
|
|
818
|
+
if err != nil {
|
|
819
|
+
return fmt.Errorf("begin transaction: %w", err)
|
|
820
|
+
}
|
|
821
|
+
defer tx.Rollback()
|
|
822
|
+
|
|
823
|
+
_, err = tx.ExecContext(ctx, `
|
|
824
|
+
INSERT INTO orders (id, customer_id, status, created_at)
|
|
825
|
+
VALUES ($1, $2, $3, $4)
|
|
826
|
+
ON CONFLICT (id) DO UPDATE SET status = $3
|
|
827
|
+
`, order.ID, order.CustomerID, order.Status, order.CreatedAt)
|
|
828
|
+
if err != nil {
|
|
829
|
+
return fmt.Errorf("insert order: %w", err)
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
for _, item := range order.Items {
|
|
833
|
+
_, err = tx.ExecContext(ctx, `
|
|
834
|
+
INSERT INTO order_items (id, order_id, product_id, quantity, unit_price)
|
|
835
|
+
VALUES ($1, $2, $3, $4, $5)
|
|
836
|
+
`, item.ID, order.ID, item.ProductID, item.Quantity, item.UnitPrice)
|
|
837
|
+
if err != nil {
|
|
838
|
+
return fmt.Errorf("insert item: %w", err)
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
if err := tx.Commit(); err != nil {
|
|
843
|
+
return fmt.Errorf("commit transaction: %w", err)
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
return nil
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
func (r *OrderRepository) FindByID(ctx context.Context, id uuid.UUID) (*entity.Order, error) {
|
|
850
|
+
row := r.db.QueryRowContext(ctx, `
|
|
851
|
+
SELECT id, customer_id, status, created_at
|
|
852
|
+
FROM orders WHERE id = $1
|
|
853
|
+
`, id)
|
|
854
|
+
|
|
855
|
+
var order entity.Order
|
|
856
|
+
if err := row.Scan(&order.ID, &order.CustomerID, &order.Status, &order.CreatedAt); err != nil {
|
|
857
|
+
if err == sql.ErrNoRows {
|
|
858
|
+
return nil, nil
|
|
859
|
+
}
|
|
860
|
+
return nil, fmt.Errorf("scan order: %w", err)
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
items, err := r.findItemsByOrderID(ctx, id)
|
|
864
|
+
if err != nil {
|
|
865
|
+
return nil, err
|
|
866
|
+
}
|
|
867
|
+
order.Items = items
|
|
868
|
+
|
|
869
|
+
return &order, nil
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
test:
|
|
873
|
+
description: "Table-driven test"
|
|
874
|
+
code: |
|
|
875
|
+
// internal/domain/entity/order_test.go
|
|
876
|
+
package entity_test
|
|
877
|
+
|
|
878
|
+
import (
|
|
879
|
+
"testing"
|
|
880
|
+
|
|
881
|
+
"github.com/google/uuid"
|
|
882
|
+
"github.com/stretchr/testify/assert"
|
|
883
|
+
"github.com/stretchr/testify/require"
|
|
884
|
+
"myapp/internal/domain/entity"
|
|
885
|
+
)
|
|
886
|
+
|
|
887
|
+
func TestNewOrder(t *testing.T) {
|
|
888
|
+
t.Parallel()
|
|
889
|
+
|
|
890
|
+
customerID := uuid.New()
|
|
891
|
+
validItem := entity.OrderItem{
|
|
892
|
+
ID: uuid.New(),
|
|
893
|
+
ProductID: uuid.New(),
|
|
894
|
+
Quantity: 2,
|
|
895
|
+
UnitPrice: 1000,
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
tests := []struct {
|
|
899
|
+
name string
|
|
900
|
+
customerID uuid.UUID
|
|
901
|
+
items []entity.OrderItem
|
|
902
|
+
wantErr error
|
|
903
|
+
wantStatus entity.OrderStatus
|
|
904
|
+
}{
|
|
905
|
+
{
|
|
906
|
+
name: "valid order",
|
|
907
|
+
customerID: customerID,
|
|
908
|
+
items: []entity.OrderItem{validItem},
|
|
909
|
+
wantStatus: entity.OrderStatusPending,
|
|
910
|
+
},
|
|
911
|
+
{
|
|
912
|
+
name: "empty items",
|
|
913
|
+
customerID: customerID,
|
|
914
|
+
items: []entity.OrderItem{},
|
|
915
|
+
wantErr: entity.ErrEmptyItems,
|
|
916
|
+
},
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
for _, tt := range tests {
|
|
920
|
+
t.Run(tt.name, func(t *testing.T) {
|
|
921
|
+
t.Parallel()
|
|
922
|
+
|
|
923
|
+
order, err := entity.NewOrder(tt.customerID, tt.items)
|
|
924
|
+
|
|
925
|
+
if tt.wantErr != nil {
|
|
926
|
+
require.ErrorIs(t, err, tt.wantErr)
|
|
927
|
+
return
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
require.NoError(t, err)
|
|
931
|
+
assert.Equal(t, tt.customerID, order.CustomerID)
|
|
932
|
+
assert.Equal(t, tt.wantStatus, order.Status)
|
|
933
|
+
assert.NotEqual(t, uuid.Nil, order.ID)
|
|
934
|
+
})
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
func TestOrder_Confirm(t *testing.T) {
|
|
939
|
+
t.Parallel()
|
|
940
|
+
|
|
941
|
+
t.Run("should confirm pending order", func(t *testing.T) {
|
|
942
|
+
t.Parallel()
|
|
943
|
+
|
|
944
|
+
order := createTestOrder(t)
|
|
945
|
+
|
|
946
|
+
err := order.Confirm()
|
|
947
|
+
|
|
948
|
+
require.NoError(t, err)
|
|
949
|
+
assert.Equal(t, entity.OrderStatusConfirmed, order.Status)
|
|
950
|
+
})
|
|
951
|
+
|
|
952
|
+
t.Run("should fail to confirm non-pending order", func(t *testing.T) {
|
|
953
|
+
t.Parallel()
|
|
954
|
+
|
|
955
|
+
order := createTestOrder(t)
|
|
956
|
+
_ = order.Confirm()
|
|
957
|
+
|
|
958
|
+
err := order.Confirm()
|
|
959
|
+
|
|
960
|
+
require.ErrorIs(t, err, entity.ErrNotPending)
|
|
961
|
+
})
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
func createTestOrder(t *testing.T) *entity.Order {
|
|
965
|
+
t.Helper()
|
|
966
|
+
order, err := entity.NewOrder(uuid.New(), []entity.OrderItem{
|
|
967
|
+
{ID: uuid.New(), ProductID: uuid.New(), Quantity: 1, UnitPrice: 1000},
|
|
968
|
+
})
|
|
969
|
+
require.NoError(t, err)
|
|
970
|
+
return order
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
functionalOptions:
|
|
974
|
+
description: "Functional options pattern"
|
|
975
|
+
code: |
|
|
976
|
+
// pkg/server/server.go
|
|
977
|
+
package server
|
|
978
|
+
|
|
979
|
+
import (
|
|
980
|
+
"log/slog"
|
|
981
|
+
"net/http"
|
|
982
|
+
"time"
|
|
983
|
+
)
|
|
984
|
+
|
|
985
|
+
type Server struct {
|
|
986
|
+
addr string
|
|
987
|
+
readTimeout time.Duration
|
|
988
|
+
writeTimeout time.Duration
|
|
989
|
+
logger *slog.Logger
|
|
990
|
+
handler http.Handler
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
type Option func(*Server)
|
|
994
|
+
|
|
995
|
+
func WithAddr(addr string) Option {
|
|
996
|
+
return func(s *Server) {
|
|
997
|
+
s.addr = addr
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
func WithReadTimeout(d time.Duration) Option {
|
|
1002
|
+
return func(s *Server) {
|
|
1003
|
+
s.readTimeout = d
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
func WithWriteTimeout(d time.Duration) Option {
|
|
1008
|
+
return func(s *Server) {
|
|
1009
|
+
s.writeTimeout = d
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
func WithLogger(logger *slog.Logger) Option {
|
|
1014
|
+
return func(s *Server) {
|
|
1015
|
+
s.logger = logger
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
func NewServer(handler http.Handler, opts ...Option) *Server {
|
|
1020
|
+
s := &Server{
|
|
1021
|
+
addr: ":8080",
|
|
1022
|
+
readTimeout: 15 * time.Second,
|
|
1023
|
+
writeTimeout: 15 * time.Second,
|
|
1024
|
+
logger: slog.Default(),
|
|
1025
|
+
handler: handler,
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
for _, opt := range opts {
|
|
1029
|
+
opt(s)
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
return s
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
func (s *Server) Start() error {
|
|
1036
|
+
srv := &http.Server{
|
|
1037
|
+
Addr: s.addr,
|
|
1038
|
+
Handler: s.handler,
|
|
1039
|
+
ReadTimeout: s.readTimeout,
|
|
1040
|
+
WriteTimeout: s.writeTimeout,
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
s.logger.Info("starting server", slog.String("addr", s.addr))
|
|
1044
|
+
return srv.ListenAndServe()
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
# ----------------------------------------------------------------------------
|
|
1048
|
+
# ANTI-PATTERNS
|
|
1049
|
+
# ----------------------------------------------------------------------------
|
|
1050
|
+
antiPatterns:
|
|
1051
|
+
panicInLibraries:
|
|
1052
|
+
name: "Panic in Library Code"
|
|
1053
|
+
description: "Using panic instead of returning errors"
|
|
1054
|
+
bad: |
|
|
1055
|
+
// ❌ Panic in library code
|
|
1056
|
+
func ParseConfig(path string) Config {
|
|
1057
|
+
data, err := os.ReadFile(path)
|
|
1058
|
+
if err != nil {
|
|
1059
|
+
panic(err) // Don't panic!
|
|
1060
|
+
}
|
|
1061
|
+
var cfg Config
|
|
1062
|
+
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
1063
|
+
panic(err)
|
|
1064
|
+
}
|
|
1065
|
+
return cfg
|
|
1066
|
+
}
|
|
1067
|
+
good: |
|
|
1068
|
+
// ✅ Return errors
|
|
1069
|
+
func ParseConfig(path string) (Config, error) {
|
|
1070
|
+
data, err := os.ReadFile(path)
|
|
1071
|
+
if err != nil {
|
|
1072
|
+
return Config{}, fmt.Errorf("read config file: %w", err)
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
var cfg Config
|
|
1076
|
+
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
1077
|
+
return Config{}, fmt.Errorf("parse config: %w", err)
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
return cfg, nil
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
ignoringErrors:
|
|
1084
|
+
name: "Ignoring Errors"
|
|
1085
|
+
description: "Discarding errors with _"
|
|
1086
|
+
bad: |
|
|
1087
|
+
// ❌ Ignoring errors
|
|
1088
|
+
data, _ := json.Marshal(order)
|
|
1089
|
+
_ = db.Save(order)
|
|
1090
|
+
resp, _ := http.Get(url)
|
|
1091
|
+
good: |
|
|
1092
|
+
// ❌ Handle errors properly
|
|
1093
|
+
data, err := json.Marshal(order)
|
|
1094
|
+
if err != nil {
|
|
1095
|
+
return fmt.Errorf("marshal order: %w", err)
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
if err := db.Save(order); err != nil {
|
|
1099
|
+
return fmt.Errorf("save order: %w", err)
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
resp, err := http.Get(url)
|
|
1103
|
+
if err != nil {
|
|
1104
|
+
return fmt.Errorf("fetch data: %w", err)
|
|
1105
|
+
}
|
|
1106
|
+
defer resp.Body.Close()
|
|
1107
|
+
|
|
1108
|
+
goroutineLeaks:
|
|
1109
|
+
name: "Goroutine Leaks"
|
|
1110
|
+
description: "Starting goroutines without proper lifecycle management"
|
|
1111
|
+
bad: |
|
|
1112
|
+
// ❌ Goroutine leak - no way to stop
|
|
1113
|
+
func StartWorker() {
|
|
1114
|
+
go func() {
|
|
1115
|
+
for {
|
|
1116
|
+
processItem(<-queue)
|
|
1117
|
+
}
|
|
1118
|
+
}()
|
|
1119
|
+
}
|
|
1120
|
+
good: |
|
|
1121
|
+
// ✅ Goroutine with context cancellation
|
|
1122
|
+
func StartWorker(ctx context.Context, queue <-chan Item) {
|
|
1123
|
+
go func() {
|
|
1124
|
+
for {
|
|
1125
|
+
select {
|
|
1126
|
+
case <-ctx.Done():
|
|
1127
|
+
return
|
|
1128
|
+
case item := <-queue:
|
|
1129
|
+
processItem(item)
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
}()
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
noContextPropagation:
|
|
1136
|
+
name: "Missing Context Propagation"
|
|
1137
|
+
description: "Not passing context through the call chain"
|
|
1138
|
+
bad: |
|
|
1139
|
+
// ❌ No context propagation
|
|
1140
|
+
func (s *Service) ProcessOrder(order Order) error {
|
|
1141
|
+
user := s.userRepo.FindByID(order.UserID) // No context!
|
|
1142
|
+
return s.orderRepo.Save(order) // No context!
|
|
1143
|
+
}
|
|
1144
|
+
good: |
|
|
1145
|
+
// ✅ Context propagation
|
|
1146
|
+
func (s *Service) ProcessOrder(ctx context.Context, order Order) error {
|
|
1147
|
+
user, err := s.userRepo.FindByID(ctx, order.UserID)
|
|
1148
|
+
if err != nil {
|
|
1149
|
+
return fmt.Errorf("find user: %w", err)
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
if err := s.orderRepo.Save(ctx, order); err != nil {
|
|
1153
|
+
return fmt.Errorf("save order: %w", err)
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
return nil
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
globalState:
|
|
1160
|
+
name: "Global Mutable State"
|
|
1161
|
+
description: "Using global variables for state"
|
|
1162
|
+
bad: |
|
|
1163
|
+
// ❌ Global mutable state
|
|
1164
|
+
var db *sql.DB
|
|
1165
|
+
var logger *slog.Logger
|
|
1166
|
+
|
|
1167
|
+
func init() {
|
|
1168
|
+
db, _ = sql.Open("postgres", os.Getenv("DB_URL"))
|
|
1169
|
+
logger = slog.Default()
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
func GetUser(id string) (*User, error) {
|
|
1173
|
+
return db.Query("SELECT * FROM users WHERE id = $1", id)
|
|
1174
|
+
}
|
|
1175
|
+
good: |
|
|
1176
|
+
// ✅ Dependency injection
|
|
1177
|
+
type UserRepository struct {
|
|
1178
|
+
db *sql.DB
|
|
1179
|
+
logger *slog.Logger
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
func NewUserRepository(db *sql.DB, logger *slog.Logger) *UserRepository {
|
|
1183
|
+
return &UserRepository{db: db, logger: logger}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
func (r *UserRepository) GetUser(ctx context.Context, id string) (*User, error) {
|
|
1187
|
+
// ...
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
nakedReturns:
|
|
1191
|
+
name: "Naked Returns in Long Functions"
|
|
1192
|
+
description: "Using naked returns in non-trivial functions"
|
|
1193
|
+
bad: |
|
|
1194
|
+
// ❌ Naked return in long function
|
|
1195
|
+
func ProcessOrder(id string) (order *Order, err error) {
|
|
1196
|
+
order, err = repo.FindByID(id)
|
|
1197
|
+
if err != nil {
|
|
1198
|
+
return // What are we returning?
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
// ... 50 more lines ...
|
|
1202
|
+
|
|
1203
|
+
order.Status = "processed"
|
|
1204
|
+
return // Unclear what's returned
|
|
1205
|
+
}
|
|
1206
|
+
good: |
|
|
1207
|
+
// ✅ Explicit returns
|
|
1208
|
+
func ProcessOrder(id string) (*Order, error) {
|
|
1209
|
+
order, err := repo.FindByID(id)
|
|
1210
|
+
if err != nil {
|
|
1211
|
+
return nil, fmt.Errorf("find order: %w", err)
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
// ...
|
|
1215
|
+
|
|
1216
|
+
order.Status = "processed"
|
|
1217
|
+
return order, nil
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
interfaceForNoReason:
|
|
1221
|
+
name: "Premature Interface Abstraction"
|
|
1222
|
+
description: "Creating interfaces before there's a need for them"
|
|
1223
|
+
bad: |
|
|
1224
|
+
// ❌ Interface with single implementation
|
|
1225
|
+
type OrderServiceInterface interface {
|
|
1226
|
+
Create(order Order) error
|
|
1227
|
+
Get(id string) (*Order, error)
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
type OrderService struct{}
|
|
1231
|
+
|
|
1232
|
+
func (s *OrderService) Create(order Order) error { ... }
|
|
1233
|
+
func (s *OrderService) Get(id string) (*Order, error) { ... }
|
|
1234
|
+
|
|
1235
|
+
// Only one implementation exists
|
|
1236
|
+
good: |
|
|
1237
|
+
// ✅ Define interface at consumer side when needed
|
|
1238
|
+
// internal/usecase/create_order.go
|
|
1239
|
+
type OrderRepository interface { // Defined where it's used
|
|
1240
|
+
Save(ctx context.Context, order *Order) error
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
type CreateOrderUseCase struct {
|
|
1244
|
+
repo OrderRepository // Accept interface
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
// internal/infrastructure/postgres/order_repo.go
|
|
1248
|
+
type OrderRepository struct { // Return concrete type
|
|
1249
|
+
db *sql.DB
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
closingChannelsFromConsumer:
|
|
1253
|
+
name: "Closing Channels from Consumer"
|
|
1254
|
+
description: "Consumer closing a channel instead of producer"
|
|
1255
|
+
bad: |
|
|
1256
|
+
// ❌ Consumer closes channel
|
|
1257
|
+
func consumer(ch chan int) {
|
|
1258
|
+
for v := range ch {
|
|
1259
|
+
process(v)
|
|
1260
|
+
}
|
|
1261
|
+
close(ch) // Don't close from consumer!
|
|
1262
|
+
}
|
|
1263
|
+
good: |
|
|
1264
|
+
// ✅ Producer closes channel
|
|
1265
|
+
func producer(ch chan int) {
|
|
1266
|
+
defer close(ch) // Producer closes
|
|
1267
|
+
for i := 0; i < 10; i++ {
|
|
1268
|
+
ch <- i
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
func consumer(ch chan int) {
|
|
1273
|
+
for v := range ch { // Range handles closed channel
|
|
1274
|
+
process(v)
|
|
1275
|
+
}
|
|
1276
|
+
}
|