πŸ† Chapter 20 of 20

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.

⏱ β€”πŸ“Š Production

πŸ› οΈ Project 1: CLI Task Manager

Concepts: Structs, slices, JSON file I/O, os.Args parsing, error handling, time package

πŸ“‹ Features to implement:
  • 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
go
// 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])
    }
}</code></pre></div>
  </section>

  <!-- Project 2 -->
  <section id="project2">
    <h2>πŸ•·οΈ Project 2: Concurrent Web Scraper</h2>
    <p><strong>Concepts:</strong> Goroutines, channels, WaitGroup, context/cancellation, HTTP client, HTML parsing, rate limiting</p>
    <div class="objectives"><div class="objectives-title"><span>πŸ“‹</span> Features to implement:</div>
      <ul>
        <li>Accept a list of URLs</li>
        <li>Fetch them concurrently with a configurable worker pool (N workers)</li>
        <li>Parse <title> and <h1> tags from each page</li>
        <li>Respect rate limits (N requests per second)</li>
        <li>Handle timeouts per request with context</li>
        <li>Collect and report results (successes, failures, timing)</li>
        <li>Output as JSON or table</li>
      </ul>
    </div>

    <div class="code-block"><div class="code-header"><span class="code-lang">go</span><button class="copy-btn"><span>⎘</span> Copy</button></div>
    <pre><code class="go">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)
        }
    }
}</code></pre></div>
  </section>

  <!-- Project 3 -->
  <section id="project3">
    <h2>πŸ—„οΈ Project 3: In-Memory Cache with TTL</h2>
    <p><strong>Concepts:</strong> Generics, sync.RWMutex, goroutines, time, interfaces</p>

    <div class="code-block"><div class="code-header"><span class="code-lang">go</span><button class="copy-btn"><span>⎘</span> Copy</button></div>
    <pre><code class="go">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")</code></pre></div>
  </section>

  <!-- Project 4 -->
  <section id="project4">
    <h2>πŸ“¬ Project 4: Async Job Queue</h2>
    <p><strong>Concepts:</strong> Channels, goroutines, context, error handling, retry with backoff, interfaces</p>

    <div class="code-block"><div class="code-header"><span class="code-lang">go</span><button class="copy-btn"><span>⎘</span> Copy</button></div>
    <pre><code class="go">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()</code></pre></div>
  </section>

  <!-- Project 5 -->
  <section id="project5">
    <h2>πŸ—οΈ Project 5: Microservice β€” URL Shortener</h2>
    <p><strong>Concepts:</strong> REST API, middleware, generics, error handling, concurrency, graceful shutdown, testing</p>
    <div class="objectives"><div class="objectives-title"><span>πŸ“‹</span> Full feature list:</div>
      <ul>
        <li><code>POST /shorten</code> β€” Create a short URL from a long URL</li>
        <li><code>GET /{code}</code> β€” Redirect to original URL</li>
        <li><code>GET /stats/{code}</code> β€” View click statistics</li>
        <li>In-memory storage with TTL cache (use Project 3!)</li>
        <li>Logging middleware (structured JSON logs)</li>
        <li>Rate limiting middleware (N requests/IP/second)</li>
        <li>Graceful shutdown (use pattern from Chapter 19)</li>
        <li>100% handler test coverage with httptest</li>
        <li>Docker-ready: Dockerfile + docker-compose.yml</li>
      </ul>
    </div>

    <div class="code-block"><div class="code-header"><span class="code-lang">go</span><button class="copy-btn"><span>⎘</span> Copy</button></div>
    <pre><code class="go">// 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")
}</code></pre></div>
  </section>

  <!-- What to Learn Next -->
  <section id="next">
    <h2>πŸ—ΊοΈ What to Learn Next</h2>

    <div class="table-wrapper"><table>
      <thead><tr><th>Topic</th><th>Resource</th><th>Why</th></tr></thead>
      <tbody>
        <tr><td>Database (sqlx, pgx)</td><td>github.com/jmoiron/sqlx</td><td>Production apps need persistence</td></tr>
        <tr><td>gRPC</td><td>grpc.io/docs/languages/go</td><td>Microservice communication</td></tr>
        <tr><td>OpenTelemetry</td><td>opentelemetry.io/docs/languages/go</td><td>Distributed tracing, metrics</td></tr>
        <tr><td>Docker + Kubernetes</td><td>docs.docker.com</td><td>Deploy Go services</td></tr>
        <tr><td>kafka-go, NATS</td><td>github.com/segmentio/kafka-go</td><td>Async messaging</td></tr>
        <tr><td>Prometheus metrics</td><td>github.com/prometheus/client_golang</td><td>Observability</td></tr>
        <tr><td>Wire / uber-fx</td><td>github.com/google/wire</td><td>Dependency injection</td></tr>
        <tr><td>pprof profiling</td><td>pkg.go.dev/net/http/pprof</td><td>Performance analysis</td></tr>
        <tr><td>Effective Go</td><td>go.dev/doc/effective_go</td><td>Idiomatic patterns from Go authors</td></tr>
        <tr><td>100 Go Mistakes</td><td>100go.co</td><td>Avoid common pitfalls</td></tr>
      </tbody>
    </table></div>
  </section>

  <!-- Completion -->
  <section id="completion">
    <div style="text-align:center;padding:3rem 1rem;background:linear-gradient(135deg,var(--go-blue)15,var(--go-green)85);border-radius:var(--radius-xl);margin:2rem 0;">
      <div style="font-size:3rem;margin-bottom:0.75rem;">πŸ†</div>
      <h2 style="color:#fff;font-size:2rem;margin-bottom:0.5rem;border:none;">Congratulations!</h2>
      <p style="color:rgba(255,255,255,0.9);font-size:1.1rem;max-width:500px;margin:0 auto 1.5rem;">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.</p>
      <div style="display:flex;gap:1rem;justify-content:center;flex-wrap:wrap;">
        <a href="../index.html" style="background:rgba(255,255,255,0.2);color:#fff;padding:0.65rem 1.4rem;border-radius:var(--radius-md);font-weight:600;text-decoration:none;border:2px solid rgba(255,255,255,0.4);">🏠 Back to Home</a>
        <a href="01-introduction.html" style="background:#fff;color:var(--go-blue);padding:0.65rem 1.4rem;border-radius:var(--radius-md);font-weight:600;text-decoration:none;">πŸ”„ Start Again</a>
      </div>
    </div>
  </section>

  <nav class="chapter-nav">
    <a href="19-rest-api.html" class="chapter-nav-item prev"><span class="chapter-nav-arrow">←</span><div><div class="chapter-nav-label">Previous</div><div class="chapter-nav-title">REST API</div></div></a>
    <a href="../index.html" class="chapter-nav-item next"><span class="chapter-nav-arrow">🏠</span><div><div class="chapter-nav-label">Course Complete!</div><div class="chapter-nav-title">Return Home</div></div></a>
  </nav>
  <button class="mark-done-btn" data-chapter="ch20" style="display:inline-flex;align-items:center;gap:0.5rem;padding:0.6rem 1.25rem;background:var(--go-green);color:#fff;border:none;border-radius:var(--radius-md);font-family:var(--font-sans);font-weight:600;font-size:0.875rem;cursor:pointer;margin-bottom:2rem;">βœ“ Mark Complete</button>
</div></main></div>

<nav class="on-page-toc"><div class="on-page-toc-title">On this page</div><a href="#project1">Project 1: CLI</a><a href="#project2">Project 2: Scraper</a><a href="#project3">Project 3: Cache</a><a href="#project4">Project 4: Job Queue</a><a href="#project5">Project 5: URL Shortener</a><a href="#next">What's Next</a><a href="#completion">Completion</a></nav>
<button class="back-to-top">↑</button>
<script src="../script.js"></script>
</body>
</html>