Building a REST API with Go: From Zero to Hero

Β·

22 min read

Hey there! πŸ‘‹ I recently built a production-grade Inventory Management System while preparing for a Backend Engineer role. Coming from minimal Go experience, I learned a ton about building APIs, and I want to share that journey with you.

Why This Series?

Most Go tutorials either show you basic CRUD operations or jump straight into complex code. But what about the middle ground? What about ACTUALLY learning how to build something?

That's exactly what we'll cover. We'll build an Inventory Management SystemπŸ”₯

What Makes This Series Different?

Instead of just throwing code at you, I'll share:

  • The thought process behind each decision

  • Why certain patterns are used

What We'll Build

A complete inventory management API that can:

  • Handle product CRUD operations

  • Manage stock levels

  • Process concurrent requests

  • Scale with your needs

  • Deploy easily with Docker

Prerequisites

  • Basic Go knowledge (variables, functions, structs)

  • Understanding of REST APIs

  • Docker installed locally

  • PostgreSQL basics

You don't need to be a Go expert - I wasn't when I started!

Part 1: Configuration and Project Setup

Why Start With Configuration?

When I started building this inventory management system, my first instinct was to jump straight into writing API endpoints. But I quickly realized something: every component of our application would need configuration - database connections, server settings, API keys, etc.

Project Structure First

Before writing any code, let's set up a proper project structure. Here's what we'll use:

inventory-system/
β”œβ”€β”€ cmd/
β”‚   └── api/          # Application entry point
β”‚       └── main.go
β”œβ”€β”€ internal/         # Private application code
β”‚   β”œβ”€β”€ config/       # Configuration management
β”‚   β”œβ”€β”€ models/       # Data models
β”‚   β”œβ”€β”€ repository/   # Data access
β”‚   β”œβ”€β”€ service/      # Business logic
β”‚   β”œβ”€β”€ handlers/     # HTTP handlers
β”‚   └── middleware/   # Custom middleware
β”œβ”€β”€ pkg/              # Public libraries
└── tests/            # Integration tests

Why this structure?

  • cmd/: Separates executable applications from library code

  • internal/: Go's way of preventing packages from being imported by other projects

  • pkg/: Reusable packages that could be imported by other projects

  • Clear separation of concerns between layers

Configuration Flow

Environment (.env) β†’ Config Struct β†’ Application Components
                     ↑
                     Load & Validate

Configuration Implementation

Let's look at our configuration implementation. First, create internal/config/config.go:

package config

import (
    "fmt"
    "os"
    "github.com/joho/godotenv"
)

type Config struct {
    DB_HOST     string
    DB_PORT     string
    DB_USER     string
    DB_PASSWORD string
    DB_NAME     string
}

func LoadConfig() (*Config, error) {
    // Load the .env file
    if err := godotenv.Load(); err != nil {
        return nil, fmt.Errorf("error loading .env file: %w", err)
    }

    config := &Config{
        DB_HOST:     os.Getenv("DB_HOST"),
        DB_PORT:     os.Getenv("DB_PORT"),
        DB_USER:     os.Getenv("DB_USER"),
        DB_PASSWORD: os.Getenv("DB_PASSWORD"),
        DB_NAME:     os.Getenv("DB_NAME"),
    }

    // Validate required fields
    if config.DB_HOST == "" || config.DB_PORT == "" || 
       config.DB_USER == "" || config.DB_PASSWORD == "" || 
       config.DB_NAME == "" {
        return nil, fmt.Errorf("missing required database configuration")
    }

    return config, nil
}

Let's break down the key decisions here:

1. Configuration Structure

type Config struct {
    DB_HOST     string
    DB_PORT     string
    DB_USER     string
    DB_PASSWORD string
    DB_NAME     string
}

Why this approach?

  • Each config value is a field in a struct (type safety)

  • Clear documentation of what configuration is needed

  • Easy to add new configuration values

  • Compiler will catch typos in field names

2. Environment Variables

Create a .env file in your project root:

DB_HOST=localhost
DB_PORT=5434
DB_USER=postgres
DB_PASSWORD=postgres
DB_NAME=inventory

Why use .env?

  • Keep sensitive data out of code

  • Different values for different environments

  • Easy to change values without recompiling

  • Standard practice for configuration management

3. Validation on Load

if config.DB_HOST == "" || config.DB_PORT == "" {
    return nil, fmt.Errorf("missing database configuration")
}

Why validate early?

  • Fail fast if configuration is missing

  • Clear error messages about what's wrong

  • Prevents runtime errors later

  • Better developer experience

Using the Configuration

In your main application (cmd/api/main.go), you'll use it like this:

func main() {
    cfg, err := config.LoadConfig()
    if err != nil {
        log.Fatal("Failed to load config:", err)
    }

    // Now you can use cfg.DB_HOST, cfg.DB_PORT, etc.
}

Next Steps

With configuration handled, we can now move on to setting up our database connection in Part 2. We'll use these configuration values to:

  • Connect to PostgreSQL

  • Set up GORM

  • Configure connection pools

  • Handle database errors

Part 2: Database Integration with GORM

Why GORM for Database Layer?

After setting up our configuration in Part 1, we need to establish our database connection. I chose GORM because:

  • Simplifies common database operations

  • Handles migrations automatically

  • Provides robust relationship management

  • Includes built-in features like soft deletes

  • Has excellent connection pooling

Database Flow

Config β†’ Database Connection β†’ GORM Instance β†’ Models
         ↑                     ↑              ↑
         Connection Pool      Migrations      Schema Definition

Database Package Implementation

Let's build our database layer in pkg/database/postgres.go:

package database

import (
    "fmt"
    "github.com/yourusername/inventory-management-system/internal/config"
    "gorm.io/driver/postgres"
    "gorm.io/gorm"
    "gorm.io/gorm/logger"
)

type Database struct {
    DB *gorm.DB
}

func NewDatabase(config *config.Config) (*Database, error) {
    dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable",
        config.DB_HOST,
        config.DB_USER,
        config.DB_PASSWORD,
        config.DB_NAME,
        config.DB_PORT,
    )

    db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
        Logger: logger.Default.LogMode(logger.Info),
    })
    if err != nil {
        return nil, fmt.Errorf("failed to connect to database: %w", err)
    }

    // Get the underlying SQL DB
    sqlDB, err := db.DB()
    if err != nil {
        return nil, fmt.Errorf("failed to get database instance: %w", err)
    }

    // Test the connection
    if err := sqlDB.Ping(); err != nil {
        return nil, fmt.Errorf("failed to ping database: %w", err)
    }

    // Configure connection pool
    sqlDB.SetMaxIdleConns(10)
    sqlDB.SetMaxOpenConns(100)

    return &Database{DB: db}, nil
}

Let's break down the key decisions and their reasoning:

1. Connection String Formation

dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable",
    config.DB_HOST,
    config.DB_USER,
    config.DB_PASSWORD,
    config.DB_NAME,
    config.DB_PORT,
)

Why this approach?

  • Clear visualization of connection parameters

  • Easy to modify individual components

  • Explicit about what we're connecting to

  • SSL disabled for local development (you'd enable it in production)

2. GORM Configuration

db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
    Logger: logger.Default.LogMode(logger.Info),
})

3. Connection Pool Settings

sqlDB.SetMaxIdleConns(10)
sqlDB.SetMaxOpenConns(100)

Why these numbers?

  • Max idle connections (10): Keep some connections ready

  • Max open connections (100): Prevent database overload

  • Balance between resource usage and performance

  • Suitable for medium-scale applications

Database Models

Now let's set up our Product model in internal/models/product.go:

package models

import (
    "time"
    "gorm.io/gorm"
)

type Product struct {
    ID          uint           `gorm:"primaryKey" json:"id"`
    Name        string         `gorm:"size:255;not null" json:"name" binding:"required"`
    Description string         `gorm:"type:text" json:"description"`
    Price       float64        `gorm:"not null" json:"price" binding:"required,gt=0"`
    Quantity    int           `gorm:"not null" json:"quantity" binding:"required,gte=0"`
    SKU         string        `gorm:"size:50;unique;not null" json:"sku" binding:"required"`
    CreatedAt   time.Time     `json:"created_at"`
    UpdatedAt   time.Time     `json:"updated_at"`
    DeletedAt   gorm.DeletedAt `gorm:"index" json:"-"`
}

func (p *Product) BeforeCreate(tx *gorm.DB) error {
    p.CreatedAt = time.Now()
    p.UpdatedAt = time.Now()
    return nil
}

func (p *Product) BeforeUpdate(tx *gorm.DB) error {
    p.UpdatedAt = time.Now()
    return nil
}

Let's analyse the model design decisions:

1. Field Tags

Name string `gorm:"size:255;not null" json:"name" binding:"required"`

Why these tags?

  • gorm: Database constraints and settings

  • json: API response field names

  • binding: Request validation rules

  • Combined tags handle database, API, and validation needs

2. Field Types

Price    float64  `gorm:"not null"`
Quantity int     `gorm:"not null"`

Type choices:

  • float64 for prices (handles decimals)

  • int for quantity (whole numbers only)

  • uint for ID (no negative IDs)

  • string with size limits where appropriate

3. Timestamps and Soft Delete

CreatedAt   time.Time
UpdatedAt   time.Time
DeletedAt   gorm.DeletedAt `gorm:"index"`

Why include these?

  • Track record lifecycle

  • Enable soft deletes (don't actually delete data)

  • Index on DeletedAt for query performance

  • Standard practice for auditing

Using in Main Application

In cmd/api/main.go, wire everything together:

func main() {
    cfg, err := config.LoadConfig()
    if err != nil {
        log.Fatal("Failed to load config:", err)
    }

    db, err := database.NewDatabase(cfg)
    if err != nil {
        log.Fatal("Failed to initialize database:", err)
    }

    // Auto-migrate the schema
    if err := db.DB.AutoMigrate(&models.Product{}); err != nil {
        log.Fatal("Failed to migrate database:", err)
    }
}

Next Steps

With our database layer set up, we can move on to Part 3, where we'll implement the Repository pattern to handle our database operations. We'll:

  • Create CRUD operations

  • Implement custom queries

  • Handle database errors properly

  • Add pagination support


Questions about database setup or any improvements ? Let me know in the comments!

Part 3: Repository Pattern Implementation

Repository Flow

GORM DB β†’ Repository β†’ Data Operations
          ↑            ↑
          Methods     CRUD & Custom Queries
          ↓
          Return Data/Errors

Why Repository Pattern?

After setting up our database layer, we need a clean way to handle data access. The repository pattern provides:

  • Abstraction of data storage

  • Centralized data access logic

  • Easier testing through interfaces

  • Clean separation from business logic

Building the Product Repository

Let's implement our repository in internal/repository/product_repository.go:

package repository

import (
    "github.com/yourusername/inventory-management-system/internal/models"
    "gorm.io/gorm"
)

type ProductRepository struct {
    db *gorm.DB
}

func NewProductRepository(db *gorm.DB) *ProductRepository {
    return &ProductRepository{
        db: db,
    }
}

Why this structure?

  • Simple constructor pattern

  • Dependency injection of database

  • Single responsibility for product data

  • Easy to mock for testing

Core CRUD Operations

Let's implement the essential operations:

// Create
func (r *ProductRepository) Create(product *models.Product) error {
    return r.db.Create(product).Error
}

// Read by ID
func (r *ProductRepository) GetById(id uint) (*models.Product, error) {
    var product models.Product
    err := r.db.First(&product, id).Error
    if err != nil {
        return nil, err
    }
    return &product, nil
}

// Update
func (r *ProductRepository) Update(product *models.Product) error {
    return r.db.Save(product).Error
}

// Delete
func (r *ProductRepository) Delete(id uint) error {
    return r.db.Delete(&models.Product{}, id).Error
}

Key design decisions:

  1. Consistent error handling

  2. Pointer returns for single items

  3. Simple interfaces

  4. Direct GORM usage

Advanced Query Methods

Let's add some specific business queries:

// Get all products with pagination
func (r *ProductRepository) GetAll(page, pageSize int) ([]models.Product, int, error) {
    var products []models.Product
    var total int64

    // Count total records
    if err := r.db.Model(&models.Product{}).Count(&total).Error; err != nil {
        return nil, 0, err
    }

    // Pagination
    offset := (page - 1) * pageSize
    err := r.db.Offset(offset).Limit(pageSize).Find(&products).Error
    if err != nil {
        return nil, 0, err
    }

    return products, int(total), nil
}

// Get by SKU (Custom Query)
func (r *ProductRepository) GetBySKU(sku string) (*models.Product, error) {
    var product models.Product
    err := r.db.Where("sku = ?", sku).First(&product).Error
    if err != nil {
        return nil, err
    }
    return &product, nil
}

Why these specific implementations?

Pagination Logic:

offset := (page - 1) * pageSize
  • Handles large datasets efficiently

  • Returns total count for UI pagination

  • Prevents loading too much data at once

SKU Query:

r.db.Where("sku = ?", sku).First(&product)
  • Uses parameterized query for safety

  • Returns single result or error

  • Important for business logic (SKUs are unique)

Common Query Patterns

  1. Safe Parameter Usage
r.db.Where("sku = ?", sku)  // Good: Safe from SQL injection
r.db.Where("sku = "+sku)    // Bad: SQL injection risk
  1. Efficient Pagination
// Count only when needed
r.db.Model(&models.Product{}).Count(&total)
// Then fetch page
r.db.Offset(offset).Limit(pageSize)
  1. Consistent Return Types
// Single item: pointer or nil
func GetById() (*models.Product, error)

// Multiple items: slice
func GetAll() ([]models.Product, int, error)

Common Pitfalls to Avoid

  1. Don't Mix Business Logic
// Bad: Business logic in repository
func (r *Repository) CreateWithValidation(p *Product) error {
    if p.Price < 0 {  // Business logic!
        return error
    }
    return r.db.Create(p).Error
}

// Good: Just data access
func (r *Repository) Create(p *Product) error {
    return r.db.Create(p).Error
}
  1. Don't Return Database Errors Directly Consider wrapping errors for better context:
if err != nil {
    return fmt.Errorf("failed to fetch product: %w", err)
}

Next Steps

In Part 4, we'll build the service layer that uses this repository and implements our business logic. We'll:

  • Add validation rules

  • Implement business operations

  • Handle complex transactions

  • Manage error responses

Part 4: Service Layer Implementation

Service Flow

Repository β†’ Service Layer β†’ Business Logic
             ↑             ↑
             Validation    Error Handling
             ↓             ↓
             Data         Business Rules

Why Service Layer?

After implementing our repository pattern, we need a place for our business logic. The service layer:

  • Apply business rules

  • Handles validation

  • Provides clean API for handlers

Implementing the Product Service

Let's build our service in internal/services/product_service.go:

package services

import (
    "fmt"
    "github.com/yourusername/inventory-management-system/internal/models"
    "github.com/yourusername/inventory-management-system/internal/repository"
)

type ProductService struct {
    repo *repository.ProductRepository
}

func NewProductService(repo *repository.ProductRepository) *ProductService {
    return &ProductService{
        repo: repo,
    }
}

Why this structure?

  • Clean dependency injection

  • Single responsibility

  • Easy to test

  • Clear separation from data access

Core Business Operations

Let's look at the create product operation:

func (s *ProductService) CreateProduct(product *models.Product) error {
    // Business validation
    existing, err := s.repo.GetBySKU(product.SKU)
    if err == nil && existing != nil {
        return fmt.Errorf("product with SKU %s already exists", product.SKU)
    }

    if product.Price <= 0 {
        return fmt.Errorf("product price should be greater than zero")
    }

    if product.Quantity < 0 {
        return fmt.Errorf("product quantity can't be negative")
    }

    return s.repo.Create(product)
}

Key design decisions:

  1. Business validation before database operation

  2. Clear error messages

  3. SKU uniqueness check

  4. Logical constraints on price and quantity

Stock Management Logic

Here's our stock update implementation:

func (s *ProductService) UpdateStock(id uint, quantity int) error {
    existing, err := s.repo.GetById(id)
    if err != nil {
        return fmt.Errorf("can't find the product: %w", err)
    }

    if existing.Quantity + quantity < 0 {
        return fmt.Errorf("insufficient stock")
    }

    existing.Quantity += quantity
    return s.repo.Update(existing)
}

Why this approach ??

  • Check existence first

  • Validate stock levels

  • Prevent negative inventory

  • Single responsibility principle

List Products with Pagination

func (s *ProductService) ListProducts(page, pageSize int) ([]models.Product, int, error) {
    if page < 1 {
        page = 1
    }
    if pageSize < 1 || pageSize > 100 {
        pageSize = 10
    }

    return s.repo.GetAll(page, pageSize)
}

Design considerations:

  • Sensible defaults

  • Limit page size (prevent overload)

  • Return total count for pagination

  • parameter validation

Business Rule Patterns

  1. Validation First
// Always validate before database operations
if product.Price <= 0 {
    return fmt.Errorf("price should be greater than zero")
}
  1. Existence Checks
existing, err := s.repo.GetById(id)
if err != nil {
    return fmt.Errorf("product not found: %w", err)
}
  1. Business Constraints
if existing.Quantity + quantity < 0 {
    return fmt.Errorf("insufficient stock")
}

Error Handling Strategy

Our service uses descriptive error messages:

// Business errors
return fmt.Errorf("product with SKU %s already exists", product.SKU)

// Wrapped database errors
return fmt.Errorf("failed to update product: %w", err)

Why this approach?

  • Clear error messages for API responses

  • Error wrapping for context

  • Separate business errors from technical errors

  • Helpful for debugging and logging

Common Service Pattern Examples

  1. Get or Error
func (s *ProductService) GetProduct(id uint) (*models.Product, error) {
    product, err := s.repo.GetById(id)
    if err != nil {
        return nil, fmt.Errorf("error getting product: %w", err)
    }
    return product, nil
}
  1. Update with Validation
func (s *ProductService) UpdateProduct(id uint, product *models.Product) error {
    existing, err := s.repo.GetById(id)
    if err != nil {
        return fmt.Errorf("product not found: %w", err)
    }

    if product.SKU != existing.SKU {
        skuProduct, err := s.repo.GetBySKU(product.SKU)
        if err == nil && skuProduct != nil {
            return fmt.Errorf("product with SKU %s already exists", product.SKU)
        }
    }

    existing.Name = product.Name
    existing.Description = product.Description
    existing.Price = product.Price
    existing.Quantity = product.Quantity
    existing.SKU = product.SKU

    return s.repo.Update(existing)
}

Common Mistakes to Avoid

  1. Don't Skip Validation
// Bad: Direct database operation
func (s *Service) CreateProduct(p *Product) error {
    return s.repo.Create(p)  // No validation!
}

// Good: Validate first
func (s *Service) CreateProduct(p *Product) error {
    if err := validateProduct(p); err != nil {
        return err
    }
    return s.repo.Create(p)
}
  1. Don't Mix Concerns
// Bad: HTTP concerns in service
func (s *Service) GetProduct(id uint) (*Product, int) {
    // Returning HTTP status codes!
    return product, http.StatusOK
}

// Good: Business logic only
func (s *Service) GetProduct(id uint) (*Product, error) {
    return product, err
}

Next Steps

In Part 5, we'll implement the HTTP handlers that use this service layer. We'll:

  • Create RESTful endpoints

  • Handle request validation

  • Format responses

  • Implement error handling middleware


Part 5: HTTP Handlers with Gin

Handler Flow

Client Request β†’ Handler β†’ Service β†’ Response
                ↑         ↑         ↑
                Parsing   Logic     Formatting
                ↓         ↓         ↓
                Validate  Process   JSON/Error

Why Gin for HTTP Layer?

With our service layer complete, we need to expose our business logic via HTTP endpoints. Gin is our framework of choice because it:

  • Provides excellent performance

  • Has built-in input validation

  • Offers middleware support

  • Handles common HTTP tasks elegantly

Handler Structure

Let's implement our handlers in internal/handlers/product_handler.go:

package handlers

import (
    "net/http"
    "strconv"
    "github.com/gin-gonic/gin"
    "github.com/yourusername/inventory-management-system/internal/models"
    "github.com/yourusername/inventory-management-system/internal/services"
)

type ProductHandler struct {
    service *services.ProductService
}

//Constructor
func NewProductHandler(service *services.ProductService) *ProductHandler {
    return &ProductHandler{
        service: service,
    }
}

Why this structure?

  • Clean dependency injection of service

  • Handler only concerned with HTTP

  • Clear separation of responsibilities

Core CRUD Endpoints

Create Product

func (h *ProductHandler) CreateProduct(c *gin.Context) {
    var product models.Product

    if err := c.ShouldBindJSON(&product); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{
            "error": "Invalid payload: " + err.Error(),
        })
        return
    }

    if err := h.service.CreateProduct(&product); err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{
            "error": "Failed to create product: " + err.Error(),
        })
        return
    }

    c.JSON(http.StatusCreated, product)
}

Key design decisions:

  1. Input validation using Gin's binding

  2. Clear error messages

  3. Appropriate HTTP status codes

  4. Consistent response format

List Products with Pagination

func (h *ProductHandler) ListProduct(c *gin.Context) {
    page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
    pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10"))

    products, total, err := h.service.ListProducts(page, pageSize)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{
            "error": "Failed to fetch list: " + err.Error(),
        })
        return
    }

    c.JSON(http.StatusOK, gin.H{
        "data": products,
        "total": total,
        "page": page,
        "page_size": pageSize,
    })
}

Why this approach?

  • Default values for optional parameters

  • Metadata included in response

  • Structured response format

  • Error handling consistency

Update Stock

func (h *ProductHandler) UpdateStock(c *gin.Context) {
    id, err := strconv.ParseUint(c.Param("id"), 10, 32)
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{
            "error": "Invalid product id: " + err.Error(),
        })
        return
    }

    var stockUpdate struct {
        Quantity int `json:"quantity" binding:"required"`
    }

    if err := c.ShouldBindJSON(&stockUpdate); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{
            "error": "Invalid payload: " + err.Error(),
        })
        return
    }

    if err := h.service.UpdateStock(uint(id), stockUpdate.Quantity); err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{
            "error": "Failed to update stock: " + err.Error(),
        })
        return
    }

    c.JSON(http.StatusOK, gin.H{
        "message": "Stock updated successfully",
    })
}

Request Handling Patterns

  1. Parameter Extraction
// URL Parameters
id, err := strconv.ParseUint(c.Param("id"), 10, 32)

// Query Parameters
page := c.DefaultQuery("page", "1")

// JSON Body
var product models.Product
c.ShouldBindJSON(&product)
  1. Response Structure
// Success Response
c.JSON(http.StatusOK, gin.H{
    "data": data,
    "metadata": metadata,
})

// Error Response
c.JSON(http.StatusBadRequest, gin.H{
    "error": errorMessage,
})

Route Setup in Main

func main() {
    router := gin.Default()

    api := router.Group("/api/v1")
    {
        products := api.Group("/products")
        {
            products.POST("", handler.CreateProduct)
            products.GET("", handler.ListProduct)
            products.GET("/:id", handler.GetProduct)
            products.PUT("/:id", handler.UpdateProduct)
            products.DELETE("/:id", handler.DeleteProduct)
            products.PATCH("/:id/stock", handler.UpdateStock)
        }
    }
}

Why this structure?

  • API versioning (/v1)

  • Grouped routes for clarity

  • RESTful endpoints

  • Clear resource hierarchy

API Endpoint Summary

POST   /api/v1/products       - Create product
GET    /api/v1/products       - List products
GET    /api/v1/products/:id   - Get single product
PUT    /api/v1/products/:id   - Update product
DELETE /api/v1/products/:id   - Delete product
PATCH  /api/v1/products/:id/stock - Update stock

Common Mistakes to Avoid

  1. Don't Mix Business Logic
// Bad: Business logic in handler
func (h *Handler) UpdateStock(c *gin.Context) {
    if quantity < 0 {  // Business logic!
        c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid quantity"})
        return
    }
}

// Good: Let service handle business logic
func (h *Handler) UpdateStock(c *gin.Context) {
    err := h.service.UpdateStock(id, quantity)
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }
}

Next Steps

In Part 6, we'll implement middleware for:

  • Logging

  • Error handling

  • Response timing

  • Request validation

Part 6: Middleware Implementation

Middleware Flow

Request β†’ Middleware Chain β†’ Handler β†’ Response
          ↑                  ↑         ↑
          Logging           Process    Format
          Error Handling    
          Authentication

Why Middleware?

With our handlers ready, we need cross-cutting concerns like logging and error handling. Middleware helps us:

  • Log request information

  • Handle panics gracefully

  • Time requests

  • Apply consistent error handling

Let's look at our middleware implementations.

Logging Middleware

First, let's implement request logging in internal/middleware/logging.go:

package middleware

import (
    "time"
    "github.com/gin-gonic/gin"
)

type logMiddleware struct {}

func NewLogMiddleware() *logMiddleware {
    return &logMiddleware{}
}

func (m *logMiddleware) Logger() gin.HandlerFunc {
    return func(c *gin.Context) {
        // Start timer
        start := time.Now()

        // Process request
        c.Next()

        // Calculate duration
        duration := time.Since(start)
        status := c.Writer.Status()
        path := c.Request.URL.Path
        method := c.Request.Method

        // Log request details
        println("[API]", method, path, status, duration)
    }
}

Key design decisions:

  1. Timing requests

  2. Logging key information

  3. Clean middleware structure

Error Handling Middleware

Next, let's implement error recovery in internal/middleware/error.go:

package middleware

import (
    "net/http"
    "github.com/gin-gonic/gin"
)

type ErrorMiddleware struct{}

func NewErrorMiddleware() *ErrorMiddleware {
    return &ErrorMiddleware{}
}

func (m *ErrorMiddleware) ErrorHandler() gin.HandlerFunc {
    return func(c *gin.Context) {
        defer func() {
            if err := recover(); err != nil {
                println("Panic Recovered:", err)
                c.JSON(http.StatusInternalServerError, gin.H{
                    "error": "An internal error occurred",
                })
                c.Abort()
            }
        }()

        c.Next()
    }
}

Why this approach?

  • Recover from panics

  • Hide internal errors from users

  • Log errors for debugging

  • Clean request termination

Middleware Application

In main.go, we apply our middleware:

func main() {
    router := gin.Default()

    // Initialize middleware
    logMiddleware := middleware.NewLogMiddleware()
    errorMiddleware := middleware.NewErrorMiddleware()

    // Apply global middleware
    router.Use(errorMiddleware.ErrorHandler())
    router.Use(logMiddleware.Logger())

    // Setup routes...
}

Why this order?

  1. Error handler first (catch all panics)

  2. Logger second (log all requests)

  3. Routes last (handle business logic)

Common Middleware Patterns

  1. Request Timing
start := time.Now()
c.Next()
duration := time.Since(start)
  1. Panic Recovery
defer func() {
    if err := recover(); err != nil {
        // Handle panic
    }
}()
  1. Context Values
// Set value
c.Set("requestID", uuid.New().String())

// Get value
if value, exists := c.Get("requestID"); exists {
    // Use value
}

Best Practices for Middleware

  1. Keep Middleware Focused
// Good: Single responsibility
func LoggerMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        // Only logging logic here
    }
}

// Bad: Mixed concerns
func LoggerAndAuthMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        // Logging and auth mixed together
    }
}
  1. Early Returns
// Good: Clear flow control
if !authorized {
    c.AbortWithStatus(http.StatusUnauthorized)
    return
}
c.Next()
  1. Error Forwarding
// Set error and abort
c.Error(err)
c.AbortWithStatus(http.StatusInternalServerError)

Middleware Order Matters

// Correct order
router.Use(recovery)    // First: handle panics
router.Use(logger)      // Second: log all requests
router.Use(auth)        // Last: authenticate requests

Common Mistakes to Avoid

  1. Don't Block in Middleware
// Bad: Blocking operation
func LoggerMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        writeToSlowDatabase(requestLog) // Blocks request
        c.Next()
    }
}

// Good: Async operation
func LoggerMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        go writeToSlowDatabase(requestLog) // Non-blocking
        c.Next()
    }
}
  1. Don't Forget to Call Next()
// Bad: Forgetting to continue chain
func Middleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        // Do something
        // Forgot c.Next()!
    }
}

Next Steps

In Part 7 (our final part), we'll cover:

  • Component initialization order and why

  • How main.go connects all components

  • Complete application flow

  • Project structure review

  • Docker and deployment

Part 7: Bringing It All Together

Understanding the Flow

Request flow through our application:

Request β†’ Router β†’ Middleware β†’ Handler β†’ Service β†’ Repository β†’ Database
         ↑          ↑            ↑          ↑           ↑           ↑
      Routing    Logging     HTTP      Business    Data       Storage
                 Errors    Handling     Logic      Access

Why Final Assembly Matters

We've built all our components - now let's see how they work together. The main application wiring:

  • Initializes components in the right order

  • Connects all layers properly

  • Sets up error handling

  • Configures routing

Main Application Structure

Let's look at our cmd/api/main.go:

package main

import (
    "log"
    "github.com/gin-gonic/gin"
    "github.com/yourusername/inventory-management-system/internal/config"
    "github.com/yourusername/inventory-management-system/internal/handlers"
    "github.com/yourusername/inventory-management-system/internal/middleware"
    "github.com/yourusername/inventory-management-system/internal/models"
    "github.com/yourusername/inventory-management-system/internal/repository"
    "github.com/yourusername/inventory-management-system/internal/services"
    "github.com/yourusername/inventory-management-system/pkg/database"
)

func main() {
    // 1. Load Configuration
    cfg, err := config.LoadConfig()
    if err != nil {
        log.Fatal("Failed to load config:", err)
    }

    // 2. Initialize Database
    db, err := database.NewDatabase(cfg)
    if err != nil {
        log.Fatal("Failed to initialize database:", err)
    }

    // 3. Auto Migrate Schema
    if err := db.DB.AutoMigrate(&models.Product{}); err != nil {
        log.Fatal("Cannot migrate database:", err)
    }

    // 4. Setup Layers
    // Repository Layer
    productRepo := repository.NewProductRepository(db.DB)
    // Service Layer
    productService := services.NewProductService(productRepo)
    // Handler Layer
    productHandler := handlers.NewProductHandler(productService)

    // 5. Initialize Middleware
    logMiddleware := middleware.NewLogMiddleware()
    errorMiddleware := middleware.NewErrorMiddleware()

    // 6. Setup Router
    router := gin.Default()

    // 7. Apply Global Middleware
    router.Use(errorMiddleware.ErrorHandler())
    router.Use(logMiddleware.Logger())

    // 8. Setup Routes
    api := router.Group("/api/v1")
    {
        // Health Check
        api.GET("/health", func(ctx *gin.Context) {
            ctx.JSON(200, gin.H{"status": "OK"})
        })

        // Product Routes
        products := api.Group("/products")
        {
            products.POST("", productHandler.CreateProduct)
            products.GET("", productHandler.ListProduct)
            products.GET("/:id", productHandler.GetProduct)
            products.PUT("/:id", productHandler.UpdateProduct)
            products.DELETE("/:id", productHandler.DeleteProduct)
            products.PATCH("/:id/stock", productHandler.UpdateStock)
        }
    }

    // 9. Start Server
    if err := router.Run(":8080"); err != nil {
        log.Fatal("Failed to start server:", err)
    }
}

Component Initialization Order

Why this specific order?

  1. Configuration First

     cfg, err := config.LoadConfig()
    
    • Everything depends on config

    • Fail fast if config is invalid

    • Environment-specific settings

  2. Database Second

     db, err := database.NewDatabase(cfg)
    
    • Core dependency for application

    • Validate connection early

    • Run migrations before serving requests

  3. Repository Layer

     productRepo := repository.NewProductRepository(db.DB)
    
    • Data access foundation

    • Required by service layer

    • Single database instance

  4. Service Layer

     productService := services.NewProductService(productRepo)
    
    • Business logic container

    • Uses repository

    • Required by handlers

  5. Handler Layer

     productHandler := handlers.NewProductHandler(productService)
    
    • HTTP request handling

    • Uses service layer

    • Routes depend on handlers

  6. Middleware

     logMiddleware := middleware.NewLogMiddleware()
     errorMiddleware := middleware.NewErrorMiddleware()
    
    • Global request processing

    • Applied before routes

Containerization

Now let's package our application. First, our Dockerfile:

FROM golang:1.22

WORKDIR /app
COPY . .
RUN go mod download
RUN go build -o main ./cmd/api
EXPOSE 8080
CMD ["./main"]

And our docker-compose.yaml:

version: "3.8"

services:
  api:
    build: .
    ports:
      - "8080:8080"
    depends_on:
      - postgres
    environment:
      - DB_HOST=postgres
      - DB_PORT=5432
      - DB_USER=postgres
      - DB_PASSWORD=postgres
      - DB_NAME=inventory

  postgres:
    image: postgres:14
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres
      - POSTGRES_DB=inventory
    ports:
      - "5434:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  postgres_data:

Running the Application

Development:

# Local development
go run cmd/api/main.go

# With Docker Compose
docker-compose up --build

Testing the Complete System

Let's test our API endpoints using the actual request formats from our Thunderclient collection:

1. Health Check

GET http://localhost:8080/api/v1/health

Expected Response:
{
    "status": "OK"
}

2. Create Product

POST http://localhost:8080/api/v1/products
Content-Type: application/json

{
    "name": "Playstation",
    "description": "Console",
    "price": 588.99,
    "quantity": 90,
    "sku": "IPH13-005"
}

3. List Products

GET http://localhost:8080/api/v1/products?page=1&page_size=10
Content-Type: application/json

4. Get Product by ID

GET http://localhost:8080/api/v1/products/{id}

5. Delete Product

DELETE http://localhost:8080/api/v1/products/{id}

6. Update Stock

PATCH http://localhost:8080/api/v1/products/{id}/stock
Content-Type: application/json

{
    "quantity": -5
}

Remember to have your server running either locally or through Docker Compose before testing:

# Local
go run cmd/api/main.go

# Docker
docker-compose up

The API will be available at http://localhost:8080 for both local and Docker deployments.

Common Mistakes to Avoid

  1. Initialization Order
// Bad: Using components before initialization
router.Use(authMiddleware) // authMiddleware not initialized yet

// Good: Initialize first
authMiddleware := middleware.NewAuthMiddleware()
router.Use(authMiddleware.Handle())
  1. Error Handling
// Bad: Ignoring startup errors
db, _ := database.NewDatabase(cfg)

// Good: Handle critical errors
db, err := database.NewDatabase(cfg)
if err != nil {
    log.Fatal("Failed to initialize database:", err)
}

Project Review

We've built a production-ready API with:

  • Clean architecture

  • Proper separation of concerns

  • Error handling

  • Logging

  • Docker support

  • Database integration

  • RESTful endpoints

The complete project structure:

inventory-system/
β”œβ”€β”€ cmd/
β”‚   └── api/
β”‚       └── main.go           # Application wiring
β”œβ”€β”€ internal/
β”‚   β”œβ”€β”€ config/              # Configuration
β”‚   β”œβ”€β”€ models/              # Data models
β”‚   β”œβ”€β”€ repository/          # Data access
β”‚   β”œβ”€β”€ service/             # Business logic
β”‚   β”œβ”€β”€ handlers/            # HTTP handling
β”‚   └── middleware/          # Request processing
β”œβ”€β”€ pkg/
β”‚   └── database/            # Database setup
β”œβ”€β”€ Dockerfile              # Container definition
└── docker-compose.yaml     # Multi-container setup

Next Steps

  1. Add authentication

  2. Implement caching

  3. Set up monitoring

  4. Add automated tests

That's it - we've built ourselves a solid inventory system!

You can find all the code on my GitHub: Inventory-Management-System

I wrote this guide while prepping for my backend role, and I hope it helps you the way I wish something like this helped me when I was starting. Feel free to use this as a base for your own projects.

Check out the repo, and if you have questions or built something cool with it, I'd love to hear about it!

See you around!

Β