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

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.
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:
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:
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
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:
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}
*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:
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}
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:
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}
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:
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:
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:
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:
1# Start the server
2go run cmd/server/main.go
In another terminal, test each endpoint:
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
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:
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.
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.
Thank you for following this course and best of luck on your journey as a Go developer!
Comments
Sign in to leave a comment
No comments yet. Be the first!