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
Taskmodel and aStore encoding/json— struct tags,Marshal/Unmarshal,MarshalIndent,omitemptytime.Timeround-tripping (RFC 3339) · zero-value-friendly design- Robust file I/O: missing file = empty store · atomic write via temp file +
os.Rename
📖 Reading / Sources¶
-
pkg.go.dev/encoding/json— esp. tags &Marshal - Go blog — JSON and Go
-
pkg.go.dev/os—ReadFile,WriteFile,Rename,CreateTemp - Re-read [[structs-tags]] notes
📝 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.
omitemptydrops zero-valued fields from output; caution: it can't tell "explicit false/0" from "unset" — don't useomitemptyonDoneiffalseis 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.Timemarshals to RFC 3339 automatically (it implementsjson.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.Renameover the target.Renameis 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. UseMarshal(compact) for wire formats.- Decode an unknown file defensively: a
json.Unmarshalinto a slice of a known type ignores extra keys by default (useDecoder.DisallowUnknownFieldsif 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 inexamples/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 toTitlewith ajson:"title"tag. - Used
os.WriteFiledirectly and a Ctrl-C mid-write left a truncated file. Switched to temp-file +os.Renamefor atomicity. - Compared
CreatedAt == wantin 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)¶
-
Q: Why won't an unexported struct field show up in
json.Marshaloutput?
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. -
Q: Why write to a temp file and
os.Renameinstead 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/Encodervs. whole-fileMarshal/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 theflagpackage.
| Time spent | Difficulty | Confidence |
|---|---|---|
| 95 min | 🟦🟦⬜⬜⬜ | 🟦🟦🟦⬜⬜ |
Suggested commit: feat(taskcli): task model and atomic JSON store (day 025)