DrillLab
第 11 / 17 节LESSON 11 / 17约 14 分钟~14 min

TODO 3 & 4 · Query.order 与 Query.ordersTODO 3 & 4 · Query.order and Query.orders

一个用 loader、一个用数据源;一个可空、一个非空。放一起讲差别最清楚。One uses the loader, one uses the data source; one is nullable, one is not. Side by side the difference is clearest.

2 个练习2 exercisesFederation · 第 3 部分Federation · Part 3
这一页有什么On this page8
学完这节你会After this lesson you can
  • 独立写出两个 Query resolverWrite both Query resolvers without help
  • 说清为什么一个用 loader、一个用数据源Explain why one uses the loader and the other uses the data source
  • 写出「找不到」时的结构化错误Write a structured error for the not-found case
  • 知道 Query.order 没有测试意味着什么Know what it means that Query.order has no test
这在考试里考什么What the exam does with this

Query.orders 有两条测试。Query.order 一条测试都没有,但 TODO 明确要求实现 —— 这种「没测试但有要求」的地方最能区分认真读题的人。Query.orders has two tests. Query.order has none, yet the TODO clearly asks for it. A required part with no test is what separates the people who read the task carefully.

这节课要看的真实文件Real files this lesson looks at高亮行 = 需要你动手改的文件Highlighted rows are the files you edit
graphql-federation-practice/node-subgraph/src/resolvers/orderResolvers.jsQuery.order 与 Query.ordersQuery.order and Query.orders

提醒:源项目在磁盘上是做完的版本 —— 下面就是答案。想自己先写一遍的话,现在关上。Heads up: on disk this project is the finished version — what follows is the answer. Close this if you want to write it yourself first.

JavaScriptorderResolvers.js源项目From source
1import DataLoader from 'dataloader';
2import { GraphQLError } from 'graphql';
3
4// Custom error codes
5const ErrorCodes = {
6 ORDER_NOT_FOUND: 'ORDER_NOT_FOUND',
7 INVALID_INPUT: 'INVALID_INPUT',
8 INVENTORY_ERROR: 'INVENTORY_ERROR',
9 SERVICE_ERROR: 'SERVICE_ERROR'
10};
11
12// DataLoader for batching shipping info requests
13function createShippingInfoLoader(shippingDataSource) {
14 return new DataLoader(async orderIds => {
15 console.log(`[DataLoader] Batching ${orderIds.length} shipping info requests`);
16
17 const shippingInfos = await Promise.all(
18 orderIds.map(id => shippingDataSource.getShippingInfo(id))
19 );
20
21 return shippingInfos;
22 });
23}
24
25// DataLoader for batching order requests
26function createOrderLoader(orderDataSource) {
27 return new DataLoader(async orderIds => {
28 console.log(`[DataLoader] Batching ${orderIds.length} order requests`);
29
30 const orders = await Promise.all(
31 orderIds.map(id => orderDataSource.getOrderById(id))
32 );
33
34 return orders;
35 });
36}
37
38export const resolvers = {
39 User: {
40 // Reference Resolver - extend User entity from Accounts subgraph
41 __resolveReference(user, { dataSources, loaders }) {
42 return { id: user.id };
43 },
44
45 // Field resolver with caching
46 async orders(user, _, { dataSources, loaders, correlationId }) {
47 // TODO: Implement orders resolver with proper error handling and correlation ID tracing
48 return [];
49 }
50 },
51
52 Order: {
53 // Field resolver for shipping info with DataLoader
54 async shippingInfo(parent, _, { dataSources, loaders, correlationId }) {
55 // TODO: Implement shipping info resolver using DataLoader to prevent N+1 queries
56 return null;
57 }
58 },
59
60 Query: {
61 async order(_, { id }, { dataSources, loaders, correlationId }) {
62 // TODO: Implement order query using DataLoader with structured error handling
63 return null;
64 },
65
66 async orders(_, { userId }, { dataSources, correlationId }) {
67 // TODO: Implement orders query with error handling and correlation ID logging
68 return [];
69 }
70 },
71
72 Mutation: {
73 // Mutation provided for reference - candidates focus on Query resolvers
74 async createOrder(_, { userId, items }, { dataSources, correlationId }) {
75 try {
76 console.log(`[${correlationId}] Creating order for userId: ${userId}`);
77
78 if (!userId || !items || items.length === 0) {
79 throw new GraphQLError('Invalid order input', {
80 extensions: {
81 code: ErrorCodes.INVALID_INPUT,
82 correlationId
83 }
84 });
85 }
86
87 const order = await dataSources.orderAPI.createOrder({ userId, items });
88 console.log(`[${correlationId}] Order created: ${order.id}`);
89
90 return order;
91 } catch (error) {
92 console.error(`[${correlationId}] Error creating order:`, error.message);
93 throw new GraphQLError('Failed to create order', {
94 extensions: {
95 code: ErrorCodes.SERVICE_ERROR,
96 correlationId,
97 originalError: error.message
98 }
99 });
100 }
101 }
102 }
103};
104
105export { createShippingInfoLoader, createOrderLoader };
Source: graphql-federation-practice/node-subgraph/src/resolvers/orderResolvers.js
§01

两个 TODO 的要求对比The two TODOs side by side

Query.orderQuery.orders
TODO 原文关键词using DataLoader with structured error handlingerror handling and correlation ID logging
schema 返回类型Order可空[Order!]!双重非空
数据来源loaders.orderLoader.load(id)dataSources.orderDataSource.getOrdersByUserId(userId)
context 解构{ dataSources, loaders, correlationId }{ dataSources, correlationId }没有 loaders
找不到时ORDER_NOT_FOUND返回 []
测试❌ 一条都没有✅ 2 条

注意 Query.orders 的签名里没有loadersstarter 代码就是这么写的 —— 这是出题人在提示 「这个字段不用 loader」。参数签名本身就是提示。

为什么 orders 不用 loader?因为 orderLoader 是按 order id批量取单个订单的。而这里要的是「某个 user 的所有订单」—— key 不一样,用不上。想用 loader 就得再造一个ordersByUserLoader,而 index.js里没有它,你也不该改 index.js(PROVIDED)。

Query.orderQuery.orders
Key words in the TODOusing DataLoader with structured error handlingerror handling and correlation ID logging
Return type in the schemaOrder (nullable)[Order!]! (non-null twice over)
Where the data comes fromloaders.orderLoader.load(id)dataSources.orderDataSource.getOrdersByUserId(userId)
context destructuring{ dataSources, loaders, correlationId }{ dataSources, correlationId } (no loaders)
When nothing is foundthrows ORDER_NOT_FOUNDreturns []
Tests❌ not a single one✅ 2 of them

Notice that the signature of Query.orders has no loaders in it. That is how the starter code is written — the examiner hinting that this field does not use a loader. The parameter signature is itself a hint.

Why does orders not use a loader? Because orderLoader batches single orders by order id, and what this field wants is “every order belonging to one user” — a different key, so it does not fit. Using a loader would mean building an ordersByUserLoader, and index.js has no such thing, and you should not be editing index.js anyway (PROVIDED).

§02

Query.order:用 loader + 找不到要抛错Query.order: use the loader, and throw when nothing is found

为什么这里用 loader?这个字段本身只取一条,看起来不需要合并。 但 loader 的另一个作用是同请求内缓存 —— 如果一次查询里多处引用同一个 order (比如 { a: order(id:"1") { ... } b: order(id:"1") { ... } }), loader 只会真的取一次。而且 TODO 原文点名了它。

找不到怎么办?schema 说 order(id: ID!): Order 是可空的, 所以 return null 不违反 schema。 但 TODO 要求 structured error handling, 而 ErrorCodes 里恰好准备了一个ORDER_NOT_FOUND —— 这是明显的暗示。

那个 ErrorCodes 常量表值得注意:四个 code 里有一个(INVENTORY_ERROR) 在参考答案里没用到,ORDER_NOT_FOUND 只有这里用。准备好的常量就是在告诉你「这里该抛什么」。

Why use a loader here? This field fetches a single row, so batching looks pointless. But a loader’s other job is caching inside one request — if a single query references the same order in several places (say { a: order(id:"1") { ... } b: order(id:"1") { ... } }), the loader only fetches once. And the TODO names it outright.

What happens when nothing is found? The schema says order(id: ID!): Order is nullable, so return null breaks no rule. But the TODO asks for structured error handling, and ErrorCodes happens to have an ORDER_NOT_FOUND ready — a fairly loud hint.

That ErrorCodes table repays a close read: one of its four codes (INVENTORY_ERROR) is never used in the reference answer, and ORDER_NOT_FOUND is used only here. A constant somebody prepared for you is telling you what to throw where.

JavaScriptstarter 里给好的错误码表The error-code table the starter gives you源项目From source
1const ErrorCodes = {
2 ORDER_NOT_FOUND: 'ORDER_NOT_FOUND', // ← Query.order 用
3 INVALID_INPUT: 'INVALID_INPUT', // ← Query.orders 和 createOrder 用
4 INVENTORY_ERROR: 'INVENTORY_ERROR', // ← 参考答案里没用到
5 SERVICE_ERROR: 'SERVICE_ERROR' // ← 兜底用
6};
1const ErrorCodes = {
2 ORDER_NOT_FOUND: 'ORDER_NOT_FOUND', // ← used by Query.order
3 INVALID_INPUT: 'INVALID_INPUT', // ← used by Query.orders and createOrder
4 INVENTORY_ERROR: 'INVENTORY_ERROR', // ← unused in the reference answer
5 SERVICE_ERROR: 'SERVICE_ERROR' // ← the catch-all
6};
Source: graphql-federation-practice/node-subgraph/src/resolvers/orderResolvers.js
JavaScriptQuery.order(参考答案)Query.order (reference answer)已跑通Verified
1async order(_, { id }, { dataSources, loaders, correlationId }) {
2 try {
3 console.log(`[${correlationId}] Query.order id: ${id}`);
4
5 const order = await loaders.orderLoader.load(id);
6
7 if (!order) {
8 throw new GraphQLError(`Order not found: ${id}`, {
9 extensions: {
10 code: ErrorCodes.ORDER_NOT_FOUND,
11 correlationId,
12 orderId: id
13 }
14 });
15 }
16
17 return order;
18 } catch (error) {
19 if (error instanceof GraphQLError) throw error; // ← 关键:放行上面那个
20
21 console.error(`[${correlationId}] Error in Query.order:`, error.message);
22 throw new GraphQLError('Failed to fetch order', {
23 extensions: {
24 code: ErrorCodes.SERVICE_ERROR,
25 correlationId,
26 originalError: error.message
27 }
28 });
29 }
30}
1async order(_, { id }, { dataSources, loaders, correlationId }) {
2 try {
3 console.log(`[${correlationId}] Query.order id: ${id}`);
4
5 const order = await loaders.orderLoader.load(id);
6
7 if (!order) {
8 throw new GraphQLError(`Order not found: ${id}`, {
9 extensions: {
10 code: ErrorCodes.ORDER_NOT_FOUND,
11 correlationId,
12 orderId: id
13 }
14 });
15 }
16
17 return order;
18 } catch (error) {
19 if (error instanceof GraphQLError) throw error; // ← the key line: let the one above through
20
21 console.error(`[${correlationId}] Error in Query.order:`, error.message);
22 throw new GraphQLError('Failed to fetch order', {
23 extensions: {
24 code: ErrorCodes.SERVICE_ERROR,
25 correlationId,
26 originalError: error.message
27 }
28 });
29 }
30}
§03

这里最能看出 instanceof 检查为什么必要This is where the instanceof check clearly matters

同一个 try 块里既抛业务错误又要接系统错误 —— 不判断就必然出错。The same try block both throws a business error and catches system errors. Without the check it will always go wrong.

Query.order 的结构:try 块里自己抛了一个ORDER_NOT_FOUND。 而同一个 catch 又要负责接住数据源可能抛的系统异常。

如果 catch 里没有 instanceof 判断:你抛的 ORDER_NOT_FOUND 会被自己的 catch 接住, 然后重新包成 SERVICE_ERROR。 客户端查一个不存在的订单,收到的是「服务器内部错误」—— 它会重试,而重试永远不会成功。

加上那一行之后:ORDER_NOT_FOUND 原样传出去, 客户端知道「这个 id 不存在,别重试了」; 而数据源真的挂了(比如网络超时)时, 才会得到 SERVICE_ERROR两种情况被正确区分了。

审计时用 order(id: "order-999")实测过,返回的 code 确实是 ORDER_NOT_FOUND

Look at the shape of Query.order: the try block throws an error itself, an ORDER_NOT_FOUND. And the same catch is also responsible for the system exceptions the data source might throw.

Without the instanceof check in the catch: the ORDER_NOT_FOUND you threw is caught by your own catch and rewrapped as SERVICE_ERROR. A client asking for an order that does not exist is told “internal server error” — so it retries, and the retry can never succeed.

With that one line added: ORDER_NOT_FOUND travels out untouched and the client learns “this id does not exist, stop retrying”; only when the data source genuinely fails (a network timeout, say) do you get a SERVICE_ERROR. The two cases are told apart correctly.

The audit measured this with order(id: "order-999"), and the code that came back really was ORDER_NOT_FOUND.

§04

Query.orders:用数据源 + 校验参数Query.orders: use the data source and validate the argument

User.orders 几乎一样,只有两处差别:

  • userId 来自 args 而不是 parent。async orders(_, { userId }, ...)—— 第一个参数是 _(顶层 Query 没有有意义的 parent)。
  • 要校验 userId。schema 写的是 userId: ID!, GraphQL 会保证它不是 null。 但空字符串 "" 能通过 schema 校验(它是个合法的 ID 值),所以自己再挡一道更稳。 这也是 TODO 里 error handling 的一部分。

返回类型是 [Order!]!,所以照样 ?? []

Almost identical to User.orders, with two differences:

  • userId comes from args, not from parent. async orders(_, { userId }, ...) — the first parameter is _, because a top-level Query has no meaningful parent.
  • userId has to be validated. The schema says userId: ID!, so GraphQL guarantees it is not null. But an empty string "" passes schema validation (it is a legal ID value), so a second guard of your own is safer. This is part of the error handling the TODO asks for.

The return type is [Order!]!, so once again ?? [].

JavaScriptQuery.orders(参考答案,实测两条测试通过)Query.orders (reference answer, both tests measured to pass)已跑通Verified
1async orders(_, { userId }, { dataSources, correlationId }) {
2 try {
3 console.log(`[${correlationId}] Query.orders userId: ${userId}`);
4
5 if (!userId) {
6 throw new GraphQLError('userId is required', {
7 extensions: {
8 code: ErrorCodes.INVALID_INPUT,
9 correlationId
10 }
11 });
12 }
13
14 const orders = await dataSources.orderDataSource.getOrdersByUserId(userId);
15 return orders ?? [];
16 } catch (error) {
17 if (error instanceof GraphQLError) throw error;
18
19 console.error(`[${correlationId}] Error in Query.orders:`, error.message);
20 throw new GraphQLError('Failed to fetch orders', {
21 extensions: {
22 code: ErrorCodes.SERVICE_ERROR,
23 correlationId,
24 originalError: error.message
25 }
26 });
27 }
28}
§05

Query.order 没有测试意味着什么What it means that Query.order has no test

十个测试里,Query.order 一条都没有。 所以你完全不实现它,npm test 也是全绿。

三种可能的处理方式,以及各自的后果:

  1. 不实现,留着 return null测试全绿。但代码里留着一个明晃晃的 TODO 注释, 人工 review 一眼就看到。
  2. 删掉 TODO 注释但还是 return null。更糟 —— 这看起来像「我以为我做完了」, 比留着 TODO 更容易被判定为疏漏。
  3. 照 TODO 要求实现。测试不会因此多绿一条,但 TODO 清空、ORDER_NOT_FOUND 这个准备好的错误码被用上了。

选 3。Online Assessment 通常是「自动测试 + 人工 review」双轨的。 自动测试是及格线,人工 review 看的是 「有没有做完、有没有理解设计意图」。一个没被测试覆盖但明确要求的 TODO, 正是拉开差距的地方。

Of the ten tests, not one covers Query.order. So you can skip implementing it entirely and npm test is still all green.

Three ways to handle that, and what each one costs:

  1. Do not implement it, leave the return null. All tests green. But a glaring TODO comment stays in the code, and a human reviewer spots it instantly.
  2. Delete the TODO comment but still return null. Worse — it reads as “I thought I was done”, which looks more careless than leaving the TODO in place.
  3. Implement what the TODO asks. No test turns green because of it, but the TODO list is clear and that prepared ORDER_NOT_FOUND code finally gets used.

Pick 3. An online assessment usually runs on two tracks: automated tests plus a human review. The tests are the pass mark; the review asks whether you finished and whether you understood the design intent. A TODO that no test covers but the brief clearly requires is exactly where candidates pull ahead.

练习Practice

动手做Get your hands on it

填空只是过渡。真正掌握的标准,是在没有答案的时候从头写出来 —— 所以做完 L2 之后一定要往 L3、L4 走。Filling blanks is a stepping stone. The real bar is writing it from nothing, so once L2 is comfortable, push on to L3 and L4.

L2填空Fill the blanks补全两个 Query resolverFill in both Query resolvers

四个空横跨两个 resolver。注意它们数据来源不同、兜底策略不同。

Four blanks across two resolvers. Note they read from different places and need different fallbacks.

JSsrc/resolvers/orderResolvers.js4 个空4 blanks
1// schema: order(id: ID!): Order (可空)
2async order(_, { id }, { dataSources, loaders, correlationId }) {
3 try {
4 const order = await loaders..load(id);
5
6 if (!order) {
7 throw new GraphQLError(`Order not found: ${id}`, {
8 extensions: { code: ErrorCodes., correlationId }
9 });
10 }
11 return order;
12 } catch (error) {
13 if (error instanceof GraphQLError) throw error;
14 throw new GraphQLError('Failed to fetch order', {
15 extensions: { code: ErrorCodes.SERVICE_ERROR, correlationId }
16 });
17 }
18}
19
20// schema: orders(userId: ID!): [Order!]! (双重非空)
21async orders(_, { }, { dataSources, correlationId }) {
22 try {
23 const orders = await dataSources.orderDataSource.getOrdersByUserId(userId);
24 return orders ?? ;
25 } catch (error) {
26 if (error instanceof GraphQLError) throw error;
27 throw new GraphQLError('Failed to fetch orders', {
28 extensions: { code: ErrorCodes.SERVICE_ERROR, correlationId }
29 });
30 }
31}
把 4 个空都填上才能检查(还差 4 个)Fill all 4 blanks to check (4 to go)
L3写整块Write a block不看答案,自己写出两个 Query resolverWrite both Query resolvers yourself, without looking at the answer

两个函数一起写。注意它们的数据来源、兜底策略、 context 解构都不一样。

Write both functions together. They differ in where they read from, what they fall back to, and what they destructure out of context.

要求Requirements
  • Query.order 用 orderLoader 取数据Query.order reads through orderLoader
  • Query.order 找不到时抛带 ORDER_NOT_FOUND code 的 GraphQLErrorWhen Query.order finds nothing, it throws a GraphQLError carrying the ORDER_NOT_FOUND code
  • Query.orders 用 orderDataSource.getOrdersByUserId 取数据Query.orders reads through orderDataSource.getOrdersByUserId
  • Query.orders 校验 userId,非法时抛 INVALID_INPUTQuery.orders validates userId and throws INVALID_INPUT when it is not valid
  • Query.orders 绝不返回 null(schema 是 [Order!]!)Query.orders never returns null (the schema says [Order!]!)
  • 两个都用 try/catch,catch 里先放行已有的 GraphQLErrorBoth use try/catch, and the catch lets an existing GraphQLError through first
  • 两个都在日志里带上 correlationIdBoth include correlationId in their log line
JavaScriptsrc/resolvers/orderResolvers.js
This check is textual: it looks for the right constructs, it does not run your code
提示Hints共 4 级,已看 0 级4 levels, 0 opened
先自己想两分钟。想不出来再点右上角 —— 提示是一级一级放的,不会一次给完。Think for two minutes first. Then use the button above — hints come one level at a time, never all at once.

看答案之前,先确认你已经自己动手写过一遍。看懂别人的答案和自己写出来,是两种能力。Before you open this, make sure you have written it yourself once. Following someone else's answer and producing your own are two different skills.

错例Wrong

初学者常见的几种写法错误Mistakes beginners actually make

下面每一段都是「能编译、但结果不对」或者「一跑就炸」的真实写法。先自己看出问题在哪,再看解释。Every snippet below either compiles and gives the wrong answer, or blows up on the first run. Spot the problem yourself before reading the explanation.

JavaScript示意Illustrative
1// ✗ Query.orders 也去用 loader
2async orders(_, { userId }, { loaders }) {
3 return loaders.orderLoader.load(userId);
4}
1// ✗ Query.orders reaching for a loader too
2async orders(_, { userId }, { loaders }) {
3 return loaders.orderLoader.load(userId);
4}
orderLoader 的 key 是 order id, 不是 user id。传 "123" 进去会去找id === "123" 的订单 —— 数据源里的 id 长得像 order-456,所以找不到, 返回 undefined
而且返回的是单个对象而不是数组,违反 [Order!]!
提示其实在参数签名里:starter 给的orders 签名没有解构 loaders
The key of orderLoader is an order id, not a user id. Passing "123" makes it look for the order whose id === "123" — the ids in the data source look like order-456, so nothing matches and it returns undefined.
It also returns a single object instead of an array, which breaks [Order!]!.
The hint is in the argument list: the orders signature in the starter code does not destructure loaders.
JavaScript示意Illustrative
1// ✗ 找不到时返回 null(Query.order)
2const order = await loaders.orderLoader.load(id);
3return order ?? null;
1// ✗ returning null when nothing is found (Query.order)
2const order = await loaders.orderLoader.load(id);
3return order ?? null;
不违反 schemaorder 是可空的), 而且没有测试会挂。
但 TODO 要求 structured error handling, 而 ErrorCodes.ORDER_NOT_FOUND 明摆着是为这里准备的。给好但没用上的常量,就是没做完的信号。
This does not break the schema (order is nullable), and no test fails.
But the TODO asks for structured error handling, and ErrorCodes.ORDER_NOT_FOUND is clearly there for this spot. A constant that is given but never used means the work is not finished.
迁移Transfer

换一道题也能用Works on other problems too

考试不会原题重考。真正能带走的是「看到这种信号 → 伸手去拿这个解法」。The exam will not reuse the same question. What you take away is the reflex: see this signal, reach for that solution.

参数签名里没有解构某个东西The argument list does not destructure something
那是提示:这个字段不需要它That is a hint: this field does not need it
starter 给了没用上的常量The starter code defines a constant nothing uses
找它对应的场景,那里大概有个 TODOFind the case it belongs to; there is probably a TODO there
同一个 try 里既抛业务错又要接系统错One try block both throws a business error and catches system errors
catch 第一行 instanceof 判断Put an instanceof check on the first line of catch
字段可空 vs 非空列表A nullable field versus a non-null list
前者可以抛错/返 null,后者必须 ?? []The first may throw or return null; the second must use ?? []
某个 TODO 没有测试A TODO has no test
照样实现 —— 人工 review 会看Implement it anyway; a person will read the code
这节的要点What to take away
  1. Query.order 用 orderLoader(TODO 点名了),Query.orders 用数据源(signature 里没给 loaders)。Query.order uses orderLoader (the TODO names it). Query.orders uses the data source (its signature does not receive loaders).
  2. Query.order 可空 → 找不到抛 ORDER_NOT_FOUND;Query.orders 双重非空 → 兜底 []。Query.order is nullable, so throw ORDER_NOT_FOUND when nothing is found. Query.orders is non-null at both levels, so fall back to [].
  3. 同一个 userId,在 User.orders 里来自 parent,在 Query.orders 里来自 args。The same userId comes from parent inside User.orders and from args inside Query.orders.
  4. catch 第一行的 instanceof 判断在 Query.order 里最关键 —— 同一个 try 里既抛业务错又接系统错。The instanceof check on the first line of catch matters most in Query.order, where one try block both throws a business error and catches system errors.
  5. Query.order 没有测试但 TODO 明确要求 —— 实现它,这是拉开差距的地方。Query.order has no test but the TODO clearly asks for it. Implement it; this is where answers differ.

接下来What next

  1. 把这一节的练习做掉Do this lesson’s exercises2 个,就在这一页上面 —— 别攒着最后一起做2 of them, further up this page — do not save them for later
    回到练习 ↑Back up to them ↑
  2. 接着看下一节Continue to the next lesson三处埋雷:怎么系统地找出来The three planted bugs: how to find them systematically
    下一节Next lesson
读完并且做过上面的练习了吗?Read it and worked through the exercises above?
上一节:Previous: TODO 2 · Order.shippingInfoTODO 2 · Order.shippingInfo