Skip to main content

Backend Architecture

The backend follows a layered architecture. Every feature's GraphQL API is built the same way, so once you understand the pattern here you can find your way around any feature's backend code just from its file path and suffix — see each feature's "Where this lives" section for the actual files.

Request flow

GraphQL request


Resolver apps/backend/graphql/resolvers/*.resolver.js
│ (reads the auth context, calls a manager method, returns the result)

Manager apps/backend/managers/**/*.manager.js
│ (business logic: validates input, applies business rules, orchestrates)
├──▶ Validator apps/backend/validators/*.validator.js
├──▶ Access Service apps/backend/data-access-services/*.access-service.js
│ │
│ ▼
│ Model (Sequelize) apps/backend/database/models/*.js

└──▶ External Service apps/backend/services/*.service.js
(Firebase, Apple, SMS, Stripe, LiveKit, translations, push, etc.)

The layers

1. GraphQL layer (graphql/)

  • graphql/typeDefs.js + graphql/types/*.type.js — schema definitions (SDL), one file per domain
  • graphql/resolvers.js + graphql/resolvers/*.resolver.js — resolver functions. These should be thin: check the auth context if needed, call the matching manager method, return the result. Business logic does not belong here.
  • graphql/context/ — builds the per-request context. auth-helper.js resolves the JWT from the Authorization header into the authenticated user; admin-auth-helper.js does the same for admin sessions; ws-context.js does the same for WebSocket subscriptions.

Example: graphql/resolvers/user.resolver.js exposes register, login, loginWithApple, and so on — each resolver function is a few lines that hand off to AuthenticationManager.

2. Manager layer (managers/)

Business logic. One class per domain, sometimes grouped into sub-folders (e.g. managers/user-managers/). A manager:

  • Validates input, usually via a matching validators/*.validator.js
  • Reads and writes data through one or more access-services
  • Calls external services/ when needed (send an email, verify a token with Firebase, charge a card, etc.)
  • Applies the actual business rules — e.g. "reject registration if the username is already taken"

A manager should never query a Sequelize model directly — that's the access-service's job.

Example: managers/user-managers/authentication.manager.js#register calls data-access-services/user.access-service.js to check whether the username/email is already taken, validators/user.validator.js to validate the payload, hashes the password, and then persists the new user through the access-service.

3. Access Service layer (data-access-services/)

The only layer allowed to talk to the database directly. Roughly one class per model, wrapping Sequelize calls (findByPk, findOne, create, update, …) behind plain, purpose-named methods like findByUsername or create. No business logic and no validation here — just data access.

Example: data-access-services/user.access-service.js#findByUsername is essentially User.findOne({ where: { username } }).

4. Model layer (database/models/)

Sequelize model definitions — the actual database schema: fields, associations, indexes. Roughly one file per table, e.g. database/models/user.js, database/models/UserSession.js, database/models/CoinPackage.js.

5. External services (services/)

Integrations with third-party providers that aren't part of Closegram's own data: Firebase, Apple Sign-In, SMS/OTP delivery, Stripe, LiveKit, translations, push notifications, and similar. Managers call these directly — access-services never do.

6. Validators (validators/)

Input validation used by manager methods, one file per domain, e.g. validators/user.validator.js.

Naming conventions

SuffixLayerExample
.resolver.jsGraphQL resolveruser.resolver.js
.type.jsGraphQL schema (SDL)user.type.js
.manager.jsBusiness logicauthentication.manager.js
.access-service.jsDatabase accessuser.access-service.js
.service.jsExternal integrationfirebase.service.js
.validator.jsInput validationuser.validator.js
(no suffix)Sequelize modeldatabase/models/user.js

Testing

Backend tests live in apps/backend/tests/, split into two Jest projects (see jest.config.js): unit (tests/unit-test/**, resolvers/managers with everything below them mocked, no database) and integration (tests/integration/**, the full resolver → manager → access-service → Postgres stack against a real test DB + Redis started via docker compose).

npm run test:unit # fast, DB-free — mocked managers/access-services
npm run test:integration # full stack against the Postgres test DB (manages Docker + migrations)
npm test # both projects

Coverage is collected from graphql/resolvers/, managers/, validators/, services/, and data-access-services/ — the model layer (database/models/) and the schema (graphql/types/) aren't included, since they're mostly declarative.

For the full setup — the test-DB/Docker lifecycle (scripts/test.sh, .env.test, the postgres-test/redis-test containers), the Apollo test-server helpers, naming conventions, coverage reporters/thresholds, and the complete command reference — see the Testing guide.

One backend-specific limitation worth knowing: because unit tests mock the manager, they can't catch a manager method that no resolver ever actually calls — the "unwired feature" problem from the section above. Only an integration test that genuinely calls the resolver, or a manual audit like the one behind this documentation site, surfaces that gap.

Catching schema/resolver drift automatically

npm run check:schema (apps/backend/scripts/check-schema-resolvers.js) statically compares every Query/Mutation field declared in graphql/types/*.js against the resolver keys actually exported from graphql/resolvers/*.js, and reports any field with no matching resolver — the exact kind of gap behind the setupTwoFactor / terminateSession / generateBackupCodes naming mismatches mentioned earlier. It's a plain Node script (no database, no test runner needed), so it's safe to run any time.

It ships with a baseline file (scripts/schema-resolver-baseline.json) snapshotting the gaps already known as of this audit (2026-07-10) — the script only fails (exit code 1) on gaps that aren't already in that baseline, so it doesn't block anything today, but it will catch new drift going forward. Fix one of the baseline entries, remove it from the JSON file in the same PR.

One caveat: this only catches fields that ARE declared in the schema but have no resolver. It can't catch a feature that's missing from the schema entirely — several features covered elsewhere in this documentation (Location, Post Collaborators, Stories & Live, and others) have a manager and access-service implemented but were never added to the GraphQL schema at all, so there's no schema field for this script to compare against. Those can currently only be found by an audit like the one behind these docs, or by cross-referencing the manager/access-service file list against the schema by hand.

Not every feature has every layer

A fully-wired feature typically has a resolver + manager + access-service (+ model). Several features documented in this site are only partially wired: they have a manager and access-service implementing the logic, but no matching resolver exposes it in the GraphQL schema — so the feature exists in the codebase but isn't reachable from the API at all. Each feature's "Implementation checklist" and "Technical implementation checklist" call this out per item; when you see a note like "manager logic exists, but no resolver," that's exactly this gap.