Cristhian Villegas
Backend12 min read2 views

Go Course #10: Final Project - REST API with Go

Go Course #10: Final Project - REST API with Go

Final Project: REST API with Go - Part 10 of 10

Go Logo

Welcome to the final article (10 of 10) in the Go Course from Scratch. Over the previous 9 articles we learned the fundamentals of the language: variables, functions, structs, interfaces, concurrency, error handling and testing. Now it is time to apply everything in a real project.

We are going to build a complete REST API to manage tasks (todo list). We will use exclusively the Go standard library (no external frameworks), which will give you a deep understanding of how APIs work in Go under the hood.

Note: This project uses Go 1.22+ which includes the new ServeMux with HTTP method support and enhanced patterns. If you use an older version, you will need to adapt the routes.

Project Setup

First, let us create the project structure following Go conventions:

bash
1# Create the project directory
2mkdir task-api
3cd task-api
4
5# Initialize the Go module
6go mod init github.com/your-username/task-api
7
8# Create the folder structure
9mkdir -p cmd/server
10mkdir -p internal/task
11mkdir -p internal/middleware

The final project structure will be:

bash
1task-api/
2  cmd/
3    server/
4      main.go          # Application entry point
5  internal/
6    task/
7      model.go         # Task struct definition
8      store.go         # In-memory storage
9      handler.go       # HTTP handlers for CRUD
10    middleware/
11      logging.go       # Logging middleware
12      cors.go          # CORS middleware
13  go.mod
Tip: The cmd/ folder contains the application entry points. The internal/ folder contains code that can only be imported by this module (Go prevents other modules from importing internal/ packages). This is a standard convention in Go projects.

Defining the Task Model

We start by defining the data structure that represents a task. Create the file internal/task/model.go:

go
1// internal/task/model.go
2package task
3
4import "time"
5
6// Status represents the state of a task.
7type Status string
8
9const (
10    StatusPending   Status = "pending"
11    StatusCompleted Status = "completed"
12)
13
14// Task represents a task in the system.
15type Task struct {
16    ID          int       "json:\"id\""
17    Title       string    "json:\"title\""
18    Description string    "json:\"description\""
19    Status      Status    "json:\"status\""
20    CreatedAt   time.Time "json:\"created_at\""
21    UpdatedAt   time.Time "json:\"updated_at\""
22}
23
24// CreateTaskRequest is the payload for creating a task.
25type CreateTaskRequest struct {
26    Title       string "json:\"title\""
27    Description string "json:\"description\""
28}
29
30// UpdateTaskRequest is the payload for updating a task.
31type UpdateTaskRequest struct {
32    Title       *string "json:\"title\""
33    Description *string "json:\"description\""
34    Status      *Status "json:\"status\""
35}
36
37// Validate checks that the creation request is valid.
38func (r CreateTaskRequest) Validate() []string {
39    var errors []string
40    if r.Title == "" {
41        errors = append(errors, "title is required")
42    }
43    if len(r.Title) > 200 {
44        errors = append(errors, "title must be 200 characters or less")
45    }
46    return errors
47}
48
49// Validate checks that the update request is valid.
50func (r UpdateTaskRequest) Validate() []string {
51    var errors []string
52    if r.Title != nil && *r.Title == "" {
53        errors = append(errors, "title cannot be empty")
54    }
55    if r.Title != nil && len(*r.Title) > 200 {
56        errors = append(errors, "title must be 200 characters or less")
57    }
58    if r.Status != nil && *r.Status != StatusPending && *r.Status != StatusCompleted {
59        errors = append(errors, "status must be pending or completed")
60    }
61    return errors
62}
Note: We use pointers (*string, *Status) in UpdateTaskRequest to distinguish between "the field was not sent" (nil) and "the field was sent empty". This is a common technique in REST APIs to support partial updates (PATCH semantics).

In-Memory Storage with sync.Mutex

To keep the project simple, we will use in-memory storage with concurrency protection. Create internal/task/store.go:

go
1// internal/task/store.go
2package task
3
4import (
5    "errors"
6    "sync"
7    "time"
8)
9
10var (
11    ErrNotFound = errors.New("task not found")
12)
13
14// Store manages in-memory task storage.
15type Store struct {
16    mu     sync.RWMutex
17    tasks  map[int]Task
18    nextID int
19}
20
21// NewStore creates a new task store.
22func NewStore() *Store {
23    return &Store{
24        tasks:  make(map[int]Task),
25        nextID: 1,
26    }
27}
28
29// GetAll returns all tasks.
30func (s *Store) GetAll() []Task {
31    s.mu.RLock()
32    defer s.mu.RUnlock()
33
34    result := make([]Task, 0, len(s.tasks))
35    for _, t := range s.tasks {
36        result = append(result, t)
37    }
38    return result
39}
40
41// GetByID returns a task by its ID.
42func (s *Store) GetByID(id int) (Task, error) {
43    s.mu.RLock()
44    defer s.mu.RUnlock()
45
46    t, ok := s.tasks[id]
47    if !ok {
48        return Task{}, ErrNotFound
49    }
50    return t, nil
51}
52
53// Create creates a new task and returns it.
54func (s *Store) Create(req CreateTaskRequest) Task {
55    s.mu.Lock()
56    defer s.mu.Unlock()
57
58    now := time.Now()
59    t := Task{
60        ID:          s.nextID,
61        Title:       req.Title,
62        Description: req.Description,
63        Status:      StatusPending,
64        CreatedAt:   now,
65        UpdatedAt:   now,
66    }
67    s.tasks[s.nextID] = t
68    s.nextID++
69    return t
70}
71
72// Update modifies an existing task.
73func (s *Store) Update(id int, req UpdateTaskRequest) (Task, error) {
74    s.mu.Lock()
75    defer s.mu.Unlock()
76
77    t, ok := s.tasks[id]
78    if !ok {
79        return Task{}, ErrNotFound
80    }
81
82    if req.Title != nil {
83        t.Title = *req.Title
84    }
85    if req.Description != nil {
86        t.Description = *req.Description
87    }
88    if req.Status != nil {
89        t.Status = *req.Status
90    }
91    t.UpdatedAt = time.Now()
92
93    s.tasks[id] = t
94    return t, nil
95}
96
97// Delete removes a task by its ID.
98func (s *Store) Delete(id int) error {
99    s.mu.Lock()
100    defer s.mu.Unlock()
101
102    if _, ok := s.tasks[id]; !ok {
103        return ErrNotFound
104    }
105    delete(s.tasks, id)
106    return nil
107}
Warning: We use sync.RWMutex instead of a regular sync.Mutex. RWMutex allows multiple simultaneous reads (RLock) but only one write at a time (Lock). This improves performance when there are many more reads than writes, which is the typical case in an API.

HTTP Handlers for CRUD

Now we implement the handlers that process HTTP requests. Create internal/task/handler.go:

go
1// internal/task/handler.go
2package task
3
4import (
5    "encoding/json"
6    "errors"
7    "net/http"
8    "strconv"
9)
10
11// Handler contains the HTTP handlers for tasks.
12type Handler struct {
13    store *Store
14}
15
16// NewHandler creates a new handler with the provided store.
17func NewHandler(store *Store) *Handler {
18    return &Handler{store: store}
19}
20
21// generic JSON response helpers
22type errorResponse struct {
23    Error   string   "json:\"error\""
24    Details []string "json:\"details,omitempty\""
25}
26
27func writeJSON(w http.ResponseWriter, status int, data interface{}) {
28    w.Header().Set("Content-Type", "application/json")
29    w.WriteHeader(status)
30    json.NewEncoder(w).Encode(data)
31}
32
33func writeError(w http.ResponseWriter, status int, msg string, details []string) {
34    writeJSON(w, status, errorResponse{Error: msg, Details: details})
35}
36
37// HandleGetAll handles GET /tasks
38func (h *Handler) HandleGetAll(w http.ResponseWriter, r *http.Request) {
39    tasks := h.store.GetAll()
40    writeJSON(w, http.StatusOK, tasks)
41}
42
43// HandleGetByID handles GET /tasks/{id}
44func (h *Handler) HandleGetByID(w http.ResponseWriter, r *http.Request) {
45    id, err := strconv.Atoi(r.PathValue("id"))
46    if err != nil {
47        writeError(w, http.StatusBadRequest, "invalid task ID", nil)
48        return
49    }
50
51    task, err := h.store.GetByID(id)
52    if err != nil {
53        if errors.Is(err, ErrNotFound) {
54            writeError(w, http.StatusNotFound, "task not found", nil)
55            return
56        }
57        writeError(w, http.StatusInternalServerError, "internal error", nil)
58        return
59    }
60
61    writeJSON(w, http.StatusOK, task)
62}
63
64// HandleCreate handles POST /tasks
65func (h *Handler) HandleCreate(w http.ResponseWriter, r *http.Request) {
66    var req CreateTaskRequest
67    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
68        writeError(w, http.StatusBadRequest, "invalid JSON body", nil)
69        return
70    }
71
72    if errs := req.Validate(); len(errs) > 0 {
73        writeError(w, http.StatusBadRequest, "validation failed", errs)
74        return
75    }
76
77    task := h.store.Create(req)
78    writeJSON(w, http.StatusCreated, task)
79}
80
81// HandleUpdate handles PUT /tasks/{id}
82func (h *Handler) HandleUpdate(w http.ResponseWriter, r *http.Request) {
83    id, err := strconv.Atoi(r.PathValue("id"))
84    if err != nil {
85        writeError(w, http.StatusBadRequest, "invalid task ID", nil)
86        return
87    }
88
89    var req UpdateTaskRequest
90    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
91        writeError(w, http.StatusBadRequest, "invalid JSON body", nil)
92        return
93    }
94
95    if errs := req.Validate(); len(errs) > 0 {
96        writeError(w, http.StatusBadRequest, "validation failed", errs)
97        return
98    }
99
100    task, err := h.store.Update(id, req)
101    if err != nil {
102        if errors.Is(err, ErrNotFound) {
103            writeError(w, http.StatusNotFound, "task not found", nil)
104            return
105        }
106        writeError(w, http.StatusInternalServerError, "internal error", nil)
107        return
108    }
109
110    writeJSON(w, http.StatusOK, task)
111}
112
113// HandleDelete handles DELETE /tasks/{id}
114func (h *Handler) HandleDelete(w http.ResponseWriter, r *http.Request) {
115    id, err := strconv.Atoi(r.PathValue("id"))
116    if err != nil {
117        writeError(w, http.StatusBadRequest, "invalid task ID", nil)
118        return
119    }
120
121    if err := h.store.Delete(id); err != nil {
122        if errors.Is(err, ErrNotFound) {
123            writeError(w, http.StatusNotFound, "task not found", nil)
124            return
125        }
126        writeError(w, http.StatusInternalServerError, "internal error", nil)
127        return
128    }
129
130    w.WriteHeader(http.StatusNoContent)
131}
Tip: r.PathValue("id") is a new function in Go 1.22+ that extracts values from route parameters. Before Go 1.22, you would have to parse the URL manually or use an external router like gorilla/mux.

Middleware: Logging and CORS

Middlewares are functions that wrap handlers to add cross-cutting functionality. Let us create two essential middlewares.

Create internal/middleware/logging.go:

go
1// internal/middleware/logging.go
2package middleware
3
4import (
5    "log"
6    "net/http"
7    "time"
8)
9
10// statusRecorder captures the response status code.
11type statusRecorder struct {
12    http.ResponseWriter
13    statusCode int
14}
15
16func (r *statusRecorder) WriteHeader(code int) {
17    r.statusCode = code
18    r.ResponseWriter.WriteHeader(code)
19}
20
21// Logging logs each HTTP request with method, path, status and duration.
22func Logging(next http.Handler) http.Handler {
23    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
24        start := time.Now()
25
26        recorder := &statusRecorder{
27            ResponseWriter: w,
28            statusCode:     http.StatusOK,
29        }
30
31        next.ServeHTTP(recorder, r)
32
33        log.Printf("%s %s %d %s",
34            r.Method,
35            r.URL.Path,
36            recorder.statusCode,
37            time.Since(start),
38        )
39    })
40}

Create internal/middleware/cors.go:

go
1// internal/middleware/cors.go
2package middleware
3
4import "net/http"
5
6// CORS adds the necessary headers for Cross-Origin Resource Sharing.
7func CORS(next http.Handler) http.Handler {
8    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
9        w.Header().Set("Access-Control-Allow-Origin", "*")
10        w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
11        w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
12
13        // Respond immediately to preflight requests
14        if r.Method == http.MethodOptions {
15            w.WriteHeader(http.StatusOK)
16            return
17        }
18
19        next.ServeHTTP(w, r)
20    })
21}

Entry Point: main.go

Finally, we wire everything together in the main file. Create cmd/server/main.go:

go
1// cmd/server/main.go
2package main
3
4import (
5    "fmt"
6    "log"
7    "net/http"
8    "os"
9
10    "github.com/your-username/task-api/internal/middleware"
11    "github.com/your-username/task-api/internal/task"
12)
13
14func main() {
15    // Configuration
16    port := os.Getenv("PORT")
17    if port == "" {
18        port = "8080"
19    }
20
21    // Create the store and handler
22    store := task.NewStore()
23    handler := task.NewHandler(store)
24
25    // Configure routes with the new Go 1.22+ ServeMux
26    mux := http.NewServeMux()
27
28    mux.HandleFunc("GET /tasks", handler.HandleGetAll)
29    mux.HandleFunc("GET /tasks/{id}", handler.HandleGetByID)
30    mux.HandleFunc("POST /tasks", handler.HandleCreate)
31    mux.HandleFunc("PUT /tasks/{id}", handler.HandleUpdate)
32    mux.HandleFunc("DELETE /tasks/{id}", handler.HandleDelete)
33
34    // Health check
35    mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
36        w.Header().Set("Content-Type", "application/json")
37        w.Write([]byte("{"status":"ok"}"))
38    })
39
40    // Apply middlewares
41    var h http.Handler = mux
42    h = middleware.Logging(h)
43    h = middleware.CORS(h)
44
45    // Start the server
46    addr := fmt.Sprintf(":%s", port)
47    log.Printf("Server started at http://localhost%s", addr)
48    log.Printf("Available endpoints:")
49    log.Printf("  GET    /tasks       - List all tasks")
50    log.Printf("  GET    /tasks/{id}  - Get a task by ID")
51    log.Printf("  POST   /tasks       - Create a new task")
52    log.Printf("  PUT    /tasks/{id}  - Update a task")
53    log.Printf("  DELETE /tasks/{id}  - Delete a task")
54    log.Printf("  GET    /health      - Health check")
55
56    if err := http.ListenAndServe(addr, h); err != nil {
57        log.Fatalf("Failed to start server: %v", err)
58    }
59}

Testing the API with curl

Once the server is running, you can test it with curl. First, start the server:

bash
1# Start the server
2go run cmd/server/main.go

In another terminal, test each endpoint:

bash
1# 1. Create a task
2curl -X POST http://localhost:8080/tasks   -H "Content-Type: application/json"   -d '{"title":"Learn Go","description":"Complete the Go course from scratch"}'
3
4# Response:
5# {"id":1,"title":"Learn Go","description":"Complete the Go course from scratch",
6#  "status":"pending","created_at":"2026-04-05T10:00:00Z","updated_at":"2026-04-05T10:00:00Z"}
7
8# 2. Create another task
9curl -X POST http://localhost:8080/tasks   -H "Content-Type: application/json"   -d '{"title":"Build an API","description":"Apply what we learned in a real project"}'
10
11# 3. List all tasks
12curl http://localhost:8080/tasks
13
14# 4. Get a task by ID
15curl http://localhost:8080/tasks/1
16
17# 5. Update a task
18curl -X PUT http://localhost:8080/tasks/1   -H "Content-Type: application/json"   -d '{"status":"completed"}'
19
20# 6. Delete a task
21curl -X DELETE http://localhost:8080/tasks/2
22
23# 7. Verify it was deleted
24curl http://localhost:8080/tasks
25
26# 8. Health check
27curl http://localhost:8080/health
Tip: If you do not have curl installed, you can use tools like Postman, Insomnia or the REST Client extension for VS Code to test your API.

Error Handling and JSON Responses

Notice how our project handles errors consistently. Every error returns a JSON object with a standard format:

go
1// Standard error response
2// {"error":"error description","details":["detail 1","detail 2"]}
3
4// Example: trying to get a task that does not exist
5// GET /tasks/999
6// Status: 404
7// {"error":"task not found"}
8
9// Example: sending invalid JSON
10// POST /tasks with body "this is not json"
11// Status: 400
12// {"error":"invalid JSON body"}
13
14// Example: validation failure
15// POST /tasks with body {"title":""}
16// Status: 400
17// {"error":"validation failed","details":["title is required"]}

This pattern of consistent responses is fundamental in professional APIs. Clients of your API always know what format to expect, both for successful responses and errors.

Note: In a production API, you would also want to add a numeric code field or a unique error identifier so that clients can handle specific errors programmatically without relying on the message text.

Next Steps

This project is an excellent starting point, but there is much more you can add to bring it to production level:

Database

Replace the in-memory storage with a real database like PostgreSQL using the database/sql package or an ORM like GORM. This allows data to persist between server restarts.

Popular Frameworks

Although the Go standard library is very powerful, frameworks like Gin, Echo or Fiber offer additional features such as automatic parameter binding, built-in validation, route groups and pre-built middleware.

Authentication

Add authentication with JWT (JSON Web Tokens) to protect the endpoints. Each user should only be able to see and modify their own tasks.

Docker and Deployment

Create a Dockerfile to containerize your API and deploy it to services like AWS ECS, Google Cloud Run or a VPS with Docker Compose.

API Documentation with Swagger

Use tools like swag to generate interactive API documentation automatically from comments in your code.

Course Conclusion

Congratulations! You have completed the Go Course from Scratch in 10 articles. Let us do a quick recap of everything you learned:

  • Article 1: Installation and environment setup for Go
  • Article 2: Variables, data types and operators
  • Article 3: Control structures: if, switch, for
  • Article 4: Functions, parameters and multiple return values
  • Article 5: Structs, methods and composition
  • Article 6: Interfaces and polymorphism
  • Article 7: Concurrency: goroutines and channels
  • Article 8: Error handling and packages
  • Article 9: Testing and benchmarking
  • Article 10: Final project — Complete REST API

You now have solid foundations to keep learning Go and build real applications. Go is a language that shines in web servers, microservices, command-line tools and distributed systems. The largest companies in the world (Google, Uber, Netflix, Docker, Kubernetes) use Go in production.

Tip: The best way to keep learning is to build projects. Some ideas: a URL shortener, a real-time chat with WebSockets, a CLI for automating tasks, or a web scraper. Each project will teach you something new.

Thank you for following this course and best of luck on your journey as a Go developer!

Share:
CV

Cristhian Villegas

Software Engineer specializing in Java, Spring Boot, Angular & AWS. Building scalable distributed systems with clean architecture.

Comments

Sign in to leave a comment

No comments yet. Be the first!

Related Articles