Development Conventions
Repo-owned conventions for lfx-v2-newsletter-service. This service owns project-scoped newsletter drafts, sent-state persistence, recipient resolution, email dispatch (per-recipient fan-out to email-service over NATS), unsubscribe opt-outs, local open tracking, and newsletter analytics. It is not a Goa service and does not emit indexer or FGA messages.
Use this skill alongside:
lfx-skills:lfx for cross-repo topology, owner lookup, and missing local checkouts.
lfx-skills:lfx-platform-architecture for platform composition, service classes, query-service/FGA/indexer flows, and deployment handoffs.
docs/newsletter-service-contract.md for this repo's HTTP API and public DTO contract.
docs/recipient-resolution.md for committee-service member lookup over NATS and the email-service fan-out.
docs/service-helm-chart.md for service-local chart values, database modes, Gateway/Heimdall wiring, and deployment surfaces.
Repo Layout
cmd/newsletter-api/ entry point, runtime config, dependency wiring
internal/domain/model/ newsletter aggregate, open event, unsubscribe, analytics DTOs
internal/domain/port/ repository and upstream interfaces (committee, project, email, user metadata)
internal/service/ draft CRUD, validation, send orchestration + fan-out, unsubscribe, analytics, email chrome render
internal/repository/ Postgres/Bun implementation and pagination cursor codec
internal/schema/ embedded idempotent schema.sql, advisory-lock bootstrap
internal/handler/ stdlib net/http routes, auth middleware, JSON/error mapping
internal/infrastructure/nats/ NATS request/reply clients (committee, project, email dispatcher, user metadata) and subject constants
internal/infrastructure/upstream/ retired HTTP client package (placeholder only)
internal/infrastructure/observability/ slog and OpenTelemetry setup
pkg/api/ public request/response DTOs
pkg/errors/ typed client/server error helpers
charts/lfx-v2-newsletter-service/ service-local Helm chart
docs/ service-owned contract and deployment docs
Match the existing package boundaries before adding a new abstraction.
Public API And DTOs
pkg/api/newsletter.go is the public DTO contract consumed by Self Serve and other callers.
- Route registration lives in
internal/handler/http.go. Keep transport concerns in handlers and business rules in internal/service/.
decodeJSON rejects unknown fields and caps request bodies at 1 MiB. Preserve this for user-supplied newsletter HTML.
- Errors map at
internal/handler/http.go::classifyError. Domain sentinel errors live in internal/domain/errors.go.
- Update
docs/newsletter-service-contract.md in the same PR as any route, payload, status code, ETag, or error-shape change.
Drafts And Send State
- Draft lifecycle is
draft -> sent; sent drafts cannot be modified, deleted, or sent again.
- Update and send use optimistic locking through strong ETags and
If-Match.
SendNewsletter mints the email-service group_id (uuid.NewString()) and persists it on the newsletter row when the draft is marked sent. The DB enforces UUID format and status='sent' => group_id IS NOT NULL.
- The send orchestrator owns email dispatch: it renders chrome, resolves the sender display name, and fans out per-recipient
lfx.email-service.send_email requests with bounded concurrency. The draft flips to sent only when at least one recipient was delivered to; a fully-failed fan-out stays a draft for retry.
SEND_FANOUT_ENABLED=false short-circuits dispatch for dev/staging shake-out. Keep docs/recipient-resolution.md and docs/newsletter-service-contract.md updated in the same PR as any send-behavior change.
Postgres And Schema
- All schema is embedded in
internal/schema/schema.sql and applied at startup by schema.Apply.
- Schema bootstrap runs in a transaction under
pg_advisory_xact_lock, with a local 60-second statement timeout.
- Repository code uses Bun over pgx/stdlib. Keep SQL ownership in
internal/repository/postgres.go unless a new repository implementation is introduced.
ListAll uses keyset pagination over (updated_at, id) and opaque base64-url page tokens. Treat tokens as service-owned and never document their internal shape as stable.
newsletter_opens stores SHA-256 recipient hashes, not raw email addresses, and deduplicates repeat opens per recipient per hour.
Recipient Resolution
- Recipient resolution calls committee-service over NATS (
lfx.committee-api.list_members), one request per committee UID, concurrently via errgroup.WithContext.
- The inbound bearer token is validated by this service but never attached to context or forwarded — outbound NATS calls carry no token (trust is enforced upstream of NATS). Keep it that way.
- The orchestrator deduplicates recipients by lowercased email, filters empty or obviously invalid addresses, and excludes the project's unsubscribed addresses.
- Subject constants live in
internal/infrastructure/nats/subjects.go; keep them in sync with the owning services. Read docs/recipient-resolution.md before changing member lookup, unsubscribe exclusion, or recipient selection.
Logging And Observability
- Use
log/slog with slog.*Context variants.
- Use
internal/infrastructure/observability.AppendCtx for request-scoped fields when needed.
- Never log bearer tokens, JWTs, raw Authorization headers, database passwords, newsletter HTML bodies, or recipient lists.
- OpenTelemetry setup lives in
internal/infrastructure/observability/; chart values map to OTEL_* env vars.
- Keep request logging in middleware, not duplicated in service methods.
Errors
- Domain sentinels:
ErrNotFound, ErrVersionMismatch, ErrInvalidRequest, ErrAlreadySent.
- Transport mapping:
- not found -> 404
- version mismatch -> 412
- already sent -> 409
- invalid request -> 400
- upstream dependency failure -> 503
- unexpected -> 500 with a generic client message
- Wrap upstream and database errors with
%w. Do not return raw database errors or upstream NATS reply bodies directly to clients. NATS upstream clients return typed pkgerrors.* wrappers matched with errors.As in classifyError.
Tests
- Prefer table-driven tests and co-locate
*_test.go with the code under test.
- Depend on
internal/domain/port interfaces for repositories and upstream clients.
- Use fake repositories or fake
CommitteeClient/EmailDispatcher implementations for service tests. Do not require a live NATS server for unit tests.
- Handler tests should exercise
http.Handler paths and assert status, ETag, and JSON bodies where the route contract changes.
- Run
make test before handoff; it enables the race detector.
Formatting, Linting, License
make fmt runs go fmt ./... and gofmt -s.
make lint installs/runs the repo-pinned golangci-lint version when needed.
make check runs format, lint, license check, and go vet ./....
Every new .go file starts with:
// Copyright The Linux Foundation and each contributor to LFX.
// SPDX-License-Identifier: MIT
Every new .md file in this repo starts with the HTML-comment license header.
Document exported Go symbols when the linter requires it. Add implementation comments only where they clarify non-obvious behavior.
References
references/go-http-postgres-conventions.md: package boundaries, handler shape, Postgres conventions, upstream calls, tests, and review checklist for this repo.
Chart Work
- Service-local chart truth lives under
charts/lfx-v2-newsletter-service/ and docs/service-helm-chart.md.
- Shared chart conventions live in
lfx-v2-helm/docs/service-chart-patterns.md.
- Deployed values, image tags, database secret names, and environment promotion live in
lfx-v2-argocd.
openfga.enabled is currently reserved for future use. Do not add FGA/indexer contract docs unless this service starts emitting those contracts.
Boundaries
- This repo owns newsletter persistence, newsletter API behavior, and the newsletter email fan-out.
lfx-v2-committee-service owns committee-member data and the lfx.committee-api.list_members payload.
lfx-v2-project-service owns project name/slug lookup payloads.
lfx-v2-email-service owns transactional email delivery, the send_email payload, and engagement tracking records.
lfx-v2-auth-service owns the user-metadata payload used for sender display names.
- The LFX UI consumes this service's HTTP API; it no longer talks to email-service directly for newsletters.
- If a change requires a peer repo, use
lfx-skills:lfx to locate or clone it and read that repo's CLAUDE.md plus contract docs.
1---2name: newsletter-service-dev3description: Repo-local Go coding conventions and implementation guidance for lfx-v2-newsletter-service. Auto-attaches when editing Go code, HTTP handlers, Postgres/Bun repository code, embedded schema, NATS upstream clients, recipient resolution, email fan-out, newsletter API DTOs, Makefile, Helm chart templates, or service-owned docs. Owns the newsletter HTTP API, draft/send state transitions, email dispatch, unsubscribe, local analytics/open tracking, recipient resolution, tests, formatting, linting, and license headers. Central platform composition stays in lfx-skills:lfx-platform-architecture; cross-repo routing stays in lfx-skills:lfx.4---56<!-- Copyright The Linux Foundation and each contributor to LFX. -->7<!-- SPDX-License-Identifier: MIT -->89# Development Conventions1011Repo-owned conventions for `lfx-v2-newsletter-service`. This service owns project-scoped newsletter drafts, sent-state persistence, recipient resolution, email dispatch (per-recipient fan-out to email-service over NATS), unsubscribe opt-outs, local open tracking, and newsletter analytics. It is not a Goa service and does not emit indexer or FGA messages.1213Use this skill alongside:1415- `lfx-skills:lfx` for cross-repo topology, owner lookup, and missing local checkouts.16- `lfx-skills:lfx-platform-architecture` for platform composition, service classes, query-service/FGA/indexer flows, and deployment handoffs.17- `docs/newsletter-service-contract.md` for this repo's HTTP API and public DTO contract.18- `docs/recipient-resolution.md` for committee-service member lookup over NATS and the email-service fan-out.19- `docs/service-helm-chart.md` for service-local chart values, database modes, Gateway/Heimdall wiring, and deployment surfaces.2021## Repo Layout2223```text24cmd/newsletter-api/ entry point, runtime config, dependency wiring25internal/domain/model/ newsletter aggregate, open event, unsubscribe, analytics DTOs26internal/domain/port/ repository and upstream interfaces (committee, project, email, user metadata)27internal/service/ draft CRUD, validation, send orchestration + fan-out, unsubscribe, analytics, email chrome render28internal/repository/ Postgres/Bun implementation and pagination cursor codec29internal/schema/ embedded idempotent schema.sql, advisory-lock bootstrap30internal/handler/ stdlib net/http routes, auth middleware, JSON/error mapping31internal/infrastructure/nats/ NATS request/reply clients (committee, project, email dispatcher, user metadata) and subject constants32internal/infrastructure/upstream/ retired HTTP client package (placeholder only)33internal/infrastructure/observability/ slog and OpenTelemetry setup34pkg/api/ public request/response DTOs35pkg/errors/ typed client/server error helpers36charts/lfx-v2-newsletter-service/ service-local Helm chart37docs/ service-owned contract and deployment docs38```3940Match the existing package boundaries before adding a new abstraction.4142## Public API And DTOs4344- `pkg/api/newsletter.go` is the public DTO contract consumed by Self Serve and other callers.45- Route registration lives in `internal/handler/http.go`. Keep transport concerns in handlers and business rules in `internal/service/`.46- `decodeJSON` rejects unknown fields and caps request bodies at 1 MiB. Preserve this for user-supplied newsletter HTML.47- Errors map at `internal/handler/http.go::classifyError`. Domain sentinel errors live in `internal/domain/errors.go`.48- Update `docs/newsletter-service-contract.md` in the same PR as any route, payload, status code, ETag, or error-shape change.4950## Drafts And Send State5152- Draft lifecycle is `draft -> sent`; sent drafts cannot be modified, deleted, or sent again.53- Update and send use optimistic locking through strong ETags and `If-Match`.54- `SendNewsletter` mints the email-service `group_id` (`uuid.NewString()`) and persists it on the newsletter row when the draft is marked sent. The DB enforces UUID format and `status='sent' => group_id IS NOT NULL`.55- The send orchestrator owns email dispatch: it renders chrome, resolves the sender display name, and fans out per-recipient `lfx.email-service.send_email` requests with bounded concurrency. The draft flips to sent only when at least one recipient was delivered to; a fully-failed fan-out stays a draft for retry.56- `SEND_FANOUT_ENABLED=false` short-circuits dispatch for dev/staging shake-out. Keep `docs/recipient-resolution.md` and `docs/newsletter-service-contract.md` updated in the same PR as any send-behavior change.5758## Postgres And Schema5960- All schema is embedded in `internal/schema/schema.sql` and applied at startup by `schema.Apply`.61- Schema bootstrap runs in a transaction under `pg_advisory_xact_lock`, with a local 60-second statement timeout.62- Repository code uses Bun over pgx/stdlib. Keep SQL ownership in `internal/repository/postgres.go` unless a new repository implementation is introduced.63- `ListAll` uses keyset pagination over `(updated_at, id)` and opaque base64-url page tokens. Treat tokens as service-owned and never document their internal shape as stable.64- `newsletter_opens` stores SHA-256 recipient hashes, not raw email addresses, and deduplicates repeat opens per recipient per hour.6566## Recipient Resolution6768- Recipient resolution calls committee-service over NATS (`lfx.committee-api.list_members`), one request per committee UID, concurrently via `errgroup.WithContext`.69- The inbound bearer token is validated by this service but never attached to context or forwarded — outbound NATS calls carry no token (trust is enforced upstream of NATS). Keep it that way.70- The orchestrator deduplicates recipients by lowercased email, filters empty or obviously invalid addresses, and excludes the project's unsubscribed addresses.71- Subject constants live in `internal/infrastructure/nats/subjects.go`; keep them in sync with the owning services. Read `docs/recipient-resolution.md` before changing member lookup, unsubscribe exclusion, or recipient selection.7273## Logging And Observability7475- Use `log/slog` with `slog.*Context` variants.76- Use `internal/infrastructure/observability.AppendCtx` for request-scoped fields when needed.77- Never log bearer tokens, JWTs, raw Authorization headers, database passwords, newsletter HTML bodies, or recipient lists.78- OpenTelemetry setup lives in `internal/infrastructure/observability/`; chart values map to `OTEL_*` env vars.79- Keep request logging in middleware, not duplicated in service methods.8081## Errors8283- Domain sentinels: `ErrNotFound`, `ErrVersionMismatch`, `ErrInvalidRequest`, `ErrAlreadySent`.84- Transport mapping:85 - not found -> 40486 - version mismatch -> 41287 - already sent -> 40988 - invalid request -> 40089 - upstream dependency failure -> 50390 - unexpected -> 500 with a generic client message91- Wrap upstream and database errors with `%w`. Do not return raw database errors or upstream NATS reply bodies directly to clients. NATS upstream clients return typed `pkgerrors.*` wrappers matched with `errors.As` in `classifyError`.9293## Tests9495- Prefer table-driven tests and co-locate `*_test.go` with the code under test.96- Depend on `internal/domain/port` interfaces for repositories and upstream clients.97- Use fake repositories or fake `CommitteeClient`/`EmailDispatcher` implementations for service tests. Do not require a live NATS server for unit tests.98- Handler tests should exercise `http.Handler` paths and assert status, ETag, and JSON bodies where the route contract changes.99- Run `make test` before handoff; it enables the race detector.100101## Formatting, Linting, License102103- `make fmt` runs `go fmt ./...` and `gofmt -s`.104- `make lint` installs/runs the repo-pinned golangci-lint version when needed.105- `make check` runs format, lint, license check, and `go vet ./...`.106- Every new `.go` file starts with:107108 ```go109 // Copyright The Linux Foundation and each contributor to LFX.110 // SPDX-License-Identifier: MIT111 ```112113- Every new `.md` file in this repo starts with the HTML-comment license header.114- Document exported Go symbols when the linter requires it. Add implementation comments only where they clarify non-obvious behavior.115116## References117118- `references/go-http-postgres-conventions.md`: package boundaries, handler shape, Postgres conventions, upstream calls, tests, and review checklist for this repo.119120## Chart Work121122- Service-local chart truth lives under `charts/lfx-v2-newsletter-service/` and `docs/service-helm-chart.md`.123- Shared chart conventions live in `lfx-v2-helm/docs/service-chart-patterns.md`.124- Deployed values, image tags, database secret names, and environment promotion live in `lfx-v2-argocd`.125- `openfga.enabled` is currently reserved for future use. Do not add FGA/indexer contract docs unless this service starts emitting those contracts.126127## Boundaries128129- This repo owns newsletter persistence, newsletter API behavior, and the newsletter email fan-out.130- `lfx-v2-committee-service` owns committee-member data and the `lfx.committee-api.list_members` payload.131- `lfx-v2-project-service` owns project name/slug lookup payloads.132- `lfx-v2-email-service` owns transactional email delivery, the `send_email` payload, and engagement tracking records.133- `lfx-v2-auth-service` owns the user-metadata payload used for sender display names.134- The LFX UI consumes this service's HTTP API; it no longer talks to email-service directly for newsletters.135- If a change requires a peer repo, use `lfx-skills:lfx` to locate or clone it and read that repo's `CLAUDE.md` plus contract docs.