Capstone Projects
The best way to solidify your Go knowledge is to build real projects. This chapter presents five carefully designed capstone projects that each exercise different combinations of the concepts you've learned throughout the course.
π οΈ Project 1: CLI Task Manager
Concepts: Structs, slices, JSON file I/O, os.Args parsing, error handling, time package
- Add tasks:
task add "Buy groceries" - List tasks with status:
task list - Mark done:
task done 3 - Delete:
task delete 3 - Persist to
~/.tasks.json - Filter by status:
task list --done - Due dates:
task add "Report" --due 2024-12-31
// Starter skeleton β complete the implementation!
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
)
type Status string
const (
StatusPending Status = "pending"
StatusDone Status = "done"
)
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Status Status `json:"status"`
CreatedAt time.Time `json:"created_at"`
DueDate *time.Time `json:"due_date,omitempty"`
}
type Store struct {
Tasks []Task `json:"tasks"`
NextID int `json:"next_id"`
}
func loadStore() (*Store, error) {
path := filepath.Join(os.Getenv("HOME"), ".tasks.json")
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return &Store{NextID: 1}, nil
}
if err != nil { return nil, err }
var s Store
return &s, json.Unmarshal(data, &s)
}
func (s *Store) Save() error {
path := filepath.Join(os.Getenv("HOME"), ".tasks.json")
data, err := json.MarshalIndent(s, "", " ")
if err != nil { return err }
return os.WriteFile(path, data, 0644)
}
func (s *Store) Add(title string) *Task {
t := Task{
ID: s.NextID, Title: title,
Status: StatusPending, CreatedAt: time.Now(),
}
s.Tasks = append(s.Tasks, t)
s.NextID++
return &s.Tasks[len(s.Tasks)-1]
}
func main() {
store, err := loadStore()
if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err); os.Exit(1) }
if len(os.Args) < 2 {
fmt.Println("Usage: task [args]")
os.Exit(1)
}
switch os.Args[1] {
case "add":
if len(os.Args) < 3 { fmt.Println("Usage: task add "); os.Exit(1) }
task := store.Add(os.Args[2])
store.Save()
fmt.Printf("β
Added task #%d: %s\n", task.ID, task.Title)
case "list":
if len(store.Tasks) == 0 { fmt.Println("No tasks."); return }
for _, t := range store.Tasks {
icon := "β¬"
if t.Status == StatusDone { icon = "β
" }
fmt.Printf("%s #%d %s\n", icon, t.ID, t.Title)
}
// TODO: implement done, delete, filter...
default:
fmt.Printf("Unknown command: %s\n", os.Args[1])
}
} π·οΈ Project 2: Concurrent Web Scraper
Concepts: Goroutines, channels, WaitGroup, context/cancellation, HTTP client, HTML parsing, rate limiting
- Accept a list of URLs
- Fetch them concurrently with a configurable worker pool (N workers)
- Parse <title> and <h1> tags from each page
- Respect rate limits (N requests per second)
- Handle timeouts per request with context
- Collect and report results (successes, failures, timing)
- Output as JSON or table
package main
import (
"context"
"fmt"
"net/http"
"sync"
"time"
)
type ScrapeResult struct {
URL string
Title string
Duration time.Duration
Error error
}
type Scraper struct {
workers int
rateLimit time.Duration
httpClient *http.Client
}
func NewScraper(workers int, requestsPerSec int) *Scraper {
return &Scraper{
workers: workers,
rateLimit: time.Second / time.Duration(requestsPerSec),
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
func (s *Scraper) ScrapeAll(ctx context.Context, urls []string) []ScrapeResult {
jobs := make(chan string, len(urls))
results := make(chan ScrapeResult, len(urls))
ticker := time.NewTicker(s.rateLimit)
defer ticker.Stop()
var wg sync.WaitGroup
for i := 0; i < s.workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for url := range jobs {
select {
case <-ctx.Done():
return
case <-ticker.C:
results <- s.scrapeOne(ctx, url)
}
}
}()
}
for _, url := range urls { jobs <- url }
close(jobs)
go func() { wg.Wait(); close(results) }()
var out []ScrapeResult
for r := range results { out = append(out, r) }
return out
}
func (s *Scraper) scrapeOne(ctx context.Context, url string) ScrapeResult {
start := time.Now()
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return ScrapeResult{URL: url, Error: err, Duration: time.Since(start)}
}
resp, err := s.httpClient.Do(req)
if err != nil {
return ScrapeResult{URL: url, Error: err, Duration: time.Since(start)}
}
defer resp.Body.Close()
// TODO: parse HTML with golang.org/x/net/html
return ScrapeResult{URL: url, Title: resp.Status, Duration: time.Since(start)}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
scraper := NewScraper(5, 10) // 5 workers, 10 req/sec
urls := []string{
"https://go.dev",
"https://pkg.go.dev",
"https://golang.org/doc",
}
results := scraper.ScrapeAll(ctx, urls)
for _, r := range results {
if r.Error != nil {
fmt.Printf("β %s β %v\n", r.URL, r.Error)
} else {
fmt.Printf("β
%s (%v) β %s\n", r.URL, r.Duration.Round(time.Millisecond), r.Title)
}
}
}ποΈ Project 3: In-Memory Cache with TTL
Concepts: Generics, sync.RWMutex, goroutines, time, interfaces
package cache
import (
"sync"
"time"
)
type item[V any] struct {
value V
expiresAt time.Time
}
func (i item[V]) isExpired() bool {
return !i.expiresAt.IsZero() && time.Now().After(i.expiresAt)
}
// Cache is a generic thread-safe in-memory cache with optional TTL
type Cache[K comparable, V any] struct {
mu sync.RWMutex
items map[K]item[V]
done chan struct{}
}
// New creates a cache that runs a background janitor to evict expired items
func New[K comparable, V any](cleanupInterval time.Duration) *Cache[K, V] {
c := &Cache[K, V]{
items: make(map[K]item[V]),
done: make(chan struct{}),
}
go c.janitor(cleanupInterval)
return c
}
func (c *Cache[K, V]) Set(key K, value V, ttl time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
var exp time.Time
if ttl > 0 { exp = time.Now().Add(ttl) }
c.items[key] = item[V]{value: value, expiresAt: exp}
}
func (c *Cache[K, V]) Get(key K) (V, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
it, ok := c.items[key]
if !ok || it.isExpired() {
var zero V
return zero, false
}
return it.value, true
}
func (c *Cache[K, V]) Delete(key K) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.items, key)
}
func (c *Cache[K, V]) Len() int {
c.mu.RLock()
defer c.mu.RUnlock()
return len(c.items)
}
func (c *Cache[K, V]) Close() { close(c.done) }
func (c *Cache[K, V]) janitor(interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
c.mu.Lock()
for k, v := range c.items {
if v.isExpired() { delete(c.items, k) }
}
c.mu.Unlock()
case <-c.done:
return
}
}
}
// Usage example:
// c := cache.New[string, []byte](5 * time.Minute)
// c.Set("key", []byte("value"), 30*time.Second)
// val, ok := c.Get("key")π¬ Project 4: Async Job Queue
Concepts: Channels, goroutines, context, error handling, retry with backoff, interfaces
package queue
import (
"context"
"fmt"
"log"
"math"
"time"
)
type JobFunc func(ctx context.Context) error
type Job struct {
ID string
Name string
Execute JobFunc
MaxRetry int
}
type Queue struct {
jobs chan Job
workers int
ctx context.Context
cancel context.CancelFunc
}
func New(workers, capacity int) *Queue {
ctx, cancel := context.WithCancel(context.Background())
return &Queue{
jobs: make(chan Job, capacity),
workers: workers,
ctx: ctx,
cancel: cancel,
}
}
func (q *Queue) Start() {
for i := 0; i < q.workers; i++ {
go q.worker(i)
}
}
func (q *Queue) Submit(job Job) error {
select {
case q.jobs <- job:
return nil
case <-q.ctx.Done():
return fmt.Errorf("queue is stopped")
default:
return fmt.Errorf("queue is full")
}
}
func (q *Queue) Stop() {
q.cancel()
close(q.jobs)
}
func (q *Queue) worker(id int) {
for job := range q.jobs {
q.process(id, job)
}
}
func (q *Queue) process(workerID int, job Job) {
maxRetry := job.MaxRetry
if maxRetry == 0 { maxRetry = 3 }
for attempt := 0; attempt <= maxRetry; attempt++ {
if attempt > 0 {
// Exponential backoff: 1s, 2s, 4s, ...
wait := time.Duration(math.Pow(2, float64(attempt-1))) * time.Second
log.Printf("[worker %d] retrying job %s in %v", workerID, job.ID, wait)
select {
case <-time.After(wait):
case <-q.ctx.Done():
return
}
}
err := job.Execute(q.ctx)
if err == nil {
log.Printf("[worker %d] job %s completed", workerID, job.ID)
return
}
log.Printf("[worker %d] job %s attempt %d failed: %v", workerID, job.ID, attempt+1, err)
}
log.Printf("[worker %d] job %s PERMANENTLY FAILED after %d attempts", workerID, job.ID, maxRetry+1)
}
// Usage:
// q := queue.New(5, 100)
// q.Start()
// q.Submit(queue.Job{ID:"1", Name:"send-email", Execute: sendEmailFn, MaxRetry: 3})
// q.Stop()ποΈ Project 5: Microservice β URL Shortener
Concepts: REST API, middleware, generics, error handling, concurrency, graceful shutdown, testing
POST /shortenβ Create a short URL from a long URLGET /{code}β Redirect to original URLGET /stats/{code}β View click statistics- In-memory storage with TTL cache (use Project 3!)
- Logging middleware (structured JSON logs)
- Rate limiting middleware (N requests/IP/second)
- Graceful shutdown (use pattern from Chapter 19)
- 100% handler test coverage with httptest
- Docker-ready: Dockerfile + docker-compose.yml
// main.go β URL Shortener microservice
package main
import (
"context"
"crypto/rand"
"encoding/json"
"fmt"
"log/slog"
"math/big"
"net/http"
"os"
"os/signal"
"sync"
"sync/atomic"
"syscall"
"time"
)
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
func generateCode(n int) (string, error) {
b := make([]byte, n)
for i := range b {
idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
if err != nil { return "", err }
b[i] = charset[idx.Int64()]
}
return string(b), nil
}
type URLEntry struct {
Code string `json:"code"`
LongURL string `json:"long_url"`
Clicks int64 `json:"clicks"`
CreatedAt time.Time `json:"created_at"`
}
type Store struct {
mu sync.RWMutex
codes map[string]*URLEntry
}
func NewStore() *Store { return &Store{codes: make(map[string]*URLEntry)} }
func (s *Store) Add(long string) (*URLEntry, error) {
code, err := generateCode(6)
if err != nil { return nil, err }
entry := &URLEntry{Code: code, LongURL: long, CreatedAt: time.Now()}
s.mu.Lock()
s.codes[code] = entry
s.mu.Unlock()
return entry, nil
}
func (s *Store) Get(code string) (*URLEntry, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
e, ok := s.codes[code]
return e, ok
}
type Server struct {
store *Store
logger *slog.Logger
mux *http.ServeMux
}
func NewServer(logger *slog.Logger) *Server {
s := &Server{store: NewStore(), logger: logger, mux: http.NewServeMux()}
s.mux.HandleFunc("POST /shorten", s.handleShorten)
s.mux.HandleFunc("GET /{code}", s.handleRedirect)
s.mux.HandleFunc("GET /stats/{code}", s.handleStats)
return s
}
func (s *Server) handleShorten(w http.ResponseWriter, r *http.Request) {
var req struct{ URL string `json:"url"` }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.URL == "" {
http.Error(w, `{"error":"invalid url"}`, http.StatusBadRequest)
return
}
entry, err := s.store.Add(req.URL)
if err != nil {
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
resp := map[string]string{
"code": entry.Code,
"short_url": fmt.Sprintf("http://localhost:8080/%s", entry.Code),
"long_url": entry.LongURL,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(resp)
s.logger.Info("shortened", "code", entry.Code, "url", req.URL)
}
func (s *Server) handleRedirect(w http.ResponseWriter, r *http.Request) {
code := r.PathValue("code")
entry, ok := s.store.Get(code)
if !ok {
http.NotFound(w, r)
return
}
atomic.AddInt64(&entry.Clicks, 1)
http.Redirect(w, r, entry.LongURL, http.StatusMovedPermanently)
}
func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
code := r.PathValue("code")
entry, ok := s.store.Get(code)
if !ok { http.NotFound(w, r); return }
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(entry)
}
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
srv := NewServer(logger)
httpServer := &http.Server{
Addr: ":8080",
Handler: srv.mux,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
go func() {
logger.Info("server starting", "addr", httpServer.Addr)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("server error", "err", err)
os.Exit(1)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
logger.Info("shutting down gracefully...")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
httpServer.Shutdown(ctx)
logger.Info("server exited")
}πΊοΈ What to Learn Next
| Topic | Resource | Why |
|---|---|---|
| Database (sqlx, pgx) | github.com/jmoiron/sqlx | Production apps need persistence |
| gRPC | grpc.io/docs/languages/go | Microservice communication |
| OpenTelemetry | opentelemetry.io/docs/languages/go | Distributed tracing, metrics |
| Docker + Kubernetes | docs.docker.com | Deploy Go services |
| kafka-go, NATS | github.com/segmentio/kafka-go | Async messaging |
| Prometheus metrics | github.com/prometheus/client_golang | Observability |
| Wire / uber-fx | github.com/google/wire | Dependency injection |
| pprof profiling | pkg.go.dev/net/http/pprof | Performance analysis |
| Effective Go | go.dev/doc/effective_go | Idiomatic patterns from Go authors |
| 100 Go Mistakes | 100go.co | Avoid common pitfalls |
Congratulations!
You have completed the Go Mastery course. You now have deep, production-grade knowledge of the Go programming language β from zero values and memory layouts to concurrency, generics, and building real APIs.