GoProjects

Go REST API Project: Build a Real Backend

TT
TopicTrick Team
Go REST API Project: Build a Real Backend

Go REST API Project: The Architectural Mirror

You have mastered variables, logic, concurrency, and web handlers. Now, it's time to pull those separate threads together. In this comprehensive module, we will build a production-inspired REST API for a simple "Task Manager" application.

By the end of this project, you will understand how a real-world Go backend is architected, from the database connection down to the JSON response.


1. The Architectural Mirror: Hexagonal Decoupling

In professional Go engineering, we avoid "Spaghetti Code" by following the Hexagonal (Ports and Adapters) mirror.

The Structural Physics

  • The Domain Mirror: The "Core" of your app (the entities and rules) should not know about the database or the web server. It exists in a pure silicon vacuum.
  • The Adapter Mirror: Your HTTP handlers and your SQL queries are "Adapters." They wrap the core logic, mapping the external world's signals (JSON, SQL) into the Internal Domain's types.
  • The Result: If you decide to switch from PostgreSQL to MongoDB, or from HTTP to gRPC, you only change the "Adapters." The "Domain Mirror" remains untouched, ensuring architectural stability over decades.

2. What You Will Build

Structure Overview

Let's look at how we'll organize our files. Each directory has a specific, single responsibility.

text
/my-api
  ├── go.mod
  ├── main.go            # Entry point and route registration
  ├── /internal
  │   ├── /models        # Struct definitions (Task, User)
  │   ├── /repository    # Database logic (Save, Fetch)
  │   └── /handlers      # HTTP logic (Decode JSON, Send Response)
  └── /pkg
      └── /database      # DB connection pooling

Step 1: Defining the Data (Models)

First, we define exactly what a Task looks like in our system.

go
package models

import "time"

type Task struct {
    ID          int       `json:"id"`
    Title       string    `json:"title"`
    Description string    `json:"description"`
    Completed   bool      `json:"completed"`
    CreatedAt   time.Time `json:"created_at"`
}

3. The Data Mirror: DTO vs. Domain Entities

In production, your Database Model and your API Response should never be the same mirror.

The Mapping Physics

  • The Security Mirror: Your database may store sensitive fields (e.g., InternalNote) that should never reach the client.
  • The DTO (Data Transfer Object): We create a specific struct for the API response. We copy data from the DB Model to the DTO selectively.
  • The Result: You prevent "Accidental Data Leakage" by ensuring every byte of JSON sent to the network was explicitly mapped through your code's transformation layer.

Step 2: Persistence (Repository Layer)

Next, we create a layer that handles talking to the database. This keeps our database code out of our web handlers.

go
package repository

import "github.com/jmoiron/sqlx"

type TaskRepository struct {
    db *sqlx.DB
}

func (r *TaskRepository) GetAll() ([]models.Task, error) {
    var tasks []models.Task
    err := r.db.Select(&tasks, "SELECT * FROM tasks ORDER BY created_at DESC")
    return tasks, err
}

Step 3: Web Logic (Handlers)

Finally, our handlers bridge the gap between the HTTP request and the repository.

go
package handlers

func (h *TaskHandler) GetTasks(w http.ResponseWriter, r *http.Request) {
    tasks, err := h.repo.GetAll()
    if err != nil {
        http.Error(w, "Failed to fetch tasks", http.StatusInternalServerError)
        return
    }
    
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(tasks)
}

The REST Blueprint

No data available
Task / FeatureMonolith (Single File)Layered Architecture
No comparison data available

Step 4: Wiring It All Together in main.go

The entry point brings every layer together, registers routes, and applies middleware.

go
package main

import (
    "log"
    "net/http"

    "github.com/yourusername/myapi/internal/handlers"
    "github.com/yourusername/myapi/internal/repository"
    "github.com/yourusername/myapi/pkg/database"
)

func main() {
    db := database.Connect()
    repo := &repository.TaskRepository{DB: db}
    handler := &handlers.TaskHandler{Repo: repo}

    mux := http.NewServeMux()
    mux.HandleFunc("/api/tasks", handler.GetTasks)
    mux.HandleFunc("/api/tasks/create", handler.CreateTask)

    // Apply middleware chain (logging + recovery)
    wrapped := LoggingMiddleware(RecoveryMiddleware(mux))

    log.Println("Server running on :8080")
    log.Fatal(http.ListenAndServe(":8080", wrapped))
}

The middleware layer is covered in depth in our Go middleware patterns guide, and the web server fundamentals are explained in Building a web server with net/http.

Adding Input Validation

Never trust data arriving from the client. Decode the request body into a struct and validate before passing to the repository.

go
func (h *TaskHandler) CreateTask(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        return
    }

    var task models.Task
    if err := json.NewDecoder(r.Body).Decode(&task); err != nil {
        http.Error(w, "Invalid JSON", http.StatusBadRequest)
        return
    }

    if task.Title == "" {
        http.Error(w, "Title is required", http.StatusUnprocessableEntity)
        return
    }

    created, err := h.Repo.Create(task)
    if err != nil {
        http.Error(w, "Failed to create task", http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(created)
}

Securing Routes

Apply authentication middleware selectively to protect write operations while keeping read endpoints public:

go
mux.Handle("/api/tasks/create", AuthMiddleware(http.HandlerFunc(handler.CreateTask)))
mux.HandleFunc("/api/tasks", handler.GetTasks) // Public

See Go security best practices for how to implement parameterized queries to prevent SQL injection in your repository layer.

Testing the API

Go's net/http/httptest package lets you test the full handler stack without starting a server. Refer to our Go testing and unit benchmarks guide for a complete table-driven test suite pattern, including repository mocking.

go
func TestGetTasksReturns200(t *testing.T) {
    req := httptest.NewRequest(http.MethodGet, "/api/tasks", nil)
    rr := httptest.NewRecorder()
    handler.GetTasks(rr, req)
    if rr.Code != http.StatusOK {
        t.Errorf("expected 200, got %d", rr.Code)
    }
}

Further Reading

Common Mistakes and How to Avoid Them

Building a Go REST API for the first time comes with predictable pitfalls. Here are the five most common mistakes and how to fix them.

1. Not closing the request body. Every r.Body must be closed after reading — the Go HTTP client does not do this automatically. A missed defer r.Body.Close() quietly exhausts file descriptors under load. Always add it immediately after you check for read errors.

2. Writing to the response after calling http.Error. Once you call http.Error(w, ...), the response status and headers are committed. Any subsequent call to w.Write or w.WriteHeader has no effect — but it also does not panic, so the bug is silent. Always return immediately after an error response.

3. Ignoring the Content-Type header. Browsers and API clients use Content-Type to decide how to parse a response. A Go handler that sends JSON without first calling w.Header().Set("Content-Type", "application/json") may cause client-side parse failures or security rejections in strict CORS environments.

4. Putting all routes in main.go. As your API grows beyond five or six endpoints, a single main.go becomes unmaintainable. Move handler registration into a dedicated routes.go file and keep main.go focused on wiring up dependencies. This matches the layered architecture described in Effective Go and the standard library net/http documentation.

5. Missing database connection pool configuration. sql.Open does not open a connection — it only validates the data source name. Call db.Ping() at startup to confirm connectivity. Then set db.SetMaxOpenConns, db.SetMaxIdleConns, and db.SetConnMaxLifetime to prevent connection exhaustion under real traffic.

FAQ

Q: Should I use a third-party router (Gin, Chi) or the standard net/http?

For most APIs, the Go 1.22+ ServeMux with method-based routing (GET /api/tasks/{id}) is sufficient and carries zero external dependencies. Reach for Chi or Gin when you need advanced features like sub-router grouping, built-in middleware bundles, or OpenAPI integration. The Go net/http package documentation documents all pattern-matching rules introduced in 1.22.

Q: How do I handle pagination in a REST API?

Accept limit and offset (or cursor) query parameters, parse them with r.URL.Query().Get("limit"), convert with strconv.Atoi, apply bounds checking (max 100 per page), and pass them as parameters to your repository query. Return a meta object in the JSON response containing the total count so clients can render page controls.

Distinguish between operational errors (invalid input, not found) and unexpected errors (database failure). For client errors, return a structured JSON body: {"error": "title is required"} with the appropriate 4xx status. For server errors, return a generic {"error": "internal server error"} with 500 — never expose stack traces or database error messages to clients. Log the full detail server-side using the Go standard log or slog packages.


Phase 19: REST Project Architecture Mastery Checklist

  • Verify Layer Boundaries: Ensure no generic net/http logic reaches the repository layer. All translation must happen in the handler or service mirrors.
  • Audit DTO Selection: Identify if any sensitive database fields are being marshalled into JSON. Refactor to dedicated Response DTOs to protect the confidentiality mirror.
  • Implement Dependency Injection: Pass the Repository and Service instances to the Handler struct via its constructor rather than using global state.
  • Test Integration Flow: Use httptest to verify the "End-to-End" mirror of your application, from the incoming HTTP request to the final database transaction.
  • Use Interface Mocks: Define interfaces for your repositories so you can swap real database IO for a memory-based mock mirror during unit testing.

Read next: Go Database & GORM: The Persistence Mirror →


Next Steps

Congratulations! You have just architected a real-world web application. But as your user base grows, your database calls might become a bottleneck. In our next tutorial, we will explore Caching in Go with Redis, learning how to speed up your API by storing frequently accessed data in memory.

Common Mistakes When Building a Go REST API

1. Ignoring error returns from the router and server http.ListenAndServe returns an error that is frequently ignored. Always wrap it: log.Fatal(http.ListenAndServe(":8080", router)) so a port conflict or binding failure surfaces immediately.

2. Writing business logic inside handlers Handlers that query the database, format responses, and apply business rules become untestable. Move logic into a service layer and pass it to the handler via dependency injection — your tests will thank you.

3. Not setting response Content-Type Omitting w.Header().Set("Content-Type", "application/json") before writing a JSON body causes some clients to misparse the response. Always set headers before calling w.WriteHeader() or json.NewEncoder(w).Encode().

4. Leaking database connections Forgetting to call rows.Close() or stmt.Close() after a database operation leaks connections from the pool. Use defer rows.Close() immediately after a successful db.Query() call.

5. Hard-coding configuration Port numbers, DSN strings, and secret keys embedded in source code are a security risk. Use environment variables via os.Getenv() or a configuration package like godotenv.

Frequently Asked Questions

What router should I use for a production Go REST API? The standard library net/http ServeMux works for simple APIs, but for path parameters and method-based routing, gorilla/mux or chi are the most widely adopted choices. Chi is particularly popular for its lightweight middleware chain.

How do I handle authentication in a Go REST API? The most common approach is JWT-based authentication via middleware. The middleware extracts the token from the Authorization header, validates it with a library like golang-jwt/jwt, and either passes the request to the next handler or returns a 401. Session-based auth is also possible using a cookie store.

Should I use GORM or raw database/sql? For rapid development and simple CRUD, GORM reduces boilerplate significantly. For performance-critical paths or complex queries, raw database/sql with the pgx driver gives you full control. Many production services start with GORM and drop to raw SQL only for the queries where performance matters. See the official database/sql documentation for the standard interface.

Continue Learning

Building a REST API involves a lot of JSON serialisation. For a deep dive into how Go handles encoding, struct tags, custom marshallers, and edge cases, see the Go JSON Marshalling and Unmarshalling guide.