myvocap-backend
Persona: You are the MyVocab backend engineer. You know every pattern in this codebase intimately. When writing new code, you reproduce the exact file layout, naming, error flow, and DI wiring that existing features use — no improvisation, no "better" alternatives. When reviewing code, you check against the repo's pre-commit checklist.
Precedence: This skill overrides generic jimmy-skills@backend-go-* skills when they conflict. The codebase conventions documented here are the source of truth.
Modes:
- Build mode — adding a new feature or endpoint: scaffold from the feature module template, wire in
cmd/app/http.go, run quality gates. - Review mode — auditing code changes: run the pre-commit checklist, flag violations.
Quick Reference
| Concept | Pattern | Reference |
|---|---|---|
| Feature layout | internal/feature/<name>/{types,repository,service,handler,routes,provider,errors}.go |
references/feature-module-template.md |
| DI wiring | Deps struct + Provide(d Deps) *Handler |
references/feature-module-template.md |
| Error flow | sentinel → toAppError() → writeError() → httpresponse.WriteError() |
references/error-flow.md |
| Repository | sqlc-only (sqlcgen.Queries), domain type conversion via *FromRow() |
references/feature-module-template.md |
| Caching | cache.FetchOne / cache.FetchWithSingleflight + graceful nil degradation |
references/cache-event-patterns.md |
| Events | event.Bus / event.Publisher + Noop() fallback + registerListeners() |
references/cache-event-patterns.md |
| Response | httpresponse.Success/Paginated/Error/WriteError envelope (all success = 200) |
references/response-envelope.md |
| Middleware | auth → localUser → rateLimit chain; consumer-side interfaces | references/middleware-wiring.md |
| User context | middleware.LocalUserIDFromCtx(ctx) — never call user service from handler |
references/middleware-wiring.md |
| Validation | handler = syntactic (ShouldBindJSON), service = semantic (state/quota/ownership) |
below |
| Pagination | ginx.QueryPage(c, maxSize) → db.Page → repo uses .Offset() |
below |
| Transactions | r.pool.Begin(ctx) → r.q.WithTx(tx) → defer r.rollback(ctx, tx) → tx.Commit(ctx) |
references/feature-module-template.md |
| Publisher wiring | cmd/app/<area>_publishers.go (struct + build + Close), nil-safe consumers |
references/publisher-wiring.md |
| Media lifecycle | pkg/storage interface + staging/→media/ keys + commit endpoint + event-based cleanup |
references/media-lifecycle.md |
Rules (Non-Negotiable)
1. Feature layout is fixed
Every feature lives in internal/feature/<name>/ with exactly these files:
| File | Responsibility |
|---|---|
types.go |
Domain models + input/output structs. JSON tags snake_case. Binding tags for validation. |
repository.go |
Repository interface + postgresRepo implementation via sqlcgen.Queries. |
service.go |
Service interface + service struct. Business logic, cache, events. Sentinel errors declared here or in errors.go. |
handler.go |
Handler struct with svc + lgr. HTTP handlers. Syntactic validation only. |
routes.go |
RegisterRoutes(rg *gin.RouterGroup, h *Handler). Route registration. |
provider.go |
Deps struct + Provide(d Deps) *Handler. Wiring: repo → service → handler. |
errors.go |
Sentinel errors, errorLogger interface, writeError(), toAppError(). |
events.go |
(optional) Topic constants: Topic<Feature><Action> = "<feature>:<action>". |
listeners.go |
(optional) registerListeners(bus *event.Bus, lgr logger.Logger). Called from Provide(). |
Do NOT invent parallel layouts, split into sub-packages, or merge files.
Exception — multi-service features. A feature may declare multiple service structs (each in its own <subdomain>_service.go file, same package) when it composes ≥2 sub-domains that share a Repository but otherwise do not call each other. Helpers, workers, or transport adapters that would bloat service.go may live in additional <role>.go files in the same package. Sub-packages are still forbidden — keep everything flat under feature/<name>/. See ADR-0015. Current applications: battle (match_service.go + room_service.go), gamification (streak_service.go + diamond_service.go + leaderboard_service.go).
2. No init(), no mutable globals
Exceptions: compiled regex, var _ Iface = (*T)(nil) assertions, sentinel errors, topic constants.
3. Deps struct lists only what the feature needs
type Deps struct {
Postgres *pgxpool.Pool
Cache *cache.Cache // may be nil → service degrades
EventBus *event.Bus // may be nil → Noop() publisher
Lgr logger.Logger
}
When a field becomes unused, delete it. Don't keep "for later".
4. Validation: two layers, no duplication
- Handler:
c.ShouldBindJSON(&in)→ 400 viahttpresponse.Error(c, http.StatusBadRequest, err) - Service: entity state, ownership, quotas, cross-entity rules → sentinel error → 4xx via
writeError - No CHECK constraints / domain UNIQUEs in migrations. DB is dumb persistence.
5. Error handling: wrap, map, handle once
- Wrap with context:
fmt.Errorf("<feature>: <operation>: %w", err) - Sentinel in
errors.go:var ErrNotFound = errors.New("<thing> not found") - Wrap sentinel with ID:
func thingNotFound(id string) error { return fmt.Errorf("%w: %s", ErrNotFound, id) } - Map in
toAppError(): sentinel →httpresponse.NotFound/BadRequest/Unprocessable/Conflict - Unmapped errors fall through to 500 automatically
- Log OR return, never both.
WriteErrorlogs 5xx.
6. Repository: sqlc-only
- All queries in
internal/db/queries/<feature>.sql - Generated code in
internal/db/sqlc/ - Repository wraps
sqlcgen.Queries:r.q.MethodName(ctx, params) - Convert rows via
<thing>FromRow(row sqlcgen.Type) DomainType pgx.ErrNoRows→ domain sentinel (e.g.,thingNotFound(id))- Exception: writable CTEs that sqlc can't parse → raw
pool.Querywith one-line comment - Exception: crawl schema → raw SQL (schema churns, JSONB upserts)
7. Every external call has a bounded context
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
8. User context from middleware, not handler
uid, ok := middleware.LocalUserIDFromCtx(c.Request.Context())
if !ok {
httpresponse.WriteError(c, h.lgr,
httpresponse.Unauthorized("MISSING_USER_CONTEXT", "missing user context", nil))
return
}
Extract into resolveUser(c *gin.Context) (string, bool) helper on the handler.
9. Cache graceful degradation
if s.cache == nil {
return s.repo.ListByIDs(ctx, ids)
}
return cache.FetchWithSingleflight(ctx, s.cache, ids, ...)
Invalidate on write: cache.Delete() + cache.Forget() (singleflight).
10. Event bus graceful degradation
// In Provide():
eb := event.Noop()
if d.EventBus != nil {
eb = d.EventBus
}
// In service:
if err := s.eventBus.PublishAsync(ctx, event.NewEvent(TopicCreated, entity)); err != nil {
s.lgr.Warn(ctx, "publish event failed", "topic", TopicCreated, "err", err)
}
11. Consumer-side interfaces for cross-cutting
Middleware declares narrow interface → feature's service satisfies it → wired in cmd/app/http.go.
// In middleware package:
type LocalUserResolver interface {
LocalUserID(ctx context.Context, prepID int64, token string) (string, error)
}
// In cmd/app/http.go:
middleware.RequireLocalUser(userBundle.Svc, lgr)
12. Response helpers
| Action | Helper |
|---|---|
| Success (including POST create) | httpresponse.Success(c, data) |
| Paginated list | httpresponse.Paginated(c, items, httpresponse.Pagination{Total, Page, PerPage}) |
| Syntactic error | httpresponse.Error(c, http.StatusBadRequest, err) |
| Domain error | writeError(c, h.lgr, err) (feature-local, maps via toAppError) |
All successful responses return 200. Per jimmy-skills@engineering-rest-api-design, POST returns 200 not 201.
13. Pagination
// Handler:
page := ginx.QueryPage(c, 100) // max 100
// Service → Repo:
items, total, err := s.repo.List(ctx, page)
// Repo:
r.q.ListThings(ctx, sqlcgen.ListThingsParams{
Limit: int32(page.Size),
Offset: int32(page.Offset()),
})
14. Route registration
func RegisterRoutes(rg *gin.RouterGroup, h *Handler) {
v1 := rg.Group("/api/v1")
v1.Handle(http.MethodGet, "/<feature>", h.List)
v1.Handle(http.MethodPost, "/<feature>", h.Create)
v1.Handle(http.MethodGet, "/<feature>/:id", h.Get)
v1.Handle(http.MethodPut, "/<feature>/:id", h.Update)
v1.Handle(http.MethodDelete, "/<feature>/:id", h.Delete)
}
Use http.Method* constants. Use v1.Handle(), not v1.GET() / v1.POST().
15. Wiring in cmd/app/http.go
// 1. Create handler via Provide
thingHandler := thing.Provide(thing.Deps{
Postgres: dbConn,
Cache: appCache,
EventBus: eventBus,
Lgr: lgr,
})
// 2. Register routes on the correct group
thing.RegisterRoutes(localUser, thingHandler) // if needs user UUID
thing.RegisterRoutes(protected, thingHandler) // if no user UUID needed
16. Cross-feature Kafka publisher wiring
Publishers that span multiple features (analytics, notification, audit) live in a sibling file under cmd/app/ named <area>_publishers.go — never inline in http.go. The file owns:
- A
<area>Publishersstruct holding every publisher adapter (typed by the consumer-side interface from the owning feature) plus the underlyingkafka.Producerinstances retained forClose. - A
build<Area>Publishers(ctx, cfg, lgr) *<area>Publishersconstructor that isolates per-producer init failures with aWarnlog + nil publisher field. Returns even when nothing succeeds. - A
(*<area>Publishers).Close(lgr)method that closes each retained producer, nil-skipping, idempotent.
http.go calls build<Area>Publishers once, injects fields into feature Deps, and calls Close from both the early-error path and the normal shutdown path.
Publisher adapter constructors (NewKafkaReviewPublisher, etc.) stay in their owning feature — the consumer-side interface pattern (Rule 11) is preserved. The wiring file is the only place the cross-feature import surface lives.
Consuming features MUST be nil-safe: a dead broker disables analytics but never blocks user write paths. See ADR-0021. Reference: references/publisher-wiring.md.
17. Storage primitive + media lifecycle
Object storage (S3-compatible: Cloudflare R2 in prod, RustFS in local) goes through pkg/storage.Storage — a 7-method interface (Upload / Download / Delete / Copy / Exists / Stat / PresignGet / PresignPut). The S3 backend lives in pkg/storage/s3. Do not import aws-sdk-go-v2 from features — only the storage package may.
User-uploaded media follows a two-prefix lifecycle:
- Presigned PUT writes to
staging/{kind}/{userID}/{uuid}{ext}; committed objects live undermedia/{kind}/{userID}/{uuid}{ext}. Bucket lifecycle rules expirestaging/after 24h. - A
media_objectstable tracks every upload throughpending → committed → deleted. Other features storemedia_id(UUID), never the raw key or URL. - Commit endpoint runs
Statagainst the staging key, verifies size + content-type, callsCopyto the committed prefix, deletes the staging object, flips status tocommitted, publishesmedia:committed. - Delete is by id, not by key — ownership checked via
owner_user_id, soft-deletes the row, removes the storage object best-effort. - Cross-feature cleanup goes through the event bus: vocabulary publishes
vocabulary:card.deleted/vocabulary:deck.deleted, user publishesuser:avatar.changed. The media feature'slisteners.gosubscribes — vocabulary/user never import media. - Attach is a service-level operation via a narrow consumer-side interface (e.g. vocabulary's
MediaResolver), satisfied bymedia.Serviceincmd/app/http.go. Verifies ownership +status=committedand stampsattached_kind/idin one call.
See ADR-0014 / ADR-0016. Reference: references/media-lifecycle.md.
Pre-Commit Checklist
Before reporting any Go change as done, self-review:
-
Layer: handler=syntactic, service=semantic, repo=persistence, middleware=cross-cutting?
-
Imports: no feature importing another feature? middleware using consumer-side interface?
-
init()/ globals: none introduced? -
Context: every external call has
context.WithTimeout? -
Errors: wrapped with
%w? handled once? sentinels mapped viawriteError? -
Validation: syntactic in handler, semantic in service, no duplication?
-
Resources:
defer Close()right after acquire? cleanup order preserved? -
Unused: dead fields/params deleted?
-
Dead abstractions: no interface with one impl "for mocking" unless test uses it?
-
Quality gates:
cd server && go vet ./... && golangci-lint run && go test ./... -count=1
Common Mistakes (Blocked)
| Mistake | Fix |
|---|---|
| Handler calling another feature's service | Use middleware consumer-side interface or event bus |
| Business logic in handler | Move to service; handler only does ShouldBindJSON + call service |
sql.NullString in domain types |
Use *string in domain; convert in *FromRow() |
| CHECK constraint in migration | Remove; validate in service layer |
| Logging error then returning it | Pick one. WriteError logs 5xx automatically |
cache.Delete without cache.Forget |
Always pair both on write operations |
gin.Context passed to service |
Pass c.Request.Context() instead |
| Route on wrong group | protected = no user UUID; localUser = has user UUID |
Missing defer cancel() after context.WithTimeout |
Always defer immediately |
r.q.WithTx(tx) without defer r.rollback(ctx, tx) |
Always defer rollback |
kafka.NewProducer(...) inline in cmd/app/http.go |
Move to cmd/app/<area>_publishers.go (see Rule 16) |
| Producer init failure aborts process startup | Log Warn + nil publisher field; consuming feature must be nil-safe |
| Feature stores raw object key / URL of user media | Store media_id UUID; resolve via media.Service consumer-side interface |
Vocabulary/user calling media.Service.Delete directly on entity-delete |
Publish <feature>:<entity>.deleted event; media's listeners.go cleans up |
Importing aws-sdk-go-v2 from a feature |
Use pkg/storage.Storage interface; backend stays in pkg/storage/s3 |
Splitting one cohesive service into multiple <x>_service.go files "for tidiness" |
Multi-service exception requires ≥2 independent sub-domains sharing only Repository |
Cross-References
- Architecture & DI:
jimmy-skills@backend-go-design-patterns - Error handling theory:
jimmy-skills@backend-go-error-handling - Context propagation:
jimmy-skills@backend-go-context - Database patterns:
jimmy-skills@backend-go-database - Testing:
jimmy-skills@backend-go-testing - API design:
jimmy-skills@engineering-rest-api-design
More from jimnguyendev/jimmy-skills
backend-go-testing
Provides a comprehensive guide for writing production-ready Golang tests. Covers table-driven tests, test suites with testify, mocks, unit tests, integration tests, benchmarks, code coverage, parallel tests, fuzzing, fixtures, goroutine leak detection with goleak, snapshot testing, memory leaks, CI with GitHub Actions, and idiomatic naming conventions. Use this whenever writing tests, asking about testing patterns or setting up CI for Go projects. Essential for ANY test-related conversation in Go.
14backend-go-code-style
Golang code style and readability conventions that require human judgment. Use when reviewing clarity, naming noise, file organization, package boundaries, comments, or maintainability tradeoffs in Go code. Do not use this for golangci-lint setup or lint output interpretation; use `jimmy-skills@backend-go-linter` for tooling.
12backend-go-safety
Defensive Golang coding to prevent panics, silent data corruption, and subtle runtime bugs. Use whenever writing or reviewing Go code that involves nil-prone types (pointers, interfaces, maps, slices, channels), numeric conversions, resource lifecycle (defer in loops), or defensive copying. Also triggers on questions about nil panics, append aliasing, map concurrent access, float comparison, or zero-value design.
11engineering-rest-api-design
REST API design conventions covering URL structure, HTTP methods, pagination, async patterns, idempotency, error envelopes, and API documentation standards. Use when designing new endpoints, reviewing API contracts, or establishing API guidelines before implementation in any language.
11backend-go-design-patterns
Idiomatic Golang design patterns for real backend code: constructors, error flow, dependency injection, resource lifecycle, resilience, data handling, and package boundaries. Apply when designing Go APIs, structuring packages, choosing between patterns, making architecture decisions, or hardening production behavior. Default to simple, feature-first designs unless complexity has clearly appeared.
11backend-go-grpc
Provides gRPC usage guidelines, protobuf organization, and production-ready patterns for Golang microservices. Use when implementing, reviewing, or debugging gRPC servers/clients, writing proto files, setting up interceptors, handling gRPC errors with status codes, configuring TLS/mTLS, testing with bufconn, or working with streaming RPCs.
11