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
internalpackages. 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:
| File | Contains |
|---|---|
domain.go | Feature 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.go | The service struct, its constructor and the business operations. |
<feature>_service_test.go | Service and validation tests, always database-free. |
<feature>_repository.go | The repository struct, its constructor and the queries. |
<feature>_repository_test.go | The feature's only database tests, which exercise the repository directly. |
<feature>_cache.go or <feature>_store.go | Optional. 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.Recordfor 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
HandleorHandleFuncmethods 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.Rolesfor 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 withidentity.ActorID, which reports no actor for a missing identity or a guest, or withidentity.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
requireOwnerOrAdminwith the owner's user ID afterRequireAuthenticated. 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/identitysubject, 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,LockorQueue, and useStoreas a type name only when no more specific role fits. - Repositories are concrete structs in
features/<feature>/<feature>_repository.go. They reach PostgreSQL throughpostgres.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.goor<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'sCacheinservices/tadoku-api/features/leaderboard/leaderboard_cache.goholds 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/repopolicychecks 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/repopolicyrejects 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/repopolicyenforces 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-neutralKind, a message and an optional cause. - Create them with named constructors such as
errx.NewInvalidInputError(message)orerrx.NewUnavailableError(message, cause); the latter acceptsnilwhen there is no underlying cause. errx.KindOfreads the outermost typed error witherrors.As. The HTTP boundary maps its kind to a status; unknown or unclassified errors return 500.- Ordinary
%wwrapping preserves the metadata, andUnwrappreserves causes forerrors.Isanderrors.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
Validatefor checks that leave request data unchanged andNormalizefor canonicalization of local service or persistence input. Do not use vague names such asPrepare, 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; useinternal/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, notcontext.Background(), so business time and the ID source carry over. - Tests control business time by attaching a
timex.Clockwithtimex.WithClock, or for code without such a context with the process-widetimex.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/commonpackage that needs controllable real time gives the struct an unexportednow func() time.Timefield that defaults totime.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 withuuidx.NewV7(ctx), notuuid.New(). Production attaches no source, so both stay random; tests attach a deterministic stream withuuidx.WithSource. - Security tokens, secrets and salts read
crypto/randdirectly.
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/commentpolicychecks 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.