Building My First Go REST API: From Zero to Production-Ready Backend

Jay Kye
7 min read

Journey into Go development: Building a complete REST API with Fiber, PostgreSQL, and clean architecture patterns. Discovering why Go is dominating backend development.

GoBackend APIPostgreSQLpgxFiber

Building My First Go REST API: From Zero to Production-Ready Backend

After building platforms with Node.js and Python, I decided to dive into Go to understand why it's becoming the go-to choice for backend services. Today I built my first Go REST API, and the experience was eye-opening.

Why Go for Backend Development?

Coming from JavaScript/TypeScript background with BKRS.io (serving 7K+ users), I was curious about Go's reputation for performance and simplicity. The compile speed alone made me understand why companies like Google, Uber, and Docker choose Go for their backend services.

Project Overview: Todo REST API

I built a complete CRUD API for a todo application with:

  • Framework: Fiber (Express.js-like for Go)
  • Database: PostgreSQL with pgx driver
  • Architecture: Dependency injection pattern
  • Dev Tools: Air for hot reloading

Step-by-Step Implementation

1. Project Setup & Dependencies

First, I initialized a new Go module and installed the essential packages:

go mod init todo-api
go get github.com/gofiber/fiber/v2
go get github.com/jackc/pgx/v5
go get github.com/joho/godotenv
go get github.com/cosmtrek/air  # For hot reloading

Air Configuration (air.toml):

root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"

[build]
  args_bin = []
  bin = "./tmp/main"
  cmd = "go build -o ./tmp/main ."
  delay = 1000
  exclude_dir = ["assets", "tmp", "vendor", "testdata"]
  exclude_file = []
  exclude_regex = ["_test.go"]
  exclude_unchanged = false
  follow_symlink = false
  full_bin = ""
  include_dir = []
  include_ext = ["go", "tpl", "tmpl", "html"]
  kill_delay = "0s"
  log = "build-errors.log"
  send_interrupt = false
  stop_on_root = false

[color]
  app = ""
  build = "yellow"
  main = "magenta"
  runner = "green"
  watcher = "cyan"

[log]
  time = false

[misc]
  clean_on_exit = false

2. Core Data Structures

type createTodoRequest struct {
    Body string `json:"body"`
}

type Todo struct {
    ID        int    `json:"id" db:"id"`
    Completed bool   `json:"completed" db:"completed"`
    Body      string `json:"body" db:"body"`  
}

type App struct {
    DB  *pgx.Conn
    App *fiber.App
}

3. Database Connection & Setup

func main() {
    // Load environment variables
    err := godotenv.Load()
    if err != nil {
        log.Fatal("Error loading .env file", err)
    }

    // Connect to PostgreSQL
    DB := os.Getenv("DATABASE_URL")
    conn, err := pgx.Connect(context.Background(), DB)
    if err != nil {
        panic(err)
    }
    defer conn.Close(context.Background())

    // Create table if not exists
    _, err = conn.Exec(context.Background(), 
        `CREATE TABLE IF NOT EXISTS todos(
            id SERIAL PRIMARY KEY, 
            completed BOOLEAN NOT NULL DEFAULT FALSE, 
            body TEXT NOT NULL
        );`)
    if err != nil {
        panic(err)
    }
}

4. Dependency Injection Pattern

Instead of using global variables, I implemented a clean dependency injection pattern using structs:

type App struct {
    DB  *pgx.Conn
    App *fiber.App
}

// All handlers are methods on the App struct
func (a *App) getTodos(c *fiber.Ctx) error { /* ... */ }
func (a *App) createTodo(c *fiber.Ctx) error { /* ... */ }
func (a *App) updateTodo(c *fiber.Ctx) error { /* ... */ }
func (a *App) deleteTodo(c *fiber.Ctx) error { /* ... */ }

This approach provides:

  • Testability: Easy to mock dependencies
  • Clean Architecture: Clear separation of concerns
  • Maintainability: Explicit dependencies

5. REST API Endpoints

// Setup routes
a.App.Get("/api/todos", a.getTodos)
a.App.Post("/api/todos", a.createTodo)
a.App.Put("/api/todos/:id", a.updateTodo)
a.App.Delete("/api/todos/:id", a.deleteTodo)

6. CRUD Operations Implementation

GET - Fetch All Todos:

func (a *App) getTodos(c *fiber.Ctx) error {
    rows, err := a.DB.Query(context.Background(), "SELECT * FROM todos")
    if err != nil {
        return c.Status(500).JSON(fiber.Map{"error": err.Error()})
    }
    defer rows.Close()

    todos := []Todo{}
    for rows.Next() {
        var t Todo
        if err := rows.Scan(&t.ID, &t.Completed, &t.Body); err != nil {
            return c.Status(500).JSON(fiber.Map{"error": err.Error()})
        }
        todos = append(todos, t)
    }

    return c.Status(200).JSON(todos)
}

POST - Create New Todo:

func (a *App) createTodo(c *fiber.Ctx) error {
    req := new(createTodoRequest)

    // Parse JSON body
    if err := c.BodyParser(req); err != nil {
        return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"})
    }

    // Validation
    if req.Body == "" {
        return c.Status(400).JSON(fiber.Map{"error": "Todo body cannot be empty"})
    }

    todo := &Todo{Body: req.Body}
    
    // Safe parameterized query
    query := `INSERT INTO todos (body, completed) VALUES ($1, $2) RETURNING id`
    err := a.DB.QueryRow(context.Background(), query, todo.Body, false).Scan(&todo.ID)
    
    if err != nil {
        return c.Status(500).JSON(fiber.Map{"error": err.Error()})
    }

    return c.Status(201).JSON(todo)
}

Key Learnings & Insights

1. Compilation Speed is Game-Changing

Coming from Node.js development, Go's near-instantaneous compilation was remarkable. No more waiting for webpack builds or dealing with complex build pipelines.

2. Type Safety Without Complexity

Go's type system feels more approachable than TypeScript while providing similar safety benefits. The explicit error handling, while verbose, makes debugging much clearer.

3. Performance Out of the Box

Even this simple API felt snappy. The binary deployment model eliminates dependency management headaches I've experienced with Node.js projects.

4. Clean Architecture Patterns

The dependency injection pattern using structs feels natural and promotes testable code. It's similar to what I implemented in my BKRS.io platform but more explicit.

Code Improvements & Best Practices

1. Error Handling Enhancement

// Instead of panic, use proper error handling
if err != nil {
    log.Printf("Database connection failed: %v", err)
    return fmt.Errorf("failed to connect to database: %w", err)
}

2. Environment Validation

func validateEnv() error {
    required := []string{"DATABASE_URL", "PORT"}
    for _, env := range required {
        if os.Getenv(env) == "" {
            return fmt.Errorf("required environment variable %s is not set", env)
        }
    }
    return nil
}

3. Graceful Shutdown

// Add signal handling for graceful shutdown
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt, syscall.SIGTERM)

go func() {
    <-c
    log.Println("Gracefully shutting down...")
    a.App.Shutdown()
}()

Performance Comparison

Having built similar APIs in Node.js for BKRS.io, I noticed:

  • Startup Time: ~50ms vs ~2s for Node.js
  • Memory Usage: ~10MB vs ~50MB for equivalent Node.js API
  • Response Time: Consistently under 50ms for simple queries

Is This Clean Code?

The dependency injection pattern I implemented definitely moves toward clean architecture:

Single Responsibility: Each handler has one job
Dependency Inversion: Database is injected, not hardcoded
Testability: Easy to mock the database connection
Explicit Dependencies: Clear what each function needs

However, for production, I'd add:

  • Interface abstractions for the database
  • Proper logging middleware
  • Request validation middleware
  • Error handling middleware

Next Steps

This first Go project convinced me to explore further:

  1. gRPC Integration: For high-performance service communication
  2. Testing Strategies: Go's built-in testing tools
  3. Deployment: Docker containerization and Kubernetes

Conclusion

Building my first Go REST API was surprisingly smooth. The language's simplicity, combined with excellent tooling like Fiber and pgx, made the development experience enjoyable.

The compilation speed alone makes me understand why companies choose Go for backend services. While the syntax took some adjustment coming from JavaScript, the explicit nature of Go actually made the code more readable and maintainable.

Would I use Go for my next backend project? Absolutely. The performance benefits and deployment simplicity make it an excellent choice, especially for APIs that need to handle high concurrency.


Complete Code: The full implementation is available as a reference for anyone starting their Go journey. Feel free to reach out if you have questions about Go development or want to discuss backend architecture patterns!

Tech Stack Used:

  • Go 1.25+
  • Fiber v2 (Web Framework)
  • PostgreSQL (Database)
  • pgx v5 (PostgreSQL Driver)
  • Air (Hot Reloading)
  • godotenv (Environment Management)