Skip to content

Day 025 — Project: Model + JSON Storage

Month 1 · Week 4 · ⬅ Day 024 · Day 026 ➡ · Journal index

🎯 Learning Objective

Kick off the Week-4 capstone — a task CLI (taskcli) — by designing the domain model and a JSON file-backed store with correct (de)serialization and atomic writes.

📚 Topics

  • Project layout · the Task model and a Store
  • encoding/json — struct tags, Marshal/Unmarshal, MarshalIndent, omitempty
  • time.Time round-tripping (RFC 3339) · zero-value-friendly design
  • Robust file I/O: missing file = empty store · atomic write via temp file + os.Rename

📖 Reading / Sources

📝 Notes

  • Project shape (capstone lives under projects/taskcli/ in the repo):
taskcli/
  main.go            // wiring + flag parsing (Day 026)
  task.go            // Task model
  store.go           // JSON-backed Store
  store_test.go      // tests (Day 027)
  • The model: keep fields exported (json needs exported fields) and tag them:
type Task struct {
    ID        int       `json:"id"`
    Title     string    `json:"title"`
    Done      bool      `json:"done"`
    CreatedAt time.Time `json:"created_at"`
}
  • Struct tags map Go names ↔ JSON keys. omitempty drops zero-valued fields from output; caution: it can't tell "explicit false/0" from "unset" — don't use omitempty on Done if false is meaningful. Links to [[structs-tags]].
  • Only exported fields are (un)marshaled. Unexported fields are silently skipped — a classic "why is my JSON empty?" bug.
  • time.Time marshals to RFC 3339 automatically (it implements json.Marshaler); it round-trips cleanly. Compare times with .Equal, not ==, because of monotonic-clock/location nuances.
  • Store as a thin layer over a slice + the file path. Loading a missing file should yield an empty store, not an error — check errors.Is(err, os.ErrNotExist). Connects to the [[errors]] toolkit.
  • Atomic save: write to a temp file in the same directory, then os.Rename over the target. Rename is atomic on the same filesystem, so a crash mid-write never corrupts the real file or leaves a half-written JSON.
  • json.MarshalIndent(v, "", " ") makes the on-disk file human-readable/diffable. Use Marshal (compact) for wire formats.
  • Decode an unknown file defensively: a json.Unmarshal into a slice of a known type ignores extra keys by default (use Decoder.DisallowUnknownFields if you want strictness).

💻 Code Examples

// Save writes the store atomically: temp file in the same dir, then rename.
func (s *Store) Save() error {
    data, err := json.MarshalIndent(s.tasks, "", "  ")
    if err != nil {
        return fmt.Errorf("encode tasks: %w", err) // wrap for an inspectable chain
    }
    dir := filepath.Dir(s.path)
    tmp, err := os.CreateTemp(dir, ".taskcli-*.tmp")
    if err != nil {
        return fmt.Errorf("create temp: %w", err)
    }
    tmpName := tmp.Name()
    defer os.Remove(tmpName) // no-op if the rename already moved it
    if _, err := tmp.Write(data); err != nil {
        tmp.Close()
        return fmt.Errorf("write temp: %w", err)
    }
    if err := tmp.Close(); err != nil {
        return fmt.Errorf("close temp: %w", err)
    }
    return os.Rename(tmpName, s.path) // atomic swap
}

// Load returns an empty store when the file doesn't exist yet.
func Load(path string) (*Store, error) {
    data, err := os.ReadFile(path)
    if errors.Is(err, os.ErrNotExist) {
        return &Store{path: path}, nil
    }
    if err != nil {
        return nil, fmt.Errorf("read %s: %w", path, err)
    }
    var tasks []Task
    if err := json.Unmarshal(data, &tasks); err != nil {
        return nil, fmt.Errorf("decode %s: %w", path, err)
    }
    return &Store{path: path, tasks: tasks}, nil
}

This is project code (multi-file, lives under projects/taskcli/). The JSON-tag and round-trip mechanics are also exercised stdlib-only in examples/month-01/structs-tags.

🏋️ Exercises / Practice

Exercise Status Link
Round-trip Task through Marshal/Unmarshal (project store_test.go, Day 027)
Verify missing-file load returns empty store (project store_test.go, Day 027)

🐛 Mistakes Made

  • Named a field title (unexported) → it never appeared in the JSON. Exported it to Title with a json:"title" tag.
  • Used os.WriteFile directly and a Ctrl-C mid-write left a truncated file. Switched to temp-file + os.Rename for atomicity.
  • Compared CreatedAt == want in an early test and it flaked; .Equal (and stripping the monotonic clock via .Round(0)) fixed it.

❓ Open Questions

  • File locking for concurrent CLI invocations — overkill for a single-user CLI? (Yes for now; note it for a server version.)

🧠 Active Recall (answer without looking)

  1. Q: Why won't an unexported struct field show up in json.Marshal output?

    A `encoding/json` uses reflection and can only access **exported** (capitalized) fields. Unexported fields are skipped silently — export them and add a `json:"..."` tag to control the key.

  2. Q: Why write to a temp file and os.Rename instead of writing the target directly?

    A `os.Rename` within the same filesystem is **atomic**: readers see either the old file or the fully-written new one, never a half-written file. A crash mid-write can't corrupt the real data.

🪶 Feynman Reflection

The model is a plain struct with JSON tags; the store is a tiny wrapper that knows how to load a slice of those structs from a file and save them back safely. The two tricky bits are treating "no file yet" as an empty store (an expected case, not an error) and writing atomically so a crash never corrupts my data.

🕳️ Knowledge Gaps

  • Streaming large JSON with json.Decoder/Encoder vs. whole-file Marshal/Unmarshal — fine at CLI scale, revisit for big data.

✅ Summary

I designed the Task model with correct JSON tags and built a Store that loads gracefully from a missing file and saves atomically with proper error wrapping.

⏭️ Next Steps / Prep for Tomorrow

  • Day 026: wire up CLI subcommands (add, list, done, rm) with the flag package.

Time spent Difficulty Confidence
95 min 🟦🟦⬜⬜⬜ 🟦🟦🟦⬜⬜

Suggested commit: feat(taskcli): task model and atomic JSON store (day 025)