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.