Summary
Move the userService.getOrSyncByKeycloakId(jwt) call out of individual resolvers and into a GraphQL context factory (or NestJS interceptor) that runs once per authenticated request. Resolvers then read the already-synced user from context.dbUser.
Why
Every authenticated resolver today repeats the pattern:
async myHouseholds(@CurrentUser() jwt: JwtPayload): Promise<Household[]> {
const user = await this.userService.getOrSyncByKeycloakId(jwt);
return this.householdService.listForUser(user.id);
}
Today: 2 resolvers × 1 call each = fine.
When the app grows, every future feature adds resolvers that repeat this line. The DB upsert (INSERT ... ON CONFLICT DO UPDATE) is idempotent but not free — every mutation+query for an authenticated user pays for it. Worse, the copy-paste pattern diffuses the auth-sync assumption across the resolver layer; if we ever need to add a side effect around the first-per-request sync (cache warming, audit logging, feature-flag evaluation), every resolver has to be touched.
The design thinking was done during Story A review rounds (Opus round-3, round-4): sync once per request in the GraphQL context, resolvers read context.dbUser directly. Scoping the work here so we don't lose the design.
Scope
- Apollo GraphQL context factory (or a NestJS
ExecutionContext interceptor at the GraphQL layer) that:
- Reads
JwtPayload from the request (same mechanism as @CurrentUser() today)
- Calls
userService.getOrSyncByKeycloakId(jwt) once per request
- Attaches the resolved DB user to the context:
{ dbUser: User }
- Handles the unauthenticated path cleanly (public resolvers like
@Public() skip the sync)
- Update existing resolvers (2 today:
UserResolver.me, HouseholdResolver.myHouseholds, HouseholdResolver.createHousehold) to read from context.dbUser instead of calling the service
@CurrentDbUser() decorator as a typed accessor (parallel to existing @CurrentUser() for JWT access)
Trigger
Land this before the 3rd authenticated resolver is added. Once 3+ resolvers copy the pattern, refactoring gets more intrusive + the design decisions become noise to re-litigate.
Non-goals
- Caching user data across requests (that's a different concern; the existing Apollo
onConflictDoUpdate upsert is the correct per-request pattern).
- Unifying JWT verification (already handled by
JwtAuthGuard).
- Changing the public-route convention (
@Public() stays as-is).
Context
Flagged by Opus code-reviewer round-3 (confidence Minor→Important — "every future feature will repeat this lookup") and reinforced in round-4 when the two resolvers shipped identical boilerplate. Not ship-blocking for Story A; file here so the next feature PR doesn't add a 3rd copy before we refactor.
References
apps/hearthly-api/src/modules/user/resolvers/user.resolver.ts
apps/hearthly-api/src/modules/household/resolvers/household.resolver.ts
apps/hearthly-api/src/modules/user/user.service.ts (getOrSyncByKeycloakId)
apps/hearthly-api/src/modules/auth/decorators/current-user.decorator.ts (existing decorator to mirror)
Shipped from Story A (#113) review rounds.
Summary
Move the
userService.getOrSyncByKeycloakId(jwt)call out of individual resolvers and into a GraphQL context factory (or NestJS interceptor) that runs once per authenticated request. Resolvers then read the already-synced user fromcontext.dbUser.Why
Every authenticated resolver today repeats the pattern:
Today: 2 resolvers × 1 call each = fine.
When the app grows, every future feature adds resolvers that repeat this line. The DB upsert (
INSERT ... ON CONFLICT DO UPDATE) is idempotent but not free — every mutation+query for an authenticated user pays for it. Worse, the copy-paste pattern diffuses the auth-sync assumption across the resolver layer; if we ever need to add a side effect around the first-per-request sync (cache warming, audit logging, feature-flag evaluation), every resolver has to be touched.The design thinking was done during Story A review rounds (Opus round-3, round-4): sync once per request in the GraphQL context, resolvers read
context.dbUserdirectly. Scoping the work here so we don't lose the design.Scope
ExecutionContextinterceptor at the GraphQL layer) that:JwtPayloadfrom the request (same mechanism as@CurrentUser()today)userService.getOrSyncByKeycloakId(jwt)once per request{ dbUser: User }@Public()skip the sync)UserResolver.me,HouseholdResolver.myHouseholds,HouseholdResolver.createHousehold) to read fromcontext.dbUserinstead of calling the service@CurrentDbUser()decorator as a typed accessor (parallel to existing@CurrentUser()for JWT access)Trigger
Land this before the 3rd authenticated resolver is added. Once 3+ resolvers copy the pattern, refactoring gets more intrusive + the design decisions become noise to re-litigate.
Non-goals
onConflictDoUpdateupsert is the correct per-request pattern).JwtAuthGuard).@Public()stays as-is).Context
Flagged by Opus code-reviewer round-3 (confidence Minor→Important — "every future feature will repeat this lookup") and reinforced in round-4 when the two resolvers shipped identical boilerplate. Not ship-blocking for Story A; file here so the next feature PR doesn't add a 3rd copy before we refactor.
References
apps/hearthly-api/src/modules/user/resolvers/user.resolver.tsapps/hearthly-api/src/modules/household/resolvers/household.resolver.tsapps/hearthly-api/src/modules/user/user.service.ts(getOrSyncByKeycloakId)apps/hearthly-api/src/modules/auth/decorators/current-user.decorator.ts(existing decorator to mirror)Shipped from Story A (#113) review rounds.