DEV Community

Cover image for Building My First Real API in Go — with Gin
mihir mohapatra
mihir mohapatra

Posted on

Building My First Real API in Go — with Gin

Building My First Real API in Go — with Gin

In part 4 I worked through Go's error handling model and came around on if err != nil. This is the post where everything from the last four parts — structs, interfaces, goroutines, error wrapping — stops being theoretical and actually gets wired into something that runs, accepts requests, and talks to a database.

I'm building a small Orders API: create an order, fetch one by ID, list all of them. Simple enough that the routing and middleware patterns are clear, complex enough that the error handling and concurrency patterns from earlier posts actually matter.

Why Gin

The Go standard library's net/http package is genuinely capable — you can write a production API with nothing else. But Gin adds three things that make it worth reaching for immediately: a clean router with path parameters and route groups, a middleware chain that composes nicely, and response helpers (c.JSON, c.AbortWithStatusJSON) that remove a lot of boilerplate. It's the closest thing Go has to a lightweight Express or Axum — fast, minimal, and doesn't try to own your entire architecture.

go mod init orders-api
go get github.com/gin-gonic/gin
Enter fullscreen mode Exit fullscreen mode

Project Structure

Before a single line of code, here's the layout I settled on:

orders-api/
├── main.go
├── handler/
│   └── order.go
├── model/
│   └── order.go
├── store/
│   └── order.go
└── middleware/
    └── logger.go
Enter fullscreen mode Exit fullscreen mode

This is the standard Go flat-package layout — no src/, no deep nesting, no framework-imposed folders. handler owns HTTP concerns, model owns data shapes, store owns persistence logic. The separation means each layer depends on the one below it, and none of them import each other in circles.

The Model

// model/order.go
package model

import "time"

type Order struct {
    ID        string    `json:"id"`
    Customer  string    `json:"customer"`
    Amount    float64   `json:"amount"`
    Status    string    `json:"status"`
    CreatedAt time.Time `json:"created_at"`
}

type CreateOrderRequest struct {
    Customer string  `json:"customer" binding:"required"`
    Amount   float64 `json:"amount"   binding:"required,gt=0"`
}
Enter fullscreen mode Exit fullscreen mode

Two things worth noting here. The struct tags (json:"id") control serialisation — without them Gin would use the field name as-is, so CreatedAt would become CreatedAt in JSON rather than created_at. The binding tags on CreateOrderRequest are Gin's built-in validation: binding:"required" rejects blank fields, gt=0 rejects zero or negative amounts. This is the closest Go gets to Java Bean Validation annotations, except it's enforced at the handler layer rather than the model layer — I'll show how in a moment.

The Store

For now the store is in-memory — a map behind a sync.RWMutex. Part 7 will swap this for pgx. The interface is the important part:

// store/order.go
package store

import (
    "errors"
    "sync"
    "time"

    "orders-api/model"

    "github.com/google/uuid"
)

var ErrNotFound = errors.New("order not found")

type OrderStore interface {
    Create(req model.CreateOrderRequest) (model.Order, error)
    GetByID(id string) (model.Order, error)
    List() ([]model.Order, error)
}

type inMemoryStore struct {
    mu     sync.RWMutex
    orders map[string]model.Order
}

func NewInMemoryStore() OrderStore {
    return &inMemoryStore{orders: make(map[string]model.Order)}
}

func (s *inMemoryStore) Create(req model.CreateOrderRequest) (model.Order, error) {
    order := model.Order{
        ID:        uuid.New().String(),
        Customer:  req.Customer,
        Amount:    req.Amount,
        Status:    "pending",
        CreatedAt: time.Now(),
    }
    s.mu.Lock()
    s.orders[order.ID] = order
    s.mu.Unlock()
    return order, nil
}

func (s *inMemoryStore) GetByID(id string) (model.Order, error) {
    s.mu.RLock()
    defer s.mu.RUnlock()
    order, ok := s.orders[id]
    if !ok {
        return model.Order{}, ErrNotFound
    }
    return order, nil
}

func (s *inMemoryStore) List() ([]model.Order, error) {
    s.mu.RLock()
    defer s.mu.RUnlock()
    orders := make([]model.Order, 0, len(s.orders))
    for _, o := range s.orders {
        orders = append(orders, o)
    }
    return orders, nil
}
Enter fullscreen mode Exit fullscreen mode

sync.RWMutex lets multiple concurrent reads proceed at the same time but serialises writes — the right primitive for a read-heavy in-memory store. The OrderStore interface is defined here, in the store package, but that's just for organisation — the handler depends on the interface, not the concrete type, which means swapping in a real database later is a one-line change in main.go.

The Handler

// handler/order.go
package handler

import (
    "errors"
    "net/http"

    "orders-api/model"
    "orders-api/store"

    "github.com/gin-gonic/gin"
)

type OrderHandler struct {
    store store.OrderStore
}

func NewOrderHandler(s store.OrderStore) *OrderHandler {
    return &OrderHandler{store: s}
}

func (h *OrderHandler) Create(c *gin.Context) {
    var req model.CreateOrderRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    order, err := h.store.Create(req)
    if err != nil {
        c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "could not create order"})
        return
    }

    c.JSON(http.StatusCreated, order)
}

func (h *OrderHandler) GetByID(c *gin.Context) {
    id := c.Param("id")

    order, err := h.store.GetByID(id)
    if err != nil {
        if errors.Is(err, store.ErrNotFound) {
            c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "order not found"})
            return
        }
        c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "could not fetch order"})
        return
    }

    c.JSON(http.StatusOK, order)
}

func (h *OrderHandler) List(c *gin.Context) {
    orders, err := h.store.List()
    if err != nil {
        c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "could not list orders"})
        return
    }
    c.JSON(http.StatusOK, orders)
}
Enter fullscreen mode Exit fullscreen mode

ShouldBindJSON is where the binding tags from the model earn their keep — it validates and decodes the request body in one call, returning a descriptive error if anything fails. errors.Is(err, store.ErrNotFound) is the same pattern from part 4: unwrap through layers and match the sentinel. The handler doesn't know or care whether the store is in-memory or PostgreSQL — it just speaks to the interface.

Middleware: Request Logging

// middleware/logger.go
package middleware

import (
    "fmt"
    "time"

    "github.com/gin-gonic/gin"
)

func Logger() gin.HandlerFunc {
    return func(c *gin.Context) {
        start := time.Now()
        c.Next()
        fmt.Printf("[%s] %s %s %d (%s)\n",
            start.Format("15:04:05"),
            c.Request.Method,
            c.Request.URL.Path,
            c.Writer.Status(),
            time.Since(start),
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

c.Next() hands control to the next handler in the chain, then execution returns here after the response is written — which is why time.Since(start) gives you the actual request duration. This is the middleware pattern in every Gin-based service: wrap, call Next(), do something with what happened after.

Wiring It Together

// main.go
package main

import (
    "orders-api/handler"
    "orders-api/middleware"
    "orders-api/store"

    "github.com/gin-gonic/gin"
)

func main() {
    s := store.NewInMemoryStore()
    h := handler.NewOrderHandler(s)

    r := gin.New()
    r.Use(middleware.Logger())

    api := r.Group("/api/v1")
    {
        orders := api.Group("/orders")
        orders.POST("", h.Create)
        orders.GET("", h.List)
        orders.GET("/:id", h.GetByID)
    }

    r.Run(":8080")
}
Enter fullscreen mode Exit fullscreen mode

gin.New() instead of gin.Default() gives a blank engine — no Gin's built-in logger or recovery middleware added automatically, since we're providing our own. The r.Group nesting gives every orders route the /api/v1/orders prefix without repeating it on each registration. Running this and hitting POST /api/v1/orders with a JSON body gets you a real response, with validation, error handling, and request logging, in about 120 lines across all files.

What This Draws On From Earlier Posts

Part 2's interfaces: OrderStore is exactly the kind of single-responsibility interface Go favours — three methods, defined by the consumer (the handler), satisfied implicitly by the concrete store.

Part 3's concurrency: sync.RWMutex in the store is the in-memory safe concurrency answer. In part 7 when this becomes a real database, the connection pool handles this instead.

Part 4's error handling: errors.Is(err, store.ErrNotFound) translating a store-layer sentinel into an HTTP 404 is the wrapping pattern paying off at the HTTP boundary.

Up Next

Part 6 is testing — table-driven tests, the httptest package for handler tests without spinning up a real server, and the benchmarking habits that will matter once we push toward production in part 7.

What's your preferred approach to structuring Go API projects — flat packages like this, or a domain-driven layout with more nesting? Genuinely curious how people land on this one.

Top comments (0)