Building a REST API with Go: From Zero to Hero
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 codeinternal/
: Go's way of preventing packages from being imported by other projectspkg/
: Reusable packages that could be imported by other projectsClear 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 settingsjson
: API response field namesbinding
: Request validation rulesCombined 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:
Consistent error handling
Pointer returns for single items
Simple interfaces
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
- Safe Parameter Usage
r.db.Where("sku = ?", sku) // Good: Safe from SQL injection
r.db.Where("sku = "+sku) // Bad: SQL injection risk
- Efficient Pagination
// Count only when needed
r.db.Model(&models.Product{}).Count(&total)
// Then fetch page
r.db.Offset(offset).Limit(pageSize)
- 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
- 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
}
- 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:
Business validation before database operation
Clear error messages
SKU uniqueness check
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
- Validation First
// Always validate before database operations
if product.Price <= 0 {
return fmt.Errorf("price should be greater than zero")
}
- Existence Checks
existing, err := s.repo.GetById(id)
if err != nil {
return fmt.Errorf("product not found: %w", err)
}
- 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
- 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
}
- 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
- 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)
}
- 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:
Input validation using Gin's binding
Clear error messages
Appropriate HTTP status codes
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
- 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)
- 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
- 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:
Timing requests
Logging key information
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?
Error handler first (catch all panics)
Logger second (log all requests)
Routes last (handle business logic)
Common Middleware Patterns
- Request Timing
start := time.Now()
c.Next()
duration := time.Since(start)
- Panic Recovery
defer func() {
if err := recover(); err != nil {
// Handle panic
}
}()
- 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
- 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
}
}
- Early Returns
// Good: Clear flow control
if !authorized {
c.AbortWithStatus(http.StatusUnauthorized)
return
}
c.Next()
- 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
- 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()
}
}
- 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?
Configuration First
cfg, err := config.LoadConfig()
Everything depends on config
Fail fast if config is invalid
Environment-specific settings
Database Second
db, err := database.NewDatabase(cfg)
Core dependency for application
Validate connection early
Run migrations before serving requests
Repository Layer
productRepo := repository.NewProductRepository(db.DB)
Data access foundation
Required by service layer
Single database instance
Service Layer
productService := services.NewProductService(productRepo)
Business logic container
Uses repository
Required by handlers
Handler Layer
productHandler := handlers.NewProductHandler(productService)
HTTP request handling
Uses service layer
Routes depend on handlers
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
- Initialization Order
// Bad: Using components before initialization
router.Use(authMiddleware) // authMiddleware not initialized yet
// Good: Initialize first
authMiddleware := middleware.NewAuthMiddleware()
router.Use(authMiddleware.Handle())
- 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
Add authentication
Implement caching
Set up monitoring
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!