Checkpoint: A Small Program
Reviewed & published by Brayan K
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.
Part of the free Go course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You've Learned So Far
- Interfaces: one function, many types via implicit contracts
- Goroutines: concurrent work with go and sync.WaitGroup
- Channels: typed pipes to pass results between goroutines
- defer / panic / recover: guaranteed cleanup and crash recovery
- Error wrapping: %w, errors.Is and errors.As
- Generics: type parameters for reusable, type-safe code
- JSON: Marshal / Unmarshal with struct tags
- Modules & packages: go mod and exported names
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.
Step-by-step brief
- Define an Order struct with JSON tags for id, item, qty, price.
- Write total(o Order) (float64, error) that returns qty*price, but wraps a sentinel ErrEmptyOrder with %w when qty == 0.
- json.Unmarshal the JSON into a []Order.
- Launch a goroutine per order, sending each total result over a channel; use a WaitGroup to know when to close it.
- Collect results: skip orders where errors.Is(err, ErrEmptyOrder), sum the rest, and print a grand total.
package main
import (
"encoding/json"
"errors"
"fmt"
"sort"
"sync"
)
// ── The data ──────────────────────────────────────────────────────────
// Incoming JSON: a list of orders to process.
const ordersJSON = `[
{"id": 1, "item": "Pen", "qty": 3, "price": 1.50},
{"id": 2, "item": "Notebook","qty": 2, "price": 4.00},
{"id": 3, "item": "Stapler", "qty": 0, "price": 9.99},
{"id": 4, "item": "Marker", "qty": 5, "price": 0.80}
]`
// 1) TODO: define an Order struct with JSON tags for
// id, item, qty, price.
// var ErrEmptyOrder = ... a sentinel for qty == 0
// 2) TODO: total(o Order) (float64, error)
// return qty*price, but wrap ErrEmptyOrder if qty is 0.
func main() {
// 3) TODO: Unmarshal ordersJSON into []Order.
// 4) TODO: concurrently compute each order's total using goroutines
// and a channel; skip orders that error (qty == 0).
// 5) TODO: print each valid order's total, then the grand total.
_ = sort.Strings // (hint: you may sort output for stable display)
_ = errors.Is
_ = sync.WaitGroup{}
_ = json.Marshal
fmt.Println("build me!")
}💡 Stuck? Reveal the full solution
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.
package main
import (
"encoding/json"
"errors"
"fmt"
"sort"
"sync"
)
const ordersJSON = `[
{"id": 1, "item": "Pen", "qty": 3, "price": 1.50},
{"id": 2, "item": "Notebook","qty": 2, "price": 4.00},
{"id": 3, "item": "Stapler", "qty": 0, "price": 9.99},
{"id": 4, "item": "Marker", "qty": 5, "price": 0.80}
]`
// 1) The data shape, with JSON tags mapping keys to fields.
type Order struct {
ID int `json:"id"`
Item string `json:"item"`
Qty int `json:"qty"`
Price float64 `json:"price"`
}
// A sentinel error we can detect with errors.Is.
var ErrEmptyOrder = errors.New("order has zero quantity")
// 2) total returns qty*price, or a wrapped error for empty orders.
func total(o Order) (float64, error) {
if o.Qty == 0 {
return 0, fmt.Errorf("order %d (%s): %w", o.ID, o.Item, ErrEmptyOrder)
}
return float64(o.Qty) * o.Price, nil
}
// A small result type sent over the channel.
type result struct {
item string
value float64
err error
}
func main() {
// 3) Decode the JSON into a slice of orders.
var orders []Order
if err := json.Unmarshal([]byte(ordersJSON), &orders); err != nil {
fmt.Println("bad JSON:", err)
return
}
// 4) Fan out: one goroutine per order, results collected on a channel.
results := make(chan result, len(orders))
var wg sync.WaitGroup
for _, o := range orders {
wg.Add(1)
go func(ord Order) { // pass ord as an arg (own copy)
defer wg.Done()
v, err := total(ord)
results <- result{item: ord.Item, value: v, err: err}
}(o)
}
// Close the channel once every goroutine has reported.
go func() {
wg.Wait()
close(results)
}()
// 5) Collect, separating valid totals from skipped (empty) orders.
var lines []string
var grand float64
for r := range results {
if r.err != nil {
if errors.Is(r.err, ErrEmptyOrder) {
lines = append(lines, fmt.Sprintf("SKIP %-8s (empty order)", r.item))
continue
}
lines = append(lines, fmt.Sprintf("ERR %-8s %v", r.item, r.err))
continue
}
grand += r.value
lines = append(lines, fmt.Sprintf("OK %-8s £%.2f", r.item, r.value))
}
// Sort for stable output (goroutines finish in any order).
sort.Strings(lines)
for _, l := range lines {
fmt.Println(l)
}
fmt.Printf("GRAND TOTAL: £%.2f\n", grand)
}Why the Solution Works
- 💡 Pass o into the goroutine as ord so each gets its own copy — the loop-variable trap from the Goroutines lesson.
- 💡 The channel is buffered to len(orders) so senders never block, and a dedicated goroutine calls close(results) after wg.Wait() — the canonical "fan-out then close" shape.
- 💡 %w + errors.Is lets the collector tell "skip this empty order" apart from a genuine failure without string matching.
- 💡 Sorting the output lines makes the program deterministic for display, even though the goroutines finish in an unpredictable order.
📝 Checkpoint 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.
6. Why is the output sorted before printing?
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.
Frequently Asked Questions
Q: Is concurrency overkill for four orders?
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.
🎉 Checkpoint Complete!
- ✅ You combined JSON decoding, goroutines, a channel and a WaitGroup in one program
- ✅ You used %w + errors.Is to classify and skip empty orders cleanly
- ✅ You avoided the loop-variable trap by passing each order into its goroutine
- ✅ You made concurrent output deterministic by sorting before printing
- ✅ You can now assemble the course's concepts into a working mini-app
- ✅ Next: revisit Structs and Interfaces to go deeper on modelling data
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 `json:"qty"` 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.
Continue this course
- Previous: Modules & Packages
- Next: Constants & iota — Typed and untyped constants, const blocks, and the iota enumerator for bit flags