Before You Begin

  • Go 1.21+ installed
  • Completion of any previous tutorial covering structs, slices, methods, user input, file I/O, and error handling (e.g., the gemstone inventory or basic note-taker)
  • Familiarity with basic terminal commands

Project Structure

We'll build a note-taking CLI called orbit that stores notes with timestamps, supports searching, and automatically saves every 5 seconds using a background goroutine. The note store will be backed by a JSON file.

$ mkdir orbit && cd orbit
$ go mod init orbit
$ touch main.go

Step 1: The Note and Store – Structs with Planet Names

We define a Note struct with fields: ID, Title, Body, CreatedAt. The store type is Asteroid (a slice of notes) with methods for Add, List, Search, Save, Load.

// main.go
package main

import (
	"encoding/json"
	"fmt"
	"os"
	"strings"
	"sync"
	"time"
)

// Note represents a single note. Fields are exported for JSON.
type Note struct {
	ID        int       `json:"id"`
	Title     string    `json:"title"`
	Body      string    `json:"body"`
	CreatedAt time.Time `json:"created_at"`
}

// Asteroid holds a collection of notes and a mutex for safe concurrent access.
type Asteroid struct {
	mu    sync.RWMutex
	Notes []Note `json:"notes"`
	nextID int
}

The Asteroid type uses a sync.RWMutex because we'll have concurrent readers (search) and writers (add, autosave).

Step 2: Methods on Asteroid – Adding, Listing, Searching

We implement CRUD operations with proper locking.

func (a *Asteroid) Add(title, body string) Note {
	a.mu.Lock()
	defer a.mu.Unlock()

	n := Note{
		ID:        a.nextID,
		Title:     title,
		Body:      body,
		CreatedAt: time.Now(),
	}
	a.nextID++
	a.Notes = append(a.Notes, n)
	return n
}

func (a *Asteroid) List() []Note {
	a.mu.RLock()
	defer a.mu.RUnlock()

	// Return a copy to prevent external mutation
	result := make([]Note, len(a.Notes))
	copy(result, a.Notes)
	return result
}

func (a *Asteroid) Search(query string) []Note {
	a.mu.RLock()
	defer a.mu.RUnlock()

	var results []Note
	lower := strings.ToLower(query)
	for _, n := range a.Notes {
		if strings.Contains(strings.ToLower(n.Title), lower) ||
			strings.Contains(strings.ToLower(n.Body), lower) {
			results = append(results, n)
		}
	}
	return results
}

Note: Search uses a linear scan – for a real app you'd index, but this is fine for learning.

Step 3: Persistence – Save and Load

We'll use JSON encoding. Save is called by the autosave goroutine, Load is called at startup.

const storeFile = "notes.json"

func (a *Asteroid) Save() error {
	a.mu.RLock()
	defer a.mu.RUnlock()

	data, err := json.MarshalIndent(a, "", "  ")
	if err != nil {
		return fmt.Errorf("marshal: %w", err)
	}
	return os.WriteFile(storeFile, data, 0644)
}

func (a *Asteroid) Load() error {
	data, err := os.ReadFile(storeFile)
	if err != nil {
		if os.IsNotExist(err) {
			return nil // first run, no file yet
		}
		return fmt.Errorf("read file: %w", err)
	}
	if err := json.Unmarshal(data, a); err != nil {
		return fmt.Errorf("unmarshal: %w", err)
	}
	// Recover nextID from existing notes
	for _, n := range a.Notes {
		if n.ID >= a.nextID {
			a.nextID = n.ID + 1
		}
	}
	return nil
}

Step 4: Autosave with Goroutine and Ticker

We create a background goroutine that listens for a stop signal and ticks every 5 seconds. The ticker is called mars (planet analog).

func startAutosave(store *Asteroid, stop chan struct{}) {
	mars := time.NewTicker(5 * time.Second)
	defer mars.Stop()

	for {
		select {
		case <-mars.C:
			if err := store.Save(); err != nil {
				fmt.Fprintf(os.Stderr, "autosave error: %v\n", err)
			}
		case <-stop:
			fmt.Println("autosave stopped")
			return
		}
	}
}

We'll also save on exit to avoid losing data.

Step 5: CLI Loop – Commands with Channels

We'll use os.Args for simplicity. Commands: add, list, search, quit. The channel venus will be used to signal the autosave goroutine to stop when the user quits.

func main() {
	store := &Asteroid{}
	if err := store.Load(); err != nil {
		fmt.Fprintf(os.Stderr, "load error: %v\n", err)
		os.Exit(1)
	}

	stop := make(chan struct{})
	go startAutosave(store, stop)

	// Main loop: read commands from stdin
	var cmd string
	for {
		fmt.Print("orbit> ")
		_, err := fmt.Scanf("%s", &cmd)
		if err != nil {
			// End of input or error
			break
		}

		switch cmd {
		case "add":
			var title, body string
			fmt.Print("Title: ")
			fmt.Scanf("%s", &title)
			fmt.Print("Body: ")
			fmt.Scanf("%s", &body) // simple, doesn't handle spaces
			note := store.Add(title, body)
			fmt.Printf("Added note #%d: %s\n", note.ID, note.Title)

		case "list":
			notes := store.List()
			if len(notes) == 0 {
				fmt.Println("No notes yet.")
				continue
			}
			for _, n := range notes {
				fmt.Printf("%d: %s (%s)\n", n.ID, n.Title, n.CreatedAt.Format("15:04"))
			}

		case "search":
			var query string
			fmt.Print("Search query: ")
			fmt.Scanf("%s", &query)
			results := store.Search(query)
			if len(results) == 0 {
				fmt.Println("No matches.")
				continue
			}
			for _, n := range results {
				fmt.Printf("%d: %s - %s\n", n.ID, n.Title, n.Body)
			}

		case "quit":
			// Stop autosave, then save one final time
			close(stop)                  // signal goroutine to stop
			if err := store.Save(); err != nil {
				fmt.Fprintf(os.Stderr, "final save error: %v\n", err)
			}
			fmt.Println("Goodbye.")
			return

		default:
			fmt.Println("Commands: add, list, search, quit")
		}
	}

	// If we exit via Ctrl+D, also save
	close(stop)
	store.Save()
}

Note: The fmt.Scanf approach is limited – for a real app use bufio.Scanner or a proper CLI library. But this keeps the example focused on concurrency.

Step 6: Concurrent Search – A Taste of Worker Pools (Optional)

To illustrate goroutines further, we can parallelize the search by splitting the note list into chunks and searching each in a goroutine, then merging results via a channel. Below is an extension you can add after the basic app works.

func (a *Asteroid) SearchConcurrent(query string, numWorkers int) []Note {
	a.mu.RLock()
	notes := make([]Note, len(a.Notes))
	copy(notes, a.Notes)
	a.mu.RUnlock()

	if len(notes) == 0 {
		return nil
	}

	type result struct {
		notes []Note
	}
	ch := make(chan result, numWorkers)

	chunkSize := (len(notes) + numWorkers - 1) / numWorkers
	lower := strings.ToLower(query)

	for i := 0; i < numWorkers; i++ {
		start := i * chunkSize
		end := start + chunkSize
		if end > len(notes) {
			end = len(notes)
		}

		go func(chunk []Note) {
			var matches []Note
			for _, n := range chunk {
				if strings.Contains(strings.ToLower(n.Title), lower) ||
					strings.Contains(strings.ToLower(n.Body), lower) {
					matches = append(matches, n)
				}
			}
			ch <- result{notes: matches}
		}(notes[start:end])
	}

	var allMatches []Note
	for i := 0; i < numWorkers; i++ {
		r := <-ch
		allMatches = append(allMatches, r.notes...)
	}
	return allMatches
}

This demonstrates the fan-out/fan-in pattern. For small note counts it's overkill, but shows the concept.

Sponsored Deal

Step 7: Running and Testing

Build and run:

$ go build -o orbit .
$ ./orbit

Try adding a few notes, then search, then quit. Check that notes.json appears and contains your data. Autosave will fire every 5 seconds – you can verify by modifying the file externally while the program runs (though it will be overwritten on next save).

Common Issues

1. Autosave goroutine leaks if program crashes. The goroutine runs until it receives from stop. If the program panics, the goroutine will be killed. You can add a defer to save before panic, but that's outside scope. For production, use a signal handler.

2. Race conditions with concurrent search. We used RWMutex correctly: Search acquires a read lock, Add acquires a write lock. The optional concurrent search copies the slice under read lock, then processes outside the lock – this is fine because we only read from the copy.

3. fmt.Scanf doesn't handle spaces in titles. If you enter a title like "my note", only "my" is read. For a real CLI, use bufio.Scanner or flag package. This is left as an exercise – you already know how to do that from previous tutorials.

4. JSON file corruption on concurrent writes. We only write from one goroutine (autosave) and the final save, so no concurrent writes. But if you add a manual save command, you'd need to serialize access. Good practice: use a single writer goroutine with a channel.

5. nextID not persisted across restarts. We fix that in Load() by scanning existing notes. Works fine.

Next Steps

  • Replace fmt.Scanf with bufio.Scanner for full line input.
  • Add a delete command.
  • Implement a web API using net/http and serve the notes over HTTP.
  • Use context.Context to pass cancellation to the autosave goroutine.

You've now leveled up from basic CLI to concurrent Go programming. The patterns here – goroutines, channels, mutexes – are the foundation of Go's power. Your orbit app is no longer just a note-taker; it's a concurrent system that can scale with your needs.