A hands-on Apollo Federation 2 project that simulates the Daily Log feature of a construction-management platform (modeled on Procore). One supergraph, many subgraphs, each owning its own data. The goal is learning by doing: entities, @key, @external / @requires, header propagation, cross-subgraph auth, query plans, and the N+1 problem as it actually appears in a federated graph.
Status: complete (October 2026). Levels 0–6 are done: nine subgraphs compose into one supergraph, and a single operation hydrates the Daily Log view with per-project authorization. Level 7 (GraphOS) and the stretch goals were not attempted; they're described below for whoever picks this up. Known gaps lists what's deliberately unfinished.
Each subgraph owns its tables and contributes fields to a shared set of entities. Entities are joined on their @key (id everywhere); no subgraph reads another's database.
| Subgraph | Owns (resolves from its own rows) | Contributes to entities owned elsewhere | Entry points (root fields) |
|---|---|---|---|
users |
User (name, email, trade) |
– | users, user(id) |
auth |
– (no database) | – | me: the caller, from the Authorization header |
daily-logs |
DailyLog, Project |
DailyLog.createdBy → User |
dailyLogsBy, dailyLogsForCurrentUser; create/update/delete a log |
notes |
Note |
DailyLog.notes |
noteById, notesForDailyLog |
photos |
Photo |
DailyLog.photos, Project.photos, User.photos |
projectPhotos, dailyLogPhotos, userPhotos; photo mutations |
comments |
Comment |
Photo.comments, User.comments |
commentsForPhoto, commentsForUser |
permissions |
Membership, Role |
User.memberships, Project.memberships, Project.myRole |
userAllowedForProject, createOrUpdateMembership |
weather |
WeatherObservation |
DailyLog.weatherObservations, DailyLog.forecast (@requires) |
none: reached only through a DailyLog |
crews |
CrewMember (who was on site that day, and as what) |
DailyLog.crew, User.crewDays |
none: reached only through a DailyLog or User |
The rule that decides who builds each connection: whichever subgraph holds the join column can traverse it, in both directions. Notes holds notes.daily_log_id, so notes adds DailyLog.notes (and Note.dailyLog); daily-logs knows nothing about notes. See docs/service-ownership.md.
queries/FullView.graphql is the target view in one operation: logs → creator, project, notes and authors, photos → uploader, comments and authors. With loaders in place it runs in 9 SQL statements for 7 logs, 145 photos and 421 comments.
- The router (
rover dev, :4000) plans the operation across subgraphs and forwards the client'sAuthorizationheader to each one (router/router.yaml). - Each subgraph builds a context with
createContext(shared/context.ts): it authenticates the caller (anonymous requests are rejected), attaches loaders, and attachesauthzCheck. - Permissions are lazy.
getPermissions()fetches the caller's per-project permissions on first use, at most once per request: over REST from/internal/accessin most subgraphs, straight from the database insidepermissions. A request that never checks makes zero lookups. - Resolvers authorize with one line,
await authzCheck(projectId, ['read:notes']), or let a permission-aware loader do it: by-id loaders filter rows to projects the caller can read, so every__resolveReference(each subgraph's_entitiesentry point) is covered too. Single objects come backnull; lists are filtered. - Loaders batch by key.
groupedLoader(getTable, 'dailyLogId')(shared/loaders.ts) turns one-to-many lookups into onewhere … in (…)per request; by-id loaders do the same for one-to-one.
- The scope is the project. A user's role (
SUPERINTENDENT,PROJECT_MANAGER,VIEWER) is per project, and role → permission names (read:notes,delete:daily-logs, …) live in thepermissionstable, also per project. - Every project-scoped row carries
project_id, copied at creation (notes, comments, photos, weather observations, crew members), so a subgraph can authorize any entry point without asking another service. - Weather reads are not checked by weather. It has no root queries, so it's only reachable through a
DailyLogthat daily-logs already authorized. Itsproject_idexists for writes. - The full design, including the alternatives (login claims, router coprocessor, BFF) and why this lab checks in the subgraphs, is in
docs/level-4.md.
- Some list entry points aren't authorized yet:
notesForDailyLog,projectPhotos(and the other photo list queries) andcommentsForPhotostill return another project's data to a caller without access. - Anonymous requests fail with a
500, not a cleanUNAUTHENTICATED: the error is thrown in the context function. Rejecting them at the router (a Rhai script) would fix it. - A crew member can't read the logs they worked on. Crew have no permissions by design, so
me { crewDays { date } }returns entries withnulldates, and the list length still reveals how many days they worked. docs/level-2.md(before/after SQL counts) was never written. The numbers: 17 statements before the last N+1 fix, 9 after, against roughly 570 estimated with no loaders.
I write all the GraphQL: every schema.graphql, every src/resolvers.ts (including __resolveReference), dataloaders, permission logic, and the operations in queries/. Everything else (workspaces, bootstraps, data access, seeds, router config, logging) is scaffolding generated with Claude Code, whose instructions live in CLAUDE.md.
A superintendent ("supe") opens a single Daily Log for one of their projects on a tablet at the jobsite. One GraphQL operation should hydrate the whole view:
- the log itself (date, status, project, who created it)
- observed weather for that day
- the project's crew
- notes written on the log
- photos taken that day, each with its comments and comment authors
- Fetch a single Daily Log in one operation to hydrate the view.
- Support low/no-connectivity, high-latency environments.
- Respect permissions: a supe sees their projects and only their projects.
- End-of-day burstiness (everyone submits logs ~4–6pm local)
- Steady all-day traffic otherwise
- Author: the supe; has project permissions.
- Crew: users just added to a project's crew; they exist as users but have no project permissions.
- Comment author: any user who commented on a photo; each has their own permissions (some may have lost access since commenting).
┌────────────────┐
client ──────▶ │ Apollo Router │ :4000 (the BFF / gateway)
└───────┬────────┘
┌──────────┬────────┼─────────┬──────────┬──────────┬──────────┬──────────┐
▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼
users auth permissions daily-logs weather crews notes photos comments
:4001 :4002 :4003 :4004 :4005 :4006 :4007 :4008 :4009
One subgraph per domain. Each subgraph owns its own SQLite file. No subgraph reads another's database or imports another subgraph's code. Enforce this with a lint rule: subgraphs/* may import from shared/ but never from another subgraphs/*.
- Node 24 + TypeScript (strict), npm workspaces monorepo. Node runs the
.tsfiles directly (type stripping), so there's no build step. @apollo/server+@apollo/subgraphper subgraph- Apollo Router via
rover devlocally;rover supergraph compose+routerbinary as the "production-like" path - SQLite per subgraph, through knex on the
better-sqlite3driver (camelCase in code, snake_case in the database) dataloaderfor per-request batching (my loaders, plus a sharedgroupedLoader)concurrentlyto run everything with one command- ESLint (type-aware) + Prettier
- No GraphQL codegen
router/
router.yaml # header propagation, query-plan exposure
supergraph.yaml # generated per LEVEL (gitignored)
subgraphs/
users/ auth/ permissions/ daily-logs/ weather/ crews/ notes/ photos/ comments/
schema.graphql # mine
src/resolvers.ts # mine
src/context.ts # mine: builds the request context with createContext
src/loaders.ts # mine: per-request DataLoaders
src/index.ts # scaffolding: server bootstrap only
src/data.ts # scaffolding: plain query functions, no batching or caching
db/schema.sql # scaffolding
db/seed.ts # scaffolding: writes this subgraph's slice of scripts/world.ts
shared/
db.ts # knex connection; logs every SQL statement; camelCase <-> snake_case
context.ts # createContext: authentication, lazy permissions, authzCheck
loaders.ts # groupedLoader, projectsWithPermissions
permissions/client.ts # client for permissions' REST endpoint; authzCheck
log.ts # logs each incoming operation; for `_entities`, the representation count
resolvers.ts # Resolvers type for resolver maps
ids.ts # generateId('log') for rows created at runtime
queries/ # mine: named operations, one per .graphql file
scripts/
subgraphs.ts # the list of subgraphs: name, port, Level
world.ts # the deterministic seed dataset
op.ts # npm run op: run a saved operation, show plan, response and logs
seed-all.ts dev.ts write-supergraph-config.ts compose.sh
docs/
log-parsing.md # grep/awk recipes for the logs
service-ownership.md # working as the owner of one subgraph: contracts, roles, habits
level-4.md # the authorization design and its alternatives
Every subgraph logs to stdout, prefixed with its name:
- each incoming operation, and specifically each
_entitiescall with how many representations it received - each SQL statement executed
This is how I'll see the query plan fan out and see N+1 happen before I fix it. The logging is wired in at the server/plugin level, so it works no matter what's in the resolvers. The router also exposes query plans: send Apollo-Expose-Query-Plan: true (or dry-run) with any operation. See docs/log-parsing.md for how to read all of it.
What each subgraph owns, as tables only (as built; project_id columns are the authorization scope). The GraphQL shape is in each schema.graphql.
| Subgraph | Port | Scope | Tables |
|---|---|---|---|
users |
4001 | global | users(id, name, email, trade) |
auth |
4002 | current user | none; reads userId from the propagated Authorization header (fake token is fine: Bearer user-1) |
permissions |
4003 | project | project_memberships(user_id, project_id, role); permissions(role, project_id, permission_name) |
daily-logs |
4004 | project | projects(id, name, latitude, longitude), daily_logs(id, project_id, date, status, created_by) |
weather |
4005 | daily log | weather_observations(id, project_id, daily_log_id, observed_at, sky, temp_f, precipitation, wind_mph, is_delay) |
crews |
4006 | daily log | crew_members(id, project_id, daily_log_id, user_id, role): who was on site that day |
notes |
4007 | daily log | notes(id, project_id, daily_log_id, author_id, body, created_at) |
photos |
4008 | project (optionally attached to a log) | photos(id, project_id, daily_log_id NULL, url, caption, taken_at, uploaded_by) |
comments |
4009 | photo | comments(id, project_id, photo_id, author_id, body, created_at) |
Notes:
permissionsalso has a small internal REST endpoint outside the graph:GET /internal/access?userId=&projectId=→{ allowed, role }, plus a batch variant that takes many pairs. How subgraphs use it is part of Level 4.weatherhas a deterministicsrc/fakeForecast.ts(forecastFor(latitude, longitude, date): a location, not a project) and is artificially slow:WEATHER_DELAY_MS, default 800ms, so plan shape shows up as latency.crewsmodels a daily log's crew (who was on site that day), not standing named crews: on real sites crews change day to day.photosare jobsite progress photos, not profile pictures, with placeholder image URLs.daily-logsholdsprojectsfor now because the system design doesn't name a Projects service.
One operation returns, for a given Daily Log: the current user; the log's date, status, and creator; the project name, my role on it, and its crew; weather observations plus a forecast summary; notes with authors; photos with uploaders, each with comments and comment authors.
Deterministic (fixed IDs; any randomness uses a fixed seed). Minimum:
- 2 projects in different locations
- 22 users: 2 supes (one per project), 1 PM on both projects, 1 viewer, 16 crew members (8 per project, mixed trades), and a couple of users with no memberships at all
- 7 daily logs per project (one week)
- per log: 1–3 weather observations, 2–5 notes, 15–25 photos
- per photo: 0–6 comments by a mix of users, including at least one comment author who has since lost project access
- crew members are users with no
project_membershipsrows (the "just added" persona); each daily log's crew is the project's foreman plus 3–6 others
npm run seed wipes and reseeds every subgraph's SQLite file. Print a short summary afterward: user IDs with their roles per project, so I know which Bearer token to use for which persona.
I worked through these in order (✅ = done). LEVEL=n npm run dev runs Levels 0 through n. A new Level's subgraphs start as stubs, and composition fails until I've written their schemas.
- ✅ Level 0:
users+daily-logs. Composition,@key,__resolveReference, reading the query plan. - ✅ Level 1:
notes. Extending an entity another subgraph owns; watching_entitiesin the logs. - ✅ Level 2:
photos+comments. Run the view naively, count SQL statements, then fix N+1 with dataloaders. I'll write up before/after counts indocs/level-2.md. - ✅ Level 3:
auth. Header propagation (router config is yours),Query.me. - ✅ Level 4:
permissions. Where authorization belongs in a federated graph. I'll writedocs/level-4.mdcomparing approaches. - ✅ Level 5:
weather.@external/@requires, and the extra sequential plan step it causes. - ✅ Level 6:
crews. Repetition to confirm the patterns stuck. - ⬜ Level 7 (not started): GraphOS. Move from local composition to the GraphOS registry, and build the router-side option from
docs/level-4.mdfor real.- Publish each subgraph with
rover subgraph publishand compose from the registry instead of local files; gate schema changes withrover subgraph check. - Use Studio the way a subgraph owner at a real company would: explore the published supergraph, field usage, and operation traces (see
docs/service-ownership.md). - Add a coprocessor (available on the free tier, rate limited) that fetches the caller's memberships from permissions once per request and adds them as a header for every subgraph. Compare it with the per-subgraph lookups from Level 4: calls to permissions per request, latency, and what each subgraph still has to enforce. I'll add the comparison to
docs/level-4.md. - Secrets (
APOLLO_KEY, graph ref) live in.env, never committed. The lab stops being offline-only at this Level.
- Publish each subgraph with
@deferthe slow weather block (high-latency jobsite tablets)- Persisted queries
- Write-up of a client-side offline story (cached log, queued mutations)
k6load test: steady traffic plus an end-of-day burst of note writes (the operations it sends come fromqueries/)- Split one subgraph into its own repo (composition from the GraphOS registry is covered in Level 7)
nvm use # Node 24 (from .nvmrc)
npm install
npm run rover:install # once per machine: rover's router + composition plugins
npm run seed # wipe and reseed every database; prints which Bearer token is which persona
npm run seed -- notes # reseed only the named subgraph(s); the rest keep their data
npm run dev # subgraphs for LEVEL=0 + rover dev (router on :4000)
LEVEL=2 npm run dev # Levels 0 through 2
npm run dev:log # same as dev, also writes logs/dev.logLocal rover composition requires accepting Apollo's ELv2 license: APOLLO_ELV2_LICENSE=accept in .env.
Manual testing in Apollo Sandbox at http://localhost:4000 with:
{
"Authorization": "Bearer user-1",
"Apollo-Expose-Query-Plan": "true"
}Run a saved operation from queries/ and see its plan, response, and the SQL/_entities lines it caused:
npm run op -- queries/dailyLogsBy.graphql --as user-2 --vars '{"filters": {"status": "DRAFT"}}'Variables default to a sibling dailyLogsBy.variables.json; --as none sends no Authorization header. The log section needs npm run dev:log running.
Each subgraph is independently startable: npm run dev -w subgraphs/notes.
The production-like path: LEVEL=n npm run compose composes router/supergraph.graphql with rover (LEVEL defaults to 6); LEVEL=n npm run compose -- run also starts the standalone router against it.
Before committing: npm run check (typecheck, lint, format check).