Skip to main content

Conventions

Read this when you add or change an application operation, a feature service or repository, or a shared domain package.

These rules apply to every operation. The layers themselves are summarized in Code ownership.

Apply Module design when shaping these boundaries: a feature operation should hide its own rules and sequencing while the application retains actor authorization and cross-feature coordination.

Applications and features​

  • Group feature operations by the business data they own, not by the page or URL that exposes them. Aggregates belong with their source data; application operations compose those results with independently owned identity or catalog data.
  • Application operations compose features. Feature services own business decisions; repositories only query and map rows.
  • Compose independent features in the application layer. Features never import or call sibling features.

Shared domain packages​

  • Put business values and pure rules used by multiple features in services/tadoku-api/domain/<concept>. Application operations and features may import these packages.
  • Put fixed business values shared across features in a domain package instead of embedding them in a write operation.
  • Shared domain packages must not import application, feature, transport, generated, infrastructure or internal packages. Keep services, repositories and provider APIs out of them.
  • Keep types, errors and validation used by only one feature in that feature's domain.go. Sharing a concept does not require moving its feature operations.
  • Technical support concerns, such as business-time control and request identity, stay under internal/.
  • Changing a layer boundary follows Import boundaries.

Feature package layout​

Group a feature's operations by responsibility, not one file per operation:

FileContains
domain.goFeature domain types, errors and shared validation. Declare the feature's domain errors together in one var block and reuse them from the service and repository.
<feature>_service.goThe service struct, its constructor and the business operations.
<feature>_service_test.goService and validation tests, always database-free.
<feature>_repository.goThe repository struct, its constructor and the queries.
<feature>_repository_test.goThe feature's only database tests, which exercise the repository directly.
<feature>_cache.go or <feature>_store.goOptional. The cache or store struct, its constructor and its provider commands; see Repositories and stores.

When one of these files grows too long, move a group of its methods or tests into a <feature>_<topic>_ file that keeps the same suffix, such as <feature>_<topic>_repository.go or <feature>_<topic>_repository_test.go. tools/ci/repopolicy selects repository and store files by that suffix.

Service orchestration is exercised through HTTP E2Es; see Testing.

Write workflows​

  • The application authorizes the actor, coordinates locks and transactions across features, and composes their results.
  • A feature service validates or normalizes its own inputs and sequences its own repository calls, including related rows.
  • Do not add feature service methods that only pass a repository call through so an application operation can assemble that feature's write.
  • When several features take part in a write, the application passes shared domain values between them without interpreting scoring or persistence details. The service that owns a business decision creates its result; the service that owns the data translates that result into its stored rows.
  • Transaction mechanics are in Transactions.

Auditing a write​

  • Call audit.Service.Record for an entry that belongs to a PostgreSQL write, inside that write's transaction.
  • An audit entry is never updated after it is written.

Routes and actor authorization​

Authorization documents the shared JWT and ban pipeline, the permissions.Checker methods and the error-to-status mapping. The following rules decide where each check belongs.

  • Register application routes through the router's Handle or HandleFunc methods during construction. Every such route inherits the shared request deadline, authentication and ban check.
  • Application operations own full actor-access checks, such as authenticated-user and administrator requirements. They call *permissions.Checker (internal/permissions) for authentication and namespace checks and *authz.Roles for administrator checks. Do not enforce administrator access with HTTP middleware.
  • The actor is the authenticated user performing an operation; audit events record it as ActorID. Read the actor's user ID with identity.ActorID, which reports no actor for a missing identity or a guest, or with identity.RequireActorID, which returns unauthorized in those cases. Do not parse the identity subject in application operations.
  • When the owner of a resource or an administrator may act on it, call the application's requireOwnerOrAdmin with the owner's user ID after RequireAuthenticated. It passes when the actor is the owner and otherwise requires administrator access.
  • The checker evaluates each check in the request tenant for the verified internal/identity subject, without caching. A feature defines permissions on its own objects as the permission model describes.
  • Do not duplicate the shared ban gate in application operations or feature services. Code that invokes an application operation outside the application router must provide an equivalent baseline ban policy.
  • A feature service may inspect authorization facts only to expand behavior inside an operation the application has already authorized.
  • Facts about other users come from the feature that owns their namespace. They never authorize the actor and are read for each request, not cached.
  • A trusted callback route authenticates its own required bearer credential before request decoding and records a callback-authentication fact (internal/callbackauth) that its application operation must require. It never creates a user identity or enters the JWT and ban pipeline. A subject in its body is a target for a provider fact lookup, never the actor.

Repositories and stores​

  • A Repository reads and writes authoritative data in PostgreSQL. A Store holds non-authoritative data, such as a cache, derived data or coordination state. Everything in a Store can be discarded and rebuilt from its authoritative source: a Repository, or the provider for a provider-backed read cache.
  • "Store" is the umbrella term. Name the type after its specific role, such as Cache, Lock or Queue, and use Store as a type name only when no more specific role fits.
  • Repositories are concrete structs in features/<feature>/<feature>_repository.go. They reach PostgreSQL through postgres.Executor (services/tadoku-api/infra/postgres/) and the feature's generated sqlc package, and convert between sqlc rows and feature domain types internally. They never import a provider client.
  • Caches and stores are concrete structs in the same feature package, in <feature>_cache.go or <feature>_store.go. They reach only their own provider, such as Valkey, and never import PostgreSQL, pgx or sqlc packages. For example, the leaderboard feature's Cache in services/tadoku-api/features/leaderboard/leaderboard_cache.go holds the Valkey leaderboard caches.
  • A repository and a cache or store never call each other. The feature service composes them and owns the policy between them, such as cache selection, rebuilds and fallback to the repository. The composition root constructs the cache or store and passes it to the service, so the service never receives a provider client that it would only pass through. tools/ci/repopolicy checks these import rules by file name; see Import boundaries.
  • Primitive PostgreSQL conversions, including nullable values, UUIDs and timestamps, belong in services/tadoku-api/infra/postgres/. Repository files use those shared helpers; only feature domain and sqlc row mappings stay local. tools/ci/repopolicy rejects primitive conversion helper signatures in handwritten repository files.
  • Keep each repository method to one SQL statement. A coherent join or CTE counts as one statement and is appropriate when the data needs one database snapshot. Compose independent repository reads and writes in the feature service or application layer, inside an application-owned transaction when the operation must commit atomically. tools/ci/repopolicy enforces this in CI: handwritten repository functions issue at most one database statement and never allocate IDs or control transactions.

Dependencies​

  • Pass concrete application-owned collaborators through constructors.
  • Introduce a narrow, consumer-owned interface only for a genuine provider or layer boundary with multiple implementations, or for the port that core logic in internal/ declares for state it cannot import; see Cross-cutting concerns. Never add an interface, including a feature or repository interface, solely to inject mocks. Exercise concrete providers through their boundary tests and HTTP E2Es.
  • Provider-backed feature services may consume the API-local feature-flag evaluator and an application-owned provider client. The evaluator accepts a verified subject; provider types stay in the infrastructure package.
  • Provider-backed read caches:
    • reuse the provider client's cursor support, preserve provider order, read every cursor page and reject repeated continuation tokens;
    • make no provider request at construction. The first operation that needs the cache loads it with its request context; later operations reuse that snapshot for five minutes, and refreshes are serialized;
    • keep the last complete snapshot when a refresh fails. An initial failure returns unavailable because no complete snapshot exists yet.

Errors​

  • Application errors use internal/errx.Error, which carries a transport-neutral Kind, a message and an optional cause.
  • Create them with named constructors such as errx.NewInvalidInputError(message) or errx.NewUnavailableError(message, cause); the latter accepts nil when there is no underlying cause.
  • errx.KindOf reads the outermost typed error with errors.As. The HTTP boundary maps its kind to a status; unknown or unclassified errors return 500.
  • Ordinary %w wrapping preserves the metadata, and Unwrap preserves causes for errors.Is and errors.As. Do not encode categories in error text or wrap category sentinels.
  • Add call-site context only when it contributes useful diagnostics.

Validation​

  • Return specific validation errors: one validation condition per branch, a direct call to errx.NewInvalidInputError, and a message that identifies the invalid field or rule. Combine conditions only when they intentionally share one message.
  • Do not create per-validation sentinel errors or error types.
  • Use Validate for checks that leave request data unchanged and Normalize for canonicalization of local service or persistence input. Do not use vague names such as Prepare, and do not make validation functions return a rewritten request.

Request types​

When a feature request type has fields that only the feature sets, such as the actor ID from the verified identity or a year from business time, make those fields unexported and add getters. The feature package writes them directly (req.userID = ...); other packages read them through getters (req.UserID()), so transport and application code cannot set them. Fields that clients legitimately set, such as a contest ID or language codes, stay exported.

Business time​

  • Never call time.Now() for business time; use internal/timex.Now(ctx) with the operation's context. This applies to services, repositories and background workers. A pure domain rule takes the instant as a parameter instead.
  • A context built for follow-up work, such as a job's final transition, derives from the operation's context with context.WithoutCancel, not context.Background(), so business time and the ID source carry over.
  • Tests control business time by attaching a timex.Clock with timex.WithClock, or for code without such a context with the process-wide timex.TheWorld; see Time in tests.
  • Real time that only drives expiry, deadlines or latency, such as token-cache expiry or request duration, reads time.Now() and stays independent of business time.
  • A shared services/common package that needs controllable real time gives the struct an unexported now func() time.Time field that defaults to time.Now. Only tests in the same package override it.

Generated IDs​

  • Create a new record ID with internal/uuidx.New(ctx) and a request ID with uuidx.NewV7(ctx), not uuid.New(). Production attaches no source, so both stay random; tests attach a deterministic stream with uuidx.WithSource.
  • Security tokens, secrets and salts read crypto/rand directly.

Readability​

  • Write Go comments only for information a caller needs but cannot readily infer from the declaration and code, such as lifecycle, concurrency or error constraints. Put them directly on the declaration. Do not write package summaries, symbol-name restatements, implementation narration, or comments that track known defects; record defects as issues. Comments summarizing return values, ownership or constructor defaults add no value when the code shows them, even when attached to a declaration. Preserve //go: tool directives. Configure lint rules instead of suppressing them in comments.
  • A test fixture may keep a local comment for a concrete isolation or shared-resource risk. Start that comment group with // Test safety: and state the instruction. bazel run //tools/ci/commentpolicy checks placement and form in handwritten Go; review still judges whether a comment adds value.
  • Follow Go style for line length, wrapping, blank-line grouping and composite literals.
  • Split application and transport operations into files by functionality, and give operations descriptive names.
  • Keep constructors and resource lifecycle code visibly separate from endpoint behavior.

Changes and documentation​

  • Keep each change small and reviewable. Implement the requested operation without unrelated service identities, audience checks or authorization features. Change authentication and shared ban enforcement in separately reviewed changes.
  • Document durable conventions for the whole application: architecture, compatibility and testing rules that apply to all operations. Do not document individual endpoint implementations, enumerate their coverage, or add endpoint-status sections to general documentation. Use generic examples.
  • Keep deferred cleanup notes as checkboxes in services/tadoku-api/MIGRATION_LOG.md.