Checkpoint: A Small Program

You've learned interfaces, goroutines, channels, defer/panic/recover, error wrapping, generics, JSON and modules — let's build a small program. In this checkpoint you'll combine several of these into one realistic mini-app: an order processor that parses JSON, computes totals concurrently, and reports errors cleanly.

Learn Checkpoint: A Small Program in our free Go course — a beginner-friendly interactive lesson with worked examples, a practice exercise and a quick recall.

Part of the free Go course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

This checkpoint leans hardest on JSON, goroutines, channels and error wrapping — the everyday backbone of a Go service.

🛠️ The Build Challenge

Work through the five numbered TODOs in the starter below. Take it one step at a time — get the struct and JSON decoding working first, then add the concurrency, then the error handling. Paste it into the free Go Playground as you go.

Here's one clean way to do it. Notice how each concept slots in: the struct + tags handle JSON, the goroutines fan out the work, the buffered channel collects results, a separate goroutine closes the channel after wg.Wait() , and errors.Is classifies the empty-order failures. The output is sorted so concurrent ordering doesn't make it flaky.

⏱ Timed Quiz

Six questions covering the whole stretch. Reveal each answer only after you've committed to one.

1. Why does the goroutine take ord Order as a parameter instead of using o directly?

To give each goroutine its own copy of the order. Closing over the loop variable directly risks every goroutine seeing the same final value — the classic loop-variable capture bug.

2. What would happen if we forgot to close(results) ?

The for r := range results loop would block forever waiting for more values — a deadlock. range over a channel only ends once the channel is closed and drained.

3. Why use %w rather than %v when building the empty-order error?

%w wraps the sentinel so errors.Is(err, ErrEmptyOrder) can still find it deeper in the chain. %v would only copy the text and break the match.

4. The JSON has "qty": 0 for the Stapler. What does the program do with it?

It skips it. total returns a wrapped ErrEmptyOrder , the collector detects it with errors.Is , prints a SKIP line, and leaves it out of the grand total.

5. Why must the Order struct fields be capitalized?

Because encoding/json can only see exported (capitalized) fields. Lowercase fields would be invisible to Unmarshal , so they'd silently stay at their zero values.

Goroutines complete in an unpredictable order, so results arrive on the channel in any order. Sorting the lines makes the displayed output stable and reproducible.

For four trivial calculations, yes — a plain loop would be fine. The point is to practise the pattern so it's second nature when each unit of work is a slow network call or database query, where concurrency genuinely wins.

Q: Could I have used a sync.Mutex and a shared total instead of a channel?

Yes — accumulate into a locked variable. The channel version is often clearer because each goroutine just reports its result and the collector owns the aggregation, but both are valid Go.

Q: How would I extend this into a real service?

Wrap it in an HTTP handler that reads orders from the request body, put the types in their own package, and add tests. You now have every building block needed to do exactly that.

Practice quiz

In the order processor, why is each order passed into the goroutine as a parameter (go func(ord Order){...}(o)) instead of referencing o directly?

  • To make the goroutine run faster
  • So each goroutine gets its own copy and avoids the loop-variable capture bug
  • Because goroutines cannot read outer variables at all
  • To avoid allocating a channel

Answer: So each goroutine gets its own copy and avoids the loop-variable capture bug. Passing o as an argument gives each goroutine its own copy, sidestepping the classic loop-variable capture problem.

What does fmt.Errorf("...: %w", ErrEmptyOrder) give you that fmt.Errorf("...: %v", ErrEmptyOrder) does not?

  • Nicer formatting of the message
  • A faster error path
  • A wrapped error that errors.Is can still match against ErrEmptyOrder
  • Automatic logging of the error

Answer: A wrapped error that errors.Is can still match against ErrEmptyOrder. %w wraps the sentinel so errors.Is(err, ErrEmptyOrder) keeps matching it down the chain; %v only copies the text.

The results channel is created with make(chan result, len(orders)). Why is it buffered to that size?

  • So senders never block while the collector is still being set up
  • Because unbuffered channels cannot carry structs
  • To make the channel automatically close itself
  • Because len(orders) is required by json.Unmarshal

Answer: So senders never block while the collector is still being set up. A buffer of len(orders) means every goroutine can send its result without blocking, even before the receiver drains them.

A separate goroutine runs wg.Wait() then close(results). What breaks if you forget to close the channel?

  • The JSON fails to decode
  • The program panics immediately
  • The for r := range results loop blocks forever, causing a deadlock
  • Nothing — range stops on its own

Answer: The for r := range results loop blocks forever, causing a deadlock. range over a channel only ends once the channel is closed and drained; without close it waits forever.

Why must the Order struct fields (ID, Item, Qty, Price) be capitalized for json.Unmarshal to populate them?

  • Go requires all struct fields to be capitalized
  • encoding/json can only see exported (capitalized) fields via reflection
  • Lowercase fields are reserved keywords
  • Capitalization controls the JSON key names

Answer: encoding/json can only see exported (capitalized) fields via reflection. Only exported fields are visible to encoding/json; unexported fields stay at their zero values after Unmarshal.

The struct tag on the Qty field does what?

  • Validates that qty is non-negative
  • Maps the JSON key "qty" to the Go field Qty during marshal/unmarshal
  • Makes the field read-only
  • Tells Go the field is an integer

Answer: Maps the JSON key "qty" to the Go field Qty during marshal/unmarshal. Struct tags map JSON keys to Go field names, so lowercase JSON keys line up with exported Go fields.

How does the collector decide to SKIP the zero-quantity Stapler rather than treat it as a hard failure?

  • It checks if r.value == 0
  • It uses errors.Is(r.err, ErrEmptyOrder) to recognize the wrapped sentinel
  • It compares the error string with ==
  • It checks the order ID against a list

Answer: It uses errors.Is(r.err, ErrEmptyOrder) to recognize the wrapped sentinel. errors.Is matches the wrapped ErrEmptyOrder sentinel, letting the collector classify it as a skip rather than a crash.

Why does the program call sort.Strings(lines) before printing the results?

  • To remove duplicate lines
  • Because fmt.Println requires sorted input
  • Goroutines finish in an unpredictable order, so sorting makes the output deterministic
  • To convert the lines to lowercase

Answer: Goroutines finish in an unpredictable order, so sorting makes the output deterministic. Concurrent goroutines complete in any order, so sorting the collected lines makes the displayed output stable.

What is the role of sync.WaitGroup in this program?

  • It buffers the channel
  • It tracks how many goroutines are still running so the channel can be closed at the right time
  • It locks shared memory while goroutines run
  • It schedules the goroutines in order

Answer: It tracks how many goroutines are still running so the channel can be closed at the right time. wg.Add/Done/Wait count the in-flight goroutines so a separate goroutine knows when it is safe to close(results).

If you wanted to turn this order processor into an HTTP endpoint, the smallest change would be to:

  • Rewrite it in a different language
  • Read the orders JSON from the request body and write the totals to the http.ResponseWriter
  • Remove all the goroutines
  • Replace the struct with a map

Answer: Read the orders JSON from the request body and write the totals to the http.ResponseWriter. The core logic stays; you just decode orders from r.Body and write results to the ResponseWriter inside a handler.