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:
- Release in
finally. A leaked client shrinks the pool one slot at a time until every query times out. - Roll back before releasing. Releasing an open transaction leaves the next borrower in a transaction it did not start.
- 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.
- Watch sibling fan-out. Resolver siblings execute concurrently (see async resolver patterns), so a query with twenty parallel fields temporarily wants twenty checkouts — size
maxfor 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 anextensions.codea client can branch on — instead ofconnect ECONNREFUSED 10.4.2.9:8080reaching 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
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?
2. Why is one connection pool per process the right default?
3. What is the correct lifecycle for a transaction inside a resolver?
4. A downstream payment service times out on a POST /charges call. What should its wrapper do?
Flashcards
Question
Data source class
Click to reveal answer
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
Click to reveal answer
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
Click to reveal answer
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
Click to reveal answer
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
Click to reveal answer
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()infinally - 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:
grepthe resolver directory for SQL andfetch(— zero hits