February 25, 2025

Building Microservices with Go

Learn how to structure and build scalable microservices using Go, with a clean, modular architecture and practical implementation examples.

Microservices architecture has become a popular choice for designing scalable and maintainable applications. By breaking down a monolithic application into smaller, independent services, teams can develop, deploy, and scale each service independently.

Go is an excellent choice for building microservices due to its simplicity, performance, and robust standard library. In this blog, we'll explore how to create a microservices architecture using Go — with practical examples and a production-level project structure.

Why Choose Go for Microservices?

1. Simplicity

Go is designed to be simple and readable, reducing the cognitive load for developers working across multiple services.

2. Concurrency

Go's goroutines make it easy to handle concurrent operations — crucial in microservices for tasks like background jobs or parallel processing.

3. Performance

Being a compiled language with efficient memory usage, Go offers strong performance — especially beneficial for high-traffic services.

4. Standard Library

Go's rich standard library includes robust support for HTTP, networking, JSON, and more — often removing the need for third-party dependencies.

Key Components of a Microservice

  • API Layer - Handles incoming HTTP requests
  • Business Logic - Core service functionality
  • Data Layer - Communicates with databases or external services
  • Communication - REST, gRPC, or message queues for talking to other services
  • Middleware - Deals with concerns like logging, authentication, rate limiting

Project Structure

Here's a scalable and modular project layout that follows Go best practices:

go-microservices/
├── services/
│   ├── user-service/
│   │   ├── cmd/
│   │   │   └── server/
│   │   │       └── main.go
│   │   ├── internal/
│   │   │   ├── domain/
│   │   │   │   ├── user.go
│   │   │   │   └── repository.go
│   │   │   ├── handler/
│   │   │   │   ├── http/
│   │   │   │   │   └── user_handler.go
│   │   │   │   └── grpc/
│   │   │   │       └── user_grpc.go
│   │   │   ├── service/
│   │   │   │   └── user_service.go
│   │   │   ├── repository/
│   │   │   │   ├── postgres/
│   │   │   │   │   └── user_repo.go
│   │   │   │   └── redis/
│   │   │   │       └── user_cache.go
│   │   │   └── config/
│   │   │       └── config.go
│   │   ├── pkg/
│   │   │   ├── errors/
│   │   │   ├── middleware/
│   │   │   └── utils/
│   │   ├── api/
│   │   │   ├── proto/
│   │   │   │   └── user.proto
│   │   │   └── openapi/
│   │   │       └── user.yaml
│   │   ├── migrations/
│   │   ├── tests/
│   │   ├── Dockerfile
│   │   ├── go.mod
│   │   └── go.sum
│   └── product-service/
│       └── [similar structure]
├── shared/
│   ├── auth/
│   ├── database/
│   ├── events/
│   ├── monitoring/
│   └── utils/
├── api-gateway/
├── docker-compose.yml
├── kubernetes/
└── scripts/

This structure separates concerns clearly:

  • cmd/: Application entry points
  • internal/: Private application code
  • pkg/: Public library code that can be imported by other services
  • api/: API definitions (protobuf, OpenAPI)
  • shared/: Common utilities across services

Example: Product Microservice

Let's build a complete product microservice following this structure.

Step 1: Define the Product Domain

// services/product-service/internal/domain/product.go
package domain

import (
    "context"
    "errors"
)

type Product struct {
    ID          string  `json:"id" db:"id"`
    Name        string  `json:"name" db:"name"`
    Description string  `json:"description" db:"description"`
    Price       float64 `json:"price" db:"price"`
    CreatedAt   int64   `json:"created_at" db:"created_at"`
    UpdatedAt   int64   `json:"updated_at" db:"updated_at"`
}

type CreateProductRequest struct {
    Name        string  `json:"name" validate:"required,min=1,max=100"`
    Description string  `json:"description" validate:"max=500"`
    Price       float64 `json:"price" validate:"required,gt=0"`
}

type UpdateProductRequest struct {
    Name        *string  `json:"name,omitempty"`
    Description *string  `json:"description,omitempty"`
    Price       *float64 `json:"price,omitempty"`
}

// Repository interface
type ProductRepository interface {
    Create(ctx context.Context, product *Product) error
    GetByID(ctx context.Context, id string) (*Product, error)
    GetAll(ctx context.Context, limit, offset int) ([]*Product, error)
    Update(ctx context.Context, id string, updates *UpdateProductRequest) (*Product, error)
    Delete(ctx context.Context, id string) error
}

// Service interface
type ProductService interface {
    CreateProduct(ctx context.Context, req *CreateProductRequest) (*Product, error)
    GetProduct(ctx context.Context, id string) (*Product, error)
    ListProducts(ctx context.Context, limit, offset int) ([]*Product, error)
    UpdateProduct(ctx context.Context, id string, req *UpdateProductRequest) (*Product, error)
    DeleteProduct(ctx context.Context, id string) error
}

var (
    ErrProductNotFound = errors.New("product not found")
    ErrProductExists   = errors.New("product already exists")
    ErrInvalidData     = errors.New("invalid product data")
)

Step 2: Implement Repository Layer

// services/product-service/internal/repository/memory/product_repo.go
package memory

import (
    "context"
    "sync"
    "time"

    "product-service/internal/domain"
)

type productRepository struct {
    mu       sync.RWMutex
    products map[string]*domain.Product
}

func NewProductRepository() domain.ProductRepository {
    return &productRepository{
        products: make(map[string]*domain.Product),
    }
}

func (r *productRepository) Create(ctx context.Context, product *domain.Product) error {
    r.mu.Lock()
    defer r.mu.Unlock()

    if _, exists := r.products[product.ID]; exists {
        return domain.ErrProductExists
    }

    now := time.Now().Unix()
    product.CreatedAt = now
    product.UpdatedAt = now

    r.products[product.ID] = product
    return nil
}

func (r *productRepository) GetByID(ctx context.Context, id string) (*domain.Product, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()

    product, exists := r.products[id]
    if !exists {
        return nil, domain.ErrProductNotFound
    }

    // Return a copy to avoid race conditions
    productCopy := *product
    return &productCopy, nil
}

func (r *productRepository) GetAll(ctx context.Context, limit, offset int) ([]*domain.Product, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()

    var products []*domain.Product
    count := 0

    for _, product := range r.products {
        if count < offset {
            count++
            continue
        }

        if len(products) >= limit {
            break
        }

        productCopy := *product
        products = append(products, &productCopy)
        count++
    }

    return products, nil
}

func (r *productRepository) Update(ctx context.Context, id string, updates *domain.UpdateProductRequest) (*domain.Product, error) {
    r.mu.Lock()
    defer r.mu.Unlock()

    product, exists := r.products[id]
    if !exists {
        return nil, domain.ErrProductNotFound
    }

    // Apply updates
    if updates.Name != nil {
        product.Name = *updates.Name
    }
    if updates.Description != nil {
        product.Description = *updates.Description
    }
    if updates.Price != nil {
        product.Price = *updates.Price
    }

    product.UpdatedAt = time.Now().Unix()

    productCopy := *product
    return &productCopy, nil
}

func (r *productRepository) Delete(ctx context.Context, id string) error {
    r.mu.Lock()
    defer r.mu.Unlock()

    if _, exists := r.products[id]; !exists {
        return domain.ErrProductNotFound
    }

    delete(r.products, id)
    return nil
}

Step 3: Create Service Layer

// services/product-service/internal/service/product_service.go
package service

import (
    "context"
    "fmt"

    "product-service/internal/domain"
    "github.com/go-playground/validator/v10"
    "github.com/google/uuid"
)

type productService struct {
    repo      domain.ProductRepository
    validator *validator.Validate
}

func NewProductService(repo domain.ProductRepository) domain.ProductService {
    return &productService{
        repo:      repo,
        validator: validator.New(),
    }
}

func (s *productService) CreateProduct(ctx context.Context, req *domain.CreateProductRequest) (*domain.Product, error) {
    if err := s.validator.Struct(req); err != nil {
        return nil, fmt.Errorf("%w: %v", domain.ErrInvalidData, err)
    }

    product := &domain.Product{
        ID:          uuid.New().String(),
        Name:        req.Name,
        Description: req.Description,
        Price:       req.Price,
    }

    if err := s.repo.Create(ctx, product); err != nil {
        return nil, err
    }

    return product, nil
}

func (s *productService) GetProduct(ctx context.Context, id string) (*domain.Product, error) {
    return s.repo.GetByID(ctx, id)
}

func (s *productService) ListProducts(ctx context.Context, limit, offset int) ([]*domain.Product, error) {
    if limit <= 0 || limit > 100 {
        limit = 20
    }
    if offset < 0 {
        offset = 0
    }

    return s.repo.GetAll(ctx, limit, offset)
}

func (s *productService) UpdateProduct(ctx context.Context, id string, req *domain.UpdateProductRequest) (*domain.Product, error) {
    // Validate that at least one field is being updated
    if req.Name == nil && req.Description == nil && req.Price == nil {
        return nil, fmt.Errorf("%w: no fields to update", domain.ErrInvalidData)
    }

    // Validate price if provided
    if req.Price != nil && *req.Price <= 0 {
        return nil, fmt.Errorf("%w: price must be greater than 0", domain.ErrInvalidData)
    }

    return s.repo.Update(ctx, id, req)
}

func (s *productService) DeleteProduct(ctx context.Context, id string) error {
    return s.repo.Delete(ctx, id)
}

Step 4: Build HTTP Handler

// services/product-service/internal/handler/http/product_handler.go
package http

import (
    "encoding/json"
    "net/http"
    "strconv"

    "product-service/internal/domain"
    "github.com/gorilla/mux"
    "github.com/rs/zerolog/log"
)

type ProductHandler struct {
    service domain.ProductService
}

func NewProductHandler(service domain.ProductService) *ProductHandler {
    return &ProductHandler{service: service}
}

func (h *ProductHandler) RegisterRoutes(router *mux.Router) {
    router.HandleFunc("/products", h.CreateProduct).Methods("POST")
    router.HandleFunc("/products", h.ListProducts).Methods("GET")
    router.HandleFunc("/products/{id}", h.GetProduct).Methods("GET")
    router.HandleFunc("/products/{id}", h.UpdateProduct).Methods("PUT")
    router.HandleFunc("/products/{id}", h.DeleteProduct).Methods("DELETE")
}

func (h *ProductHandler) CreateProduct(w http.ResponseWriter, r *http.Request) {
    var req domain.CreateProductRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "Invalid request body", http.StatusBadRequest)
        return
    }

    product, err := h.service.CreateProduct(r.Context(), &req)
    if err != nil {
        log.Error().Err(err).Msg("Failed to create product")

        switch err {
        case domain.ErrProductExists:
            http.Error(w, "Product already exists", http.StatusConflict)
        case domain.ErrInvalidData:
            http.Error(w, err.Error(), http.StatusBadRequest)
        default:
            http.Error(w, "Internal server error", http.StatusInternalServerError)
        }
        return
    }

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

func (h *ProductHandler) GetProduct(w http.ResponseWriter, r *http.Request) {
    vars := mux.Vars(r)
    id := vars["id"]

    product, err := h.service.GetProduct(r.Context(), id)
    if err != nil {
        switch err {
        case domain.ErrProductNotFound:
            http.Error(w, "Product not found", http.StatusNotFound)
        default:
            http.Error(w, "Internal server error", http.StatusInternalServerError)
        }
        return
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(product)
}

func (h *ProductHandler) ListProducts(w http.ResponseWriter, r *http.Request) {
    limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
    offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))

    products, err := h.service.ListProducts(r.Context(), limit, offset)
    if err != nil {
        http.Error(w, "Internal server error", http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]interface{}{
        "products": products,
        "limit":    limit,
        "offset":   offset,
    })
}

func (h *ProductHandler) UpdateProduct(w http.ResponseWriter, r *http.Request) {
    vars := mux.Vars(r)
    id := vars["id"]

    var req domain.UpdateProductRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "Invalid request body", http.StatusBadRequest)
        return
    }

    product, err := h.service.UpdateProduct(r.Context(), id, &req)
    if err != nil {
        switch err {
        case domain.ErrProductNotFound:
            http.Error(w, "Product not found", http.StatusNotFound)
        case domain.ErrInvalidData:
            http.Error(w, err.Error(), http.StatusBadRequest)
        default:
            http.Error(w, "Internal server error", http.StatusInternalServerError)
        }
        return
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(product)
}

func (h *ProductHandler) DeleteProduct(w http.ResponseWriter, r *http.Request) {
    vars := mux.Vars(r)
    id := vars["id"]

    if err := h.service.DeleteProduct(r.Context(), id); err != nil {
        switch err {
        case domain.ErrProductNotFound:
            http.Error(w, "Product not found", http.StatusNotFound)
        default:
            http.Error(w, "Internal server error", http.StatusInternalServerError)
        }
        return
    }

    w.WriteHeader(http.StatusNoContent)
}

Step 5: Implement Middleware

// services/product-service/pkg/middleware/auth.go
package middleware

import (
    "net/http"
)

func AuthMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        token := r.Header.Get("Authorization")
        if token == "" {
            http.Error(w, "Unauthorized", http.StatusUnauthorized)
            return
        }

        // In a real application, you would validate the JWT token here
        // For this example, we just check if the token exists

        next.ServeHTTP(w, r)
    })
}

// services/product-service/pkg/middleware/logging.go
package middleware

import (
    "net/http"
    "time"

    "github.com/rs/zerolog/log"
)

func LoggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()

        log.Info().
            Str("method", r.Method).
            Str("path", r.URL.Path).
            Str("remote_addr", r.RemoteAddr).
            Msg("Request started")

        next.ServeHTTP(w, r)

        log.Info().
            Str("method", r.Method).
            Str("path", r.URL.Path).
            Dur("duration", time.Since(start)).
            Msg("Request completed")
    })
}

Step 6: Main Server Setup

// services/product-service/cmd/server/main.go
package main

import (
    "context"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"

    "product-service/internal/handler/http"
    "product-service/internal/repository/memory"
    "product-service/internal/service"
    "product-service/pkg/middleware"

    "github.com/gorilla/mux"
    "github.com/rs/cors"
    "github.com/rs/zerolog"
    "github.com/rs/zerolog/log"
)

func main() {
    // Configure logger
    zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
    log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})

    // Initialize dependencies
    repo := memory.NewProductRepository()
    productService := service.NewProductService(repo)
    productHandler := httpHandler.NewProductHandler(productService)

    // Setup routes
    router := mux.NewRouter()

    // Health check
    router.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
        w.Write([]byte("OK"))
    }).Methods("GET")

    // API routes
    apiRouter := router.PathPrefix("/api/v1").Subrouter()
    productHandler.RegisterRoutes(apiRouter)

    // Apply middleware
    corsHandler := cors.New(cors.Options{
        AllowedOrigins:   []string{"*"},
        AllowedMethods:   []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
        AllowedHeaders:   []string{"*"},
        AllowCredentials: true,
    })

    handler := corsHandler.Handler(
        middleware.LoggingMiddleware(
            middleware.AuthMiddleware(router),
        ),
    )

    // Create server
    server := &http.Server{
        Addr:         ":8080",
        Handler:      handler,
        ReadTimeout:  15 * time.Second,
        WriteTimeout: 15 * time.Second,
        IdleTimeout:  60 * time.Second,
    }

    // Start server in goroutine
    go func() {
        log.Info().Str("addr", server.Addr).Msg("Starting product service")
        if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatal().Err(err).Msg("Failed to start server")
        }
    }()

    // Wait for interrupt signal for graceful shutdown
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit

    log.Info().Msg("Shutting down server...")

    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    if err := server.Shutdown(ctx); err != nil {
        log.Fatal().Err(err).Msg("Server forced to shutdown")
    }

    log.Info().Msg("Server exited")
}

Running the Microservice

Initialize Go module:

cd services/product-service
go mod init product-service
go mod tidy

Run the service:

go run cmd/server/main.go

Test the endpoints:

Create Product:

curl -X POST -H "Content-Type: application/json" -H "Authorization: Bearer token" \
  -d '{"name":"Laptop","description":"MacBook Pro","price":1299.99}' \
  http://localhost:8080/api/v1/products

List Products:

curl -H "Authorization: Bearer token" \
  http://localhost:8080/api/v1/products?limit=10&offset=0

Get Product:

curl -H "Authorization: Bearer token" \
  http://localhost:8080/api/v1/products/{product-id}

Docker Configuration

# services/product-service/Dockerfile
FROM golang:1.21-alpine AS builder

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o main ./cmd/server

FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/main .
EXPOSE 8080
CMD ["./main"]

Enhancing the Microservice

As your microservice grows, consider adding:

  • Database Integration: Replace in-memory storage with PostgreSQL, MongoDB, or other databases
  • Authentication: Implement JWT validation or OAuth2
  • API Documentation: Generate OpenAPI specs automatically
  • Caching: Add Redis for frequently accessed data
  • Message Queues: Use RabbitMQ or Apache Kafka for async communication
  • Observability: Add Prometheus metrics, distributed tracing
  • Testing: Unit tests, integration tests, and contract testing

Conclusion

Go provides excellent tools for building scalable microservices. With clean architecture, proper error handling, and middleware support, you can create maintainable services that scale with your business needs.

The key to successful microservices is:

  • Clear domain boundaries
  • Consistent project structure
  • Proper error handling
  • Comprehensive testing
  • Observability from day one

Start simple, iterate fast, and add complexity only when needed. This approach will help you build robust microservices that can evolve with your requirements.

Tags

#Go
#Microservices
#Architecture
#Backend
Published on February 25, 2025