Project: Async Order Processor
Build a complete Node command-line tool for the "Page & Spine" bookshop. It validates orders, reserves stock, charges a flaky payment gateway with retries and timeouts, rolls back failures, processes orders concurrently, and prints a localized report. Almost every chapter of the book appears in it.
You will learn
- How to structure a modern Node project: ES modules,
package.json, JSON imports,parseArgs. - Domain modelling with classes, private fields and a custom error hierarchy.
- Robust async code: retries with backoff, timeouts with
AbortSignal, rollback, and bounded concurrency. - Streaming results with an async generator, and reporting with grouping and
Intl. - Testing with the built-in
node:testrunner and dependency injection.
The plan
The finished project lives in playground/reference/; build your own in playground/. (No playground yet? Clone it from coder-hop/js-book-playground.)
| Decision | Why | Chapter |
|---|---|---|
| Money as integer cents, formatted only for display | no float drift in totals | 6 |
Stock as a #private field changed only via reserve/release | invariants can't be bypassed | 11 |
| Expected failures become result objects; bugs still throw | one bad order doesn't stop the batch, but real bugs stay visible | 3, 10 |
Retry only errors marked retryable, with exponential backoff | don't hammer a declined card | 12 |
A deterministic fake gateway, injectable as pay | repeatable examples and fast tests | 4, 12 |
| An async generator with a concurrency limit | results stream out as they're ready, without overloading the gateway | 12, 13 |
Step 1: package.json and data
The card numbers drive the fake gateway: β¦0002 is always declined, and β¦0119 fails twice before succeeding, to exercise the retry logic.
Step 2: errors and the catalog
new.target.namesets each error'snameautomatically, soOutOfStockErrorreports itself correctly without repeating the name.Book.#stockcan only change throughreserveandrelease; the getter exposes it read-only.Catalogkeeps books in aMapand is iterable through a generator method, so the report can write[...catalog].
Step 3: a fake payment gateway
It uses node:timers/promises for an abortable delay (Chapter 12), counts attempts per card in a module-level Map, a singleton shared by every importer (Chapter 14), and marks transient failures with { cause: { retryable: true } }.
Step 4: the processor
- Rollback: every successful reservation is recorded, and the
catchblock releases them in one place, so a failed payment never leaks stock. - Retry:
withRetryusesreturn awaitinsidetry, so that rejections are caught (Chapter 12, Exercise 9), and backs off 5, 10, 20 ms⦠- Timeout: one
AbortSignal.timeoutcovers all attempts for an order, capping its total time. - Concurrency:
processAllkeeps at mostconcurrencyorders in flight. It races the running promises and launches the next order as soon as one finishes, yielding each result through an async generator.
Step 5: the report
Object.groupBy splits the results by status, with defaults for missing groups; Map.groupBy sums per customer; toSorted ranks without mutating anything; Intl.NumberFormat and Intl.ListFormat make the output readable.
Step 6: the command-line entry point
import books from "β¦json" with { type: "json" }loads the catalog as a JSON module.parseArgsfromnode:utilhandles-c 3and--verbosewithout any dependency.for awaitconsumes the async generator; progress messages go to stderr, so stdout stays clean for the report.usingdisposes the stopwatch whenmainfinishes, andimport.meta.mainrunsmain()only when the file is executed directly, not when it's imported by tests.
Step 7: run it
Chen's order (o3) succeeded after two retried gateway timeouts, and Ben's declined order released its Hamlet reservation. Try node src/cli.js -v -c 3 to see progress lines on stderr, and npm start, which runs the script from package.json.
Step 8: test it
Thanks to the injectable pay option, the concurrency test uses a fake gateway that counts how many calls are in flight, and asserts that the peak is exactly 2. The default gateway's module state is reset in beforeEach, so the tests don't depend on each other.
More examples
1. Using the modules as a library
2. A different payment provider through injection
3. Higher concurrency, same results
Results stream in a different order when more orders run at once, but the report sorts by id, so the output is stable.
4. Handling a missing file
5. Exporting results as JSON lines
Recap
- Structure: errors β models β services (payments, processor) β presentation (report) β entry point (cli), each a small ES module.
- Protect invariants with
#privatestate and a domain error hierarchy. Turn expected failures into results, and let bugs throw. - Async robustness: retry only retryable errors, with backoff; time out with
AbortSignal; roll back on every failure path; bound the concurrency. - Stream results with an async generator; summarise with
groupBy,toSortedandIntl. - Make dependencies injectable, and test with
node:test.
Exercises
All of these extend your own copy in playground/. Add a test for each feature.
Exercise 1 π’ easy build it Β· playground
Add a --currency option (default EUR) that changes the report's currency formatting.
Show solution
parseArgs handles the option; the formatter is created once per run and passed to render.
Exercise 2 π’ easy explain why
Why do progress messages go to console.error while the report goes to console.log?
Show solution
stdout carries the program's result, so node src/cli.js > report.txt or a pipe gets just the report. stderr carries diagnostics, which stay on screen (Appendix B).
Exercise 3 π’ easy predict the output
If data/books.json gave Dune a stock of 1, what would happen to order o6 (Dune Γ1, Hamlet Γ3), given that o1 ran first?
Show solution
o1 needs 2 Dunes but only 1 exists, so o1 fails with OutOfStockError, leaving Dune at 1. Then o6 succeeds. With concurrency, the order in which o1 and o6 reserve stock could change the outcome, which is why reservations happen before the slow payment step.
Exercise 4 π’ easy build it
Add an npm test-friendly check: a test asserting that a Book with a negative price throws ValidationError.
Show solution
assert.throws accepts an error class and checks the thrown value with instanceof.
Exercise 5 π‘ medium build it Β· playground
Add discount codes: an order may have "coupon": "SAVE10" (10% off) or "FREESHIP" (it doesn't change the total). Unknown codes fail validation. Apply discounts in integer cents, rounding once.
Show solution
A Map of functions is a small strategy table (Appendix A). Rounding once, at the end, avoids cumulative errors.
Exercise 6 π‘ medium build it
Emit events while processing: make processAll accept an EventTarget and dispatch CustomEvent("result", { detail }) for each order, so other code can listen without changing the CLI.
Show solution
EventTarget and CustomEvent are global in Node and in browsers; optional chaining makes the feature opt-in.
Exercise 7 π‘ medium refactor it Β· playground
Replace the hand-written Promise.race pool in processAll with the AsyncQueue from Chapter 12's Exercise 10. Do all the tests still pass?
Show solution
Yes, they should: the tests check behaviour (every result arrives, and the peak concurrency is 2), not internals. Note one difference: the queue version resolves promises in completion order too, but you'll need an async generator wrapper (yield each result as its promise settles) to keep the streaming interface.
Exercise 8 π‘ medium build it Β· playground
Add a --dry-run flag: validate orders and reserve stock, but never call the payment gateway; report what would be charged. Implement it by injecting a fake pay, not with if statements inside the processor.
Show solution
Dependency injection keeps the processor unaware of "modes". Spreading false is a no-op, which makes the conditional option a one-liner.
Exercise 9 π΄ hard build it Β· playground
Make the whole run cancellable: pressing Ctrl+C should stop launching new orders, let in-flight ones finish or time out, and still print a partial report.
Show solution
Cancellation is cooperative: the generator stops starting work, and the running orders drain normally, so stock is never left reserved. Use AbortSignal.any([stop.signal, AbortSignal.timeout(ms)]) to combine a user abort with the per-order timeout.
Exercise 10 π΄ hard mini-project Β· playground
Persist results: after a run, append each result as one JSON line to data/results.jsonl, and add a stats sub-command that streams that file with readline plus for await and prints the total revenue per day, the success rate, and the most common failure reason, using Intl for numbers and percentages.
Show solution
Appending JSON lines is crash-safe and streamable. readline plus for await processes files of any size line by line, and Maps aggregate per day and per reason. Wire it into cli.js with parseArgs positionals (orders stats).
Official docs
- MDN JavaScript Guide (everything used in this project)
- Node.js: test runner Β· util.parseArgs
- MDN: AbortSignal.timeout