Skip to content
intermediate Phase 4 · Resolvers and Data Source Integration

Wiring Database and Service Data Sources

Connecting resolvers to databases, ORMs, and downstream HTTP services cleanly.

50m
1 problems
Topic Progress 0%

Keep SQL Out of the Resolver Map

Keep SQL Out of the Resolver Map

A resolver map that embeds queries works at ten fields and collapses at fifty:

const resolvers = {
  Query: {
    posts: async (_p, { first }, ctx) => {
      const { rows } = await ctx.pool.query(
        'SELECT id, title, author_id FROM posts WHERE published_at IS NOT NULL ORDER BY id LIMIT $1',
        [first]);
      return rows;
    },
  },
};

SQL now lives in the schema module, row mapping is hand-written per field, and testing a query means standing up GraphQL first. Extract the knowledge into an object:

class PostDataSource {
  constructor(pool) { this.pool = pool; }

  async published(limit) {
    const { rows } = await this.pool.query(
      'SELECT id, title, author_id FROM posts WHERE published_at IS NOT NULL ORDER BY id LIMIT $1',
      [limit]);
    return rows.map(toPost);   // one mapping convention, in one place
  }
}

const resolvers = {
  Query: {
    posts: (_p, { first }, ctx) => ctx.dataSources.posts.published(first),
  },
};

The resolver now reads like the business rule — published posts, take the first N — while the data source owns SQL, row mapping, and query choice. Testing splits along the same line: data sources get integration tests against a real database, resolvers get unit tests with a stub returning fixtures.

Draw class boundaries by aggregate or service (posts, orders, users), not one class per field. If background jobs need the same queries, they depend on the same data source; a second copy of the SQL in a job script is how the two implementations drift apart.

Where the pool itself comes from is a context question, not a schema question — see resolver context for how repositories reach the resolvers that call them.

One Pool for the Process

One Pool for the Process

Postgres typically allows around 100 concurrent connections. The tempting mistake — opening a new connection inside every resolver call — exhausts that budget within seconds under load, and the failure arrives as a "too many connections" page at the worst possible time.

// db.js — one pool per process, created at startup
const pool = new Pool({
  max: 10,                    // leave headroom for migrations, psql, dashboards
  idleTimeoutMillis: 30000,   // return idle sockets instead of pinning them
  connectionTimeoutMillis: 2000,  // fail fast instead of queueing forever
});

Resolvers borrow from the pool through the data source they were handed. The rule: one pool per process, shared by every request — pooling is how N concurrent GraphQL executions fit inside a fixed database budget, and per-request pools multiply that budget by your traffic.

Transactions are the one place a resolver needs a dedicated connection:

async transfer(from, to, amountCents) {
  const client = await this.pool.connect();
  try {
    await client.query('BEGIN');
    await client.query('UPDATE accounts SET balance = balance - $1 WHERE id = $2', [amountCents, from]);
    await client.query('UPDATE accounts SET balance = balance + $1 WHERE id = $2', [amountCents, to]);
    await client.query('COMMIT');
  } catch (err) {
    await client.query('ROLLBACK');
    throw err;              // the executor turns this into a field error
  } finally {
    client.release();       // never leak — the pool budget depends on it
  }
}

Four details that decide whether the pool survives contact with production:

  1. Release in finally. A leaked client shrinks the pool one slot at a time until every query times out.
  2. Roll back before releasing. Releasing an open transaction leaves the next borrower in a transaction it did not start.
  3. No network calls inside a transaction. Calling a payment API while holding a connection pins it for the duration of someone else's latency; send the charge after commit or make the flow idempotent and retryable.
  4. Watch sibling fan-out. Resolver siblings execute concurrently (see async resolver patterns), so a query with twenty parallel fields temporarily wants twenty checkouts — size max for that peak, not the average.

Downstream Services Need a Wrapper

Downstream Services Need a Wrapper

When a resolver calls fetch() directly, every concern lands in the resolver: timeout, retries, auth headers, trace propagation, and error mapping. Put them behind one wrapper so resolvers see a method with a name:

class PricingClient {
  constructor(baseUrl, opts = {}) {
    this.baseUrl = baseUrl;
    this.timeoutMs = opts.timeoutMs || 1500;
    this.fetchImpl = opts.fetchImpl || fetch;
  }

  async quote(sku, requestId) {
    const res = await this.fetchImpl(this.baseUrl + '/quote?sku=' + encodeURIComponent(sku), {
      signal: AbortSignal.timeout(this.timeoutMs),   // bound every call
      headers: { 'x-request-id': requestId },        // one trace across services
    });
    if (!res.ok) throw new DownstreamError('pricing', res.status);
    return res.json();
  }
}

What the wrapper standardizes:

  • A timeout on every call. Without one, a slow dependency turns into an unbounded resolver and a request that hangs while its siblings already finished.
  • Retries only where they are safe. Retry idempotent reads with backoff; never blindly retry a request that charges a card. The idempotency key from serial execution travels through this wrapper on exactly those calls.
  • Error mapping. Downstream failures become typed errors, which resolver error propagation converts into errors[] entries carrying an extensions.code a client can branch on — instead of connect ECONNREFUSED 10.4.2.9:8080 reaching consumers.
  • Trace headers. Forward the request id created in the context factory so a slow quote is one log search away. See resolver context for where that id comes from.

Wiring stays in one place: construct wrappers in the context factory and expose them as ctx.dataSources.pricing. A wrapper holding no request state may be a process singleton instead — the test is whether it stores anything derived from a caller.

The acceptance check for the whole design: search the schema and resolver directory for SQL keywords and raw fetch( calls, and find nothing. Schema and resolvers describe what data is needed; data sources alone know how.

Practice Problems

0 / 1 solved
Extract a Data Source From a Fat Resolver
Layered Wiring

A single resolver contains a SQL string, a row-to-object mapping, and a raw fetch to a recommendations service, all using connections from the global pool. Refactor it into data source classes wired through context, keeping the resolver body to the business rule, and make sure no connection can leak on failure.

Example:

Input: { post(id: "p1") { title recommendation { sku } } }

Output: { "data": { "post": { "title": "Resolvers", "recommendation": { "sku": "SKU-9" } } } }

The resolver asks two context data sources for values; SQL and HTTP details no longer appear anywhere in the resolver map.

Optimal Solution — O(1) time, O(1) space

Give each aggregate a data source class, construct them in the context factory from the shared pool, and let resolvers call one method per field.

class PostDataSource {
  constructor(pool) { this.pool = pool; }

  async byId(id) {
    const { rows } = await this.pool.query(
      'SELECT id, title, body FROM posts WHERE id = $1', [id]);
    return rows[0] ? toPost(rows[0]) : null;
  }
}

class RecommendationClient {
  constructor(baseUrl, { timeoutMs = 1500 } = {}) {
    this.baseUrl = baseUrl; this.timeoutMs = timeoutMs;
  }

  async topForPost(postId, requestId) {
    const res = await fetch(this.baseUrl + '/recommend?post=' + postId, {
      signal: AbortSignal.timeout(this.timeoutMs),
      headers: { 'x-request-id': requestId },
    });
    if (!res.ok) throw new DownstreamError('recommendations', res.status);
    return res.json();
  }
}

// context factory
const context = async ({ request }) => ({
  requestId: request.headers['x-request-id'] || randomUUID(),
  dataSources: {
    posts: new PostDataSource(pool),
    recommendations: new RecommendationClient(process.env.REC_URL),
  },
});

// resolver map
const resolvers = {
  Query: {
    post: (_p, { id }, ctx) => ctx.dataSources.posts.byId(id),
  },
  Post: {
    recommendation: (post, _a, ctx) =>
      ctx.dataSources.recommendations.topForPost(post.id, ctx.requestId),
  },
};

Edge Cases:

  • A missing post id returns null for a nullable field rather than throwing a not-found error
  • The recommendations timeout must resolve the subfield to null or a field error, never hang the parent
  • Pool exhaustion shows up as connection checkout timeouts — surface them with a retryable extensions code

Quiz

1. Why should SQL live in a data source class rather than in resolver bodies?

Question 1 options

2. Why is one connection pool per process the right default?

Question 2 options

3. What is the correct lifecycle for a transaction inside a resolver?

Question 3 options

4. A downstream payment service times out on a POST /charges call. What should its wrapper do?

Question 4 options

Flashcards

Question

Data source class

Answer

An object owning query logic, row mapping, and storage choice for one aggregate. Resolvers call methods on it; SQL never appears in the resolver map.

Question

Pool sizing rule

Answer

One pool per process, max sized so (instances x max) stays below the database connection ceiling, with headroom for admin work.

Question

Transaction checklist in a resolver

Answer

Borrow one client, BEGIN, COMMIT/ROLLBACK, release in finally — and no external HTTP calls while the transaction is open.

Question

Wrapper for downstream services

Answer

Class owning timeout, capped retries only for idempotent calls, trace headers, and typed error mapping. Resolvers call methods, not fetch.

Question

Where wiring happens

Answer

Context factory builds data sources and clients per request (or immutable singletons if stateless) and exposes them as ctx.dataSources.

Revision Notes

Key Takeaways

  • 1. Resolvers declare intent; data sources own SQL, mapping, and access strategy — testable on both sides of the boundary
  • 2. One pool per process, sized below the database ceiling, released in finally
  • 3. Transactions need one dedicated client and must close before any external call
  • 4. Downstream HTTP goes through a wrapper: timeout, idempotent-only retries, trace headers, typed errors

Interview Tips

  • • When asked how you would add a database to a GraphQL server, name the three layers in order: resolver → data source → pool, and say what each one is forbidden from knowing
  • • Be ready for "your query is slow under load": connect resolver sibling fan-out to pool exhaustion, then propose sizing, batching, and indexes as separate fixes

Cheat Sheet

Data Source Wiring Cheat Sheet

class PostDataSource {
  constructor(pool) { this.pool = pool; }
  async published(limit) { /* SQL + row mapping here */ }
}
// wired in the context factory, read as ctx.dataSources.posts.published(20)
  • Resolver: business intent only — no SQL, no fetch, no pool math
  • Pool: one per process; max + idle/timeout settings; connect() only for transactions
  • Transaction: BEGIN → statements → COMMIT/ROLLBACK → client.release() in finally
  • Never hold a transaction across an HTTP call to another service
  • Wrapper per service: AbortSignal.timeout, capped backoff retries, x-request-id, typed errors
  • Retries: idempotent reads yes; charges only with an idempotency key
  • Test: grep the resolver directory for SQL and fetch( — zero hits

Continue Learning