Chapter 16 Β· Putting It Together

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:test runner 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.)

DecisionWhyChapter
Money as integer cents, formatted only for displayno float drift in totals6
Stock as a #private field changed only via reserve/releaseinvariants can't be bypassed11
Expected failures become result objects; bugs still throwone bad order doesn't stop the batch, but real bugs stay visible3, 10
Retry only errors marked retryable, with exponential backoffdon't hammer a declined card12
A deterministic fake gateway, injectable as payrepeatable examples and fast tests4, 12
An async generator with a concurrency limitresults stream out as they're ready, without overloading the gateway12, 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

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

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

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 #private state 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, toSorted and Intl.
  • 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