Guidance for AI coding assistants working in duynhlab/pkg. Read this file before making changes.
- No attribution trailers. Never add
Co-authored-by,Generated-by,Signed-off-by,Assisted-by, or any AI/tool attribution to commits. - Commit message format: Subject in imperative mood ("Add feature X" instead of "Adding feature X"), capitalized, no trailing period, ≤50 characters. Prefix with the module when the change is scoped to one:
obsx: Add metric exporter. Body wrapped at 72 columns, explaining what and why. No@mentionsor#123issue references in the commit — put those in the PR description. - Trim verbiage: in PR descriptions, commit messages, and code comments. No marketing prose, no restating the diff, no emojis.
- Rebase, don't merge: Never merge
maininto the feature branch; rebase onto the latestmainand push with--force-with-lease. - Branch names:
<type>/<desc>where<type>∈featfixchoredocsrefactorci. Never push tomain; PRs are squash-merged. - Pre-PR gate:
make test-<module>must pass for every module you touched. It runs tidy, fmt, vet, lint and tests in one shot. Runmake tidyto tidy all affected modules. - Backward compatibility is mandatory. These modules are consumed by all platform services. Breaking changes to exported APIs, function signatures, or behavior will be rejected. Design additive changes.
- Tests: New features, improvements and fixes must have test coverage. Follow existing patterns in the module you're modifying. Run tests locally before pushing.
Before submitting code, review your changes for the following:
- No unchecked I/O. Close HTTP response bodies,
sql.Rows, and file handles indeferstatements. Check and propagate errors from I/O operations. - Context everywhere. Every exported function that performs I/O takes
ctx context.Contextas its first parameter. Never store a context in a struct field. - Error handling. Wrap errors with
%wfor chain inspection. Do not swallow errors silently. Return errors that help callers diagnose the issue without leaking credentials, tokens, or connection strings. - No panics. Never use
panicin library code. Return errors and let callers decide. The explicitMust*variants (flagx.MustEnum,temporalx.MustVersioningFromEnv) exist for fail-fast startup and must stay the only exception. - No hardcoded defaults for security settings. TLS verification stays enabled by default.
authmwmust fail closed — an unparseable or absent token is a rejection, never a pass-through; a JWKS that never loaded is a 503, not a bypass. - No secrets in telemetry. Never put tokens, passwords, DSNs, or request bodies into span attributes, metric labels, or log fields.
dbxdeliberately never enables otelpgx query-parameter capture — bind parameters are PII/secrets. - Bounded cardinality. Metric labels and span attributes must never contain user IDs, request IDs, full URLs with path params, or any unbounded value. Use route patterns (
/orders/{id}), not concrete paths.flagxvalues are bounded by construction so they are safe as metric labels — keep that property. - Resource cleanup. Every constructor that owns a goroutine, pool, or connection returns a
Close/Shutdownfunction. Usedeferandt.TempDir()in tests. - Thread safety. These packages run in concurrent request handlers and Temporal workers. Do not introduce shared mutable state without synchronization.
- Minimal surface. Every exported type, function, and method is a backward-compatibility commitment consumed by multiple services. Minimize new exports. Prefer unexported struct fields with functional options over exported config structs.
duynhlab/pkg is the shared Go SDK for the platform's microservices (auth, user, product, cart, order, review, shipping, notification, payment, checkout — all built as web → logic → core layered services). It is a multi-module monorepo — there is no top-level go.mod; all code lives in the 13 per-directory modules, each independently versioned and tagged (e.g. httpx/v0.36.0, obsx/v0.36.0, logger/zapx/v0.36.0). Services import specific modules from this repo.
The repository provides: generated gRPC/protobuf contracts, structured logging, OpenTelemetry bootstrap, HTTP and gRPC transport helpers, authentication middleware, database access, migrations, startup flags, idempotency handling, and Temporal client/worker helpers.
There is no top-level go.mod — the single-module line (github.com/duynhlab/pkg) is frozen at v0.35.0 and will never publish another version. Each of the 13 modules has its own go.mod:
proto/— one module holding the versioned gRPC contracts for all services (cart,inventory,notification,order,payment,product,review,shipping, each under<svc>/v1/). Source.protofiles and the committed generated.pb.gostubs live together;bufdrives codegen from the repo root.logger/zapx/,logger/zerolog/,logger/clog/— three independent logger modules, one per backend.logger/zapx(zap) is the production default — every service uses it and it pairs withobsx.ZapCorefor OTLP log export.logger/clog(stdliblog/slogvia chainguard-dev/clog) is the slog-based alternative;logger/zerologwraps rs/zerolog. All inject the active trace ID into log lines.flagx/— startup-validated environment flags (Enum/MustEnum,Percent/MustPercent). Values are read and validated once at startup, fail fast, and are bounded by construction so they are safe as metric labels.httpx/— shared HTTP helpers on gin: consistent error responses (RespondError) and pagination (ParsePage,NewPaginated).grpcx/— gRPC server and client helpers for east-west calls:NewServer(otelgrpc stats handler, health service, reflection, panic recovery, access logging),Dial(otelgrpc,round_robinoverdns:///, default per-RPC deadline), machine-readable error reasons (reasons.go), telemetry filters.authmw/— fail-closed gin OIDC middleware.NewVerifier(Config{Issuer, Audience, ...})+MiddlewareJWT(verifier)verify bearer tokens (pinned algorithm, default RS256) against a cached, background-refreshed JWKS (URL derived from the issuer's Keycloak certs path unless overridden, refresh interval viaJWKSCacheTTL); missing/invalid token or emptysub→ 401, JWKS never loaded or nil verifier → 503 (still denies). Setsuser_id/username/email/roleson the gin context (roles normalized from theRolesClaimPathclaim, defaultrealm_access.roles);MiddlewareRequireRole(role)gates on a role with a 403FORBIDDENenvelope, fail-closed on absent context.idempotency/— Stripe-style idempotency-key handling:Record, sentinel errors (ErrConflict,ErrLocked,ErrNotFound), and a Postgres-backedRepository(Claim/Checkpoint/Release/Finish/Reap) that takes a*pgxpool.Pooldirectly. The required DDL ships as a doc comment; this module owns no migrations.dbx/— Postgres pool construction pre-wired for OpenTelemetry:NewPool(ctx, dsn, opts...)applies transaction-mode-pooler-safe settings, an otelpgx tracer with safe defaults (bounded span names, no connection details, never query parameters), and pgxpool stat metrics. Uses the OTel API only; providers are injected via options and default to the globals.migratex/— embedded SQL schema migrations with golang-migrate:Run(fsys, dir, dsn). Accepts a DSN, deliberately independent ofdbx.obsx/— OpenTelemetry SDK bootstrap and the only module that links the OTel SDK:SetupObservability(ctx, ConfigFromEnv())builds traces + metrics + logs over OTLP and returns oneShutdown;ZapCorebridges zap into OTLP logs;TraceContext/TraceIDFromContextfor log↔trace correlation;SetupProfiling(Pyroscope).temporalx/— Temporal client and worker construction with the OTel tracing interceptor wired in:Dial(Config{HostPort, Namespace}),NewWorker(client, taskQueue, opts...), opt-in Worker Deployment Versioning fromTEMPORAL_DEPLOYMENT_NAME+TEMPORAL_WORKER_BUILD_IDviaVersioningFromEnv/MustVersioningFromEnv. Those are Temporal's own variable names — the Worker Controller injects them and Temporal's reference worker reads them — so a worker needs no hand-written identity to run under the controller. An unset versioning behaviour resolves toPinned, matching that reference..github/— CI workflows. Not a module.
This is the most important thing to understand about this repo:
- Every directory with a
go.modis an independent module. There are 13 taggable modules. - Each module gets its own git tag in the form
<module-path>/v<semver>(e.g.httpx/v0.36.0,logger/zapx/v0.36.0). Module tags continue the pre-split numbering — the last single-module tag wasv0.35.0, so per-module history starts atv0.36.0. - External consumers import specific tagged versions:
go get github.com/duynhlab/pkg/httpx@v0.36.0. - The old root module line is dead.
github.com/duynhlab/pkgis frozen atv0.35.0; a dependency graph that mixes the old require with a new per-module require fails immediately withambiguous import(both provide the same package paths), and no newer root version will ever exist to resolve it — consumers must drop the old require entirely. Never re-create a rootgo.modor put Go files at the repo root. - There are currently no cross-module dependencies inside this repo — every module builds standalone. Keep it that way when you can. If a genuine internal dependency ever appears, the dependent module carries a real published version in
requireplus a permanent siblingreplace(e.g.replace github.com/duynhlab/pkg/logger/zapx => ../logger/zapx) for local development;replacein a non-main module is ignored by external consumers, so therequireversion must always be real. - Changing one module may require updating dependents in the services. A change to
obsx.ZapCore's contract, for example, affects every service'smiddleware/logging.go, which pairs it withlogger/zapx. - Do not use
go.work. With no cross-module imports there is nothing for a workspace to resolve, and a workspace file would mask module-boundary errors that CI will catch.
Modules are organised in strict layers. A module may only import modules from a lower layer. Same-layer imports are forbidden even when they would not create a cycle, because they create hidden tag-ordering constraints.
Layer 0 — foundation. Zero internal dependencies.
proto,logger/zapx,logger/zerolog,logger/clog,flagx
Layer 1 — building blocks. May import Layer 0 only.
httpx,grpcx,authmw,idempotency
Layer 2 — terminal. Heavy third-party SDKs. No module in this repository may import a Layer 2 module.
obsx,dbx,migratex,temporalx
Today no module imports another at all; the layers say what is allowed if one ever must.
Concrete rules that follow from this:
- OpenTelemetry API vs SDK.
logger/*,grpcx, anddbxmay importgo.opentelemetry.io/otel(the API),otel/trace,otel/metric, and contrib instrumentation (otelgrpc,otelpgx). They must never importgo.opentelemetry.io/otel/sdkorduynhlab/pkg/obsx. The SDK lives inobsxand nowhere else. This is what lets a service usegrpcxwithout being forced to link the OpenTelemetry SDK. (Tests are exempt: in-memory exporters need the SDK.) obsx,temporalx, andmigratexare wired inmain()only. Service business packages must not import them.dbxis exempt from this rule — repository/store code may use it — but no module in this repo may.migratexdoes not importdbx. It accepts a DSN string. Keeping them independent means a migration job binary does not link the pool implementation and vice versa.grpcxdoes not importproto. Interceptors are generic. Anything that needs a concrete message type belongs in the service.httpxandauthmwdo not import each other. Both produce gin middleware/helpers. Composition happens in the service.idempotencydoes not importdbx. Both bind to*pgxpool.Pooldirectly, which keeps them independent siblings — but they must stay on compatible pgx major versions.- Stdlib and shared-ecosystem types at the boundary. Types appearing in an exported signature become a mandatory dependency for every consumer; types used only inside a function body do not. Prefer
context.Context,error,*zap.Logger,fs.FS,*pgxpool.Pool, or a narrow interface declared locally. Never put a type from anotherduynhlab/pkgmodule into an exported signature. - Consumer-side interfaces. When a lower-layer module needs a capability from a higher layer, it declares the interface itself and lets
main()inject the implementation, or accepts the OTel API's provider interfaces (seedbx.WithTracerProvider/WithMeterProvider). - Nested modules are the escape hatch. When an implementation genuinely needs a Layer 2 dependency, put it in a nested module rather than raising the parent's layer — its own
go.mod, its own tag (thelogger/*modules already follow this shape). A hypothetical Postgres-backed store for a Layer 1 module would live in<module>/postgres/and may importdbx; the parent stays Layer 1.
Enforcement lives in .golangci.yml via depguard (terminal-module imports and the OTel SDK outside obsx are denied). If you need to add an internal import that the linter rejects, the import is wrong — not the linter. Escalate to a human before editing the depguard rules.
All targets in the root Makefile. Module paths use : as separator in make targets (logger/zapx → logger:zapx).
make modules— list the modules the Makefile discovered. Run this after adding a module to confirm it was picked up.make all— runstidy,fmt,vet,lintfor all modules.make test— runs the full gate for ALL modules.make test TAGS=integrationadditionally runs the testcontainers-backed integration tests (needs a Docker daemon).make test-<module>— runs tidy, fmt, vet, lint, thengo test ./... -race -coverprofile coverage.outfor a single module. Examples:make test-obsx,make test-logger:zapx.make tidy/make tidy-<module>—go mod tidyfor all or one module.make lint/make lint-<module>—golangci-lint(pinned, viago run) with the root.golangci.yml.make coverage— merge per-module coverage profiles into the rootcoverage.outfor SonarCloud.make generate-proto—buf generateandbuf lint.make proto-breaking—buf breakingagainstmain. Required on every PR touchingproto.make release-<module> VER=x.y.z— tags and pushes<module>/vx.y.z. Verifies the module exists and the tree is clean before tagging.
Targets fan out via $(MAKE) rather than a shell loop, so make -j8 test parallelises across modules.
To run a single test function, cd into the module directory and run go test ./... -run TestName -v.
Only the proto module has codegen. buf.yaml and buf.gen.yaml live at the repo root and point at proto/. After changing any .proto file:
make generate-protoGenerated files (never hand-edit):
proto/**/*.pb.goproto/**/*_grpc.pb.go
proto has the highest fan-in in the repo. Protobuf changes must be additive only: add fields and messages, never renumber or reuse field numbers, never rename or remove existing fields, never change a field's type. make proto-breaking enforces this and must pass before merge. Keep each option go_package at github.com/duynhlab/pkg/proto/<svc>/v1 — it is baked into the generated descriptors and is part of the module's import path.
- Standard
gofmt. All exported names need doc comments. Match the style of the module you're editing. - Naming. Module directories are domain nouns. Do not create
common,utils,shared,core,helpers, orinternaltop-level modules — they have no admission criteria and become dependency magnets. If you cannot name a module after what it does, it should not be a module yet. - Functional options. Constructors take
New(ctx, required..., opts ...Option)(seedbx.NewPool,temporalx.NewWorker). Adding an option is additive; adding a field to an exported config struct is not always. - Logging. Library modules accept a
*zap.Logger(or nothing);logger/zapxproduces one. Never construct a logger inside a library function and never log to a package-level default. Never log secrets, tokens, or bearer headers. - Configuration. Read standard environment variables rather than inventing names.
obsx.ConfigFromEnvusesOTEL_SERVICE_NAME(withSERVICE_NAMEfallback),OTEL_COLLECTOR_ENDPOINT,OTEL_SAMPLE_RATE,OTEL_METRICS_ENABLED,OTEL_LOGS_ENABLED,OTEL_RESOURCE_ATTRIBUTES,TRACING_ENABLED,PROFILING_ENABLED,PYROSCOPE_ENDPOINT;temporalxusesTEMPORAL_DEPLOYMENT_NAME/TEMPORAL_WORKER_BUILD_ID— Temporal's own names, not ours. It previously read an inventedTEMPORAL_WORKER_DEPLOYMENT_NAME, which this rule should have caught: the Worker Controller never sets that name, so a worker reading it saw half a configuration. Do not hardcode samplers or endpoints in Go. - Semantic conventions. The OTel
semconvversion is pinned where imported (currentlyobsx). Bumping it renames attributes and breaks existing dashboards and alert rules — treat it as a coordinated change, not a routine dependency update. - Shutdown. Anything that buffers telemetry or holds connections returns a shutdown function.
obsx.SetupObservabilityreturns one; failing to call it drops the final batch of spans when a pod receives SIGTERM. - Cross-module test helpers. If a test in module A needs a helper from module B, duplicate the small helper. Do not add a production
requireon another module to satisfy a test.
- Tests use standard
go test ./... -race. The Makefile orchestrates per-module. obsxandgrpcxtests use the OTel SDK's in-memory exporters (tracetest.NewInMemoryExporter,sdkmetricreaders) to assert on emitted telemetry. Do not assert against a live collector.grpcxaccess-log tests assert log↔trace correlation: a request with an active span must produce a log entry carrying that span's trace ID. This is the test that catches broken context propagation, which is otherwise invisible until production.dbxandidempotencyintegration tests usetestcontainers-gowith Postgres behind theintegrationbuild tag — plainmake testskips them;make test TAGS=integration(or CI) runs them and needs a Docker daemon.temporalxtests do not require a running Temporal server.authmwfailures are silent in tests that use a permissive stub. When adding a claims field, add a negative test asserting rejection, not only a positive test asserting acceptance.- Match the module's existing test framework (stdlib
testing, table-driven). Do not introduce a new assertion library.
- No top-level
go.mod. From the repo root,go build ./...andgo test ./...fail with "go.mod file not found" — nothing at the root builds or tests the 13 modules. Always work within a module directory or usemake test-<module>. - Module versioning is independent. Changing
logger/zapxdoes not bumphttpx. Tag each changed module separately at release time. - Tag order matters once modules depend on each other. Tag dependencies before dependents (Layer 0 → 1 → 2), otherwise a
requireline points at a tag that does not exist yet and externalgo getfails even though local builds pass. - A pushed tag cannot be fixed. The Go module proxy caches immediately. A wrong
obsx/v0.36.0cannot be corrected — you must burn the version and publishv0.36.1.make release-<module>checks the module exists and the tree is clean, but it cannot check that the content is right. - Adding a new exported symbol is a cross-repo contract change. All platform services depend on these modules. Renaming, removing, or changing the signature of any exported type or function breaks downstream consumers even if this repo's tests pass.
- Adding a new module requires:
go mod init github.com/duynhlab/pkg/<name>, adding it to the Repository layout and Dependency rules sections above, and adding it toREADME.md. The Makefile discovers it automatically — confirm withmake modules. - The Makefile computes
MODULESdynamically by scanning forgo.modfiles, bounded by-maxdepth 4. A module nested deeper than that bound disappears from every target with no error. Runmake modulesafter adding one and confirm the count. - Colon encoding in make targets.
logger/zapxis targeted asmake test-logger:zapx. The Makefile translates:back to/internally. flagxis startup-time by design. It reads and validates env vars once, at process start, and fails fast on invalid values. Do not use it for per-request or runtime-mutable flags — that is a different tool.logger/zapxandobsxversion together in practice. Every service's logging middleware pairszapx.Newwithobsx.ZapCore; when either side's contract moves, tag both and update services in one PR.- Naming is inconsistent by history. Some modules carry an
xsuffix (httpx,grpcx,dbx,obsx,flagx,migratex,temporalx,logger/zapx) and some do not (proto,authmw,idempotency). Do not rename existing modules — the import path is a published contract. New modules follow thexsuffix convention.