12 min read

Building Microservices with Go: Best Practices and Patterns

A comprehensive guide to building scalable microservices using Go, covering architecture patterns, testing strategies, and deployment considerations.

golang microservices architecture distributed-systems

Building Microservices with Go: Best Practices and Patterns

Go’s simplicity, performance characteristics, and built-in concurrency support make it an excellent choice for building microservices. In this comprehensive guide, we’ll explore proven patterns and practices for creating maintainable, scalable microservices using Go.

Service Architecture Patterns

Hexagonal Architecture

Also known as Ports and Adapters, this pattern helps create loosely coupled, testable services:

 1// Domain layer - business logic
 2type UserService interface {
 3    CreateUser(ctx context.Context, user *User) error
 4    GetUser(ctx context.Context, id string) (*User, error)
 5}
 6
 7type userService struct {
 8    repo UserRepository
 9}
10
11func NewUserService(repo UserRepository) UserService {
12    return &userService{repo: repo}
13}
14
15func (s *userService) CreateUser(ctx context.Context, user *User) error {
16    if err := user.Validate(); err != nil {
17        return fmt.Errorf("invalid user: %w", err)
18    }
19    return s.repo.Save(ctx, user)
20}
21
22// Repository interface - abstraction for data access
23type UserRepository interface {
24    Save(ctx context.Context, user *User) error
25    FindByID(ctx context.Context, id string) (*User, error)
26}
27
28// Infrastructure layer - concrete implementation
29type postgresUserRepository struct {
30    db *sql.DB
31}
32
33func (r *postgresUserRepository) Save(ctx context.Context, user *User) error {
34    query := `INSERT INTO users (id, name, email) VALUES ($1, $2, $3)`
35    _, err := r.db.ExecContext(ctx, query, user.ID, user.Name, user.Email)
36    return err
37}

HTTP Handler Patterns

Structure your HTTP handlers for maintainability and testability:

 1type UserHandler struct {
 2    service UserService
 3    logger  *slog.Logger
 4}
 5
 6func NewUserHandler(service UserService, logger *slog.Logger) *UserHandler {
 7    return &UserHandler{
 8        service: service,
 9        logger:  logger,
10    }
11}
12
13func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
14    ctx := r.Context()
15    
16    var req CreateUserRequest
17    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
18        h.writeError(w, http.StatusBadRequest, "invalid request body")
19        return
20    }
21    
22    user := &User{
23        ID:    generateID(),
24        Name:  req.Name,
25        Email: req.Email,
26    }
27    
28    if err := h.service.CreateUser(ctx, user); err != nil {
29        h.logger.Error("failed to create user", "error", err)
30        h.writeError(w, http.StatusInternalServerError, "failed to create user")
31        return
32    }
33    
34    h.writeJSON(w, http.StatusCreated, CreateUserResponse{ID: user.ID})
35}
36
37func (h *UserHandler) writeError(w http.ResponseWriter, status int, message string) {
38    w.Header().Set("Content-Type", "application/json")
39    w.WriteHeader(status)
40    json.NewEncoder(w).Encode(ErrorResponse{Error: message})
41}
42
43func (h *UserHandler) writeJSON(w http.ResponseWriter, status int, data interface{}) {
44    w.Header().Set("Content-Type", "application/json")
45    w.WriteHeader(status)
46    json.NewEncoder(w).Encode(data)
47}

Configuration Management

Implement flexible configuration using environment variables and structured config:

 1type Config struct {
 2    Server   ServerConfig   `json:"server"`
 3    Database DatabaseConfig `json:"database"`
 4    Redis    RedisConfig    `json:"redis"`
 5}
 6
 7type ServerConfig struct {
 8    Port         int           `json:"port" env:"PORT" envDefault:"8080"`
 9    ReadTimeout  time.Duration `json:"read_timeout" env:"READ_TIMEOUT" envDefault:"30s"`
10    WriteTimeout time.Duration `json:"write_timeout" env:"WRITE_TIMEOUT" envDefault:"30s"`
11}
12
13type DatabaseConfig struct {
14    Host     string `json:"host" env:"DB_HOST" envDefault:"localhost"`
15    Port     int    `json:"port" env:"DB_PORT" envDefault:"5432"`
16    Name     string `json:"name" env:"DB_NAME" envDefault:"myapp"`
17    User     string `json:"user" env:"DB_USER" envDefault:"postgres"`
18    Password string `json:"password" env:"DB_PASSWORD"`
19}
20
21func LoadConfig() (*Config, error) {
22    var cfg Config
23    if err := env.Parse(&cfg); err != nil {
24        return nil, fmt.Errorf("failed to parse config: %w", err)
25    }
26    return &cfg, nil
27}

Error Handling and Logging

Implement structured error handling and logging:

 1// Custom error types for better error handling
 2type AppError struct {
 3    Code    string `json:"code"`
 4    Message string `json:"message"`
 5    Cause   error  `json:"-"`
 6}
 7
 8func (e *AppError) Error() string {
 9    return e.Message
10}
11
12func (e *AppError) Unwrap() error {
13    return e.Cause
14}
15
16// Predefined error types
17var (
18    ErrUserNotFound = &AppError{
19        Code:    "USER_NOT_FOUND",
20        Message: "User not found",
21    }
22    
23    ErrUserExists = &AppError{
24        Code:    "USER_EXISTS",
25        Message: "User already exists",
26    }
27)
28
29// Service implementation with proper error handling
30func (s *userService) GetUser(ctx context.Context, id string) (*User, error) {
31    user, err := s.repo.FindByID(ctx, id)
32    if err != nil {
33        if errors.Is(err, sql.ErrNoRows) {
34            return nil, ErrUserNotFound
35        }
36        return nil, fmt.Errorf("failed to get user: %w", err)
37    }
38    return user, nil
39}
40
41// Middleware for request logging
42func LoggingMiddleware(logger *slog.Logger) func(http.Handler) http.Handler {
43    return func(next http.Handler) http.Handler {
44        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
45            start := time.Now()
46            
47            // Wrap ResponseWriter to capture status code
48            wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
49            
50            next.ServeHTTP(wrapped, r)
51            
52            logger.Info("request completed",
53                "method", r.Method,
54                "path", r.URL.Path,
55                "status", wrapped.statusCode,
56                "duration", time.Since(start),
57                "remote_addr", r.RemoteAddr,
58            )
59        })
60    }
61}

Testing Strategies

Unit Testing

Write comprehensive unit tests using dependency injection:

 1func TestUserService_CreateUser(t *testing.T) {
 2    tests := []struct {
 3        name    string
 4        user    *User
 5        repoErr error
 6        wantErr bool
 7    }{
 8        {
 9            name: "valid user",
10            user: &User{
11                ID:    "123",
12                Name:  "John Doe",
13                Email: "john@example.com",
14            },
15            wantErr: false,
16        },
17        {
18            name: "invalid user",
19            user: &User{
20                ID:    "123",
21                Name:  "",
22                Email: "invalid-email",
23            },
24            wantErr: true,
25        },
26    }
27    
28    for _, tt := range tests {
29        t.Run(tt.name, func(t *testing.T) {
30            mockRepo := &mockUserRepository{
31                saveErr: tt.repoErr,
32            }
33            
34            service := NewUserService(mockRepo)
35            
36            err := service.CreateUser(context.Background(), tt.user)
37            
38            if tt.wantErr {
39                assert.Error(t, err)
40            } else {
41                assert.NoError(t, err)
42                assert.True(t, mockRepo.saveCalled)
43            }
44        })
45    }
46}
47
48// Mock implementation for testing
49type mockUserRepository struct {
50    saveErr    error
51    saveCalled bool
52}
53
54func (m *mockUserRepository) Save(ctx context.Context, user *User) error {
55    m.saveCalled = true
56    return m.saveErr
57}

Integration Testing

Test your services with real dependencies using testcontainers:

 1func TestUserRepository_Integration(t *testing.T) {
 2    // Start PostgreSQL container for testing
 3    ctx := context.Background()
 4    
 5    postgres, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
 6        ContainerRequest: testcontainers.ContainerRequest{
 7            Image:        "postgres:13",
 8            ExposedPorts: []string{"5432/tcp"},
 9            Env: map[string]string{
10                "POSTGRES_PASSWORD": "password",
11                "POSTGRES_DB":       "testdb",
12            },
13            WaitingFor: wait.ForLog("database system is ready to accept connections"),
14        },
15        Started: true,
16    })
17    require.NoError(t, err)
18    defer postgres.Terminate(ctx)
19    
20    // Get connection details
21    host, err := postgres.Host(ctx)
22    require.NoError(t, err)
23    
24    port, err := postgres.MappedPort(ctx, "5432")
25    require.NoError(t, err)
26    
27    // Connect to database and run tests
28    db, err := sql.Open("postgres", fmt.Sprintf(
29        "host=%s port=%s user=postgres password=password dbname=testdb sslmode=disable",
30        host, port.Port(),
31    ))
32    require.NoError(t, err)
33    defer db.Close()
34    
35    // Run your integration tests here
36    repo := &postgresUserRepository{db: db}
37    // ... test repository methods
38}

Observability

Implement comprehensive observability with metrics, tracing, and health checks:

 1// Health check endpoint
 2func (s *Server) healthCheck(w http.ResponseWriter, r *http.Request) {
 3    ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
 4    defer cancel()
 5    
 6    health := HealthStatus{
 7        Status: "healthy",
 8        Checks: make(map[string]CheckResult),
 9    }
10    
11    // Check database connectivity
12    if err := s.db.PingContext(ctx); err != nil {
13        health.Status = "unhealthy"
14        health.Checks["database"] = CheckResult{
15            Status: "unhealthy",
16            Error:  err.Error(),
17        }
18    } else {
19        health.Checks["database"] = CheckResult{Status: "healthy"}
20    }
21    
22    // Check Redis connectivity
23    if _, err := s.redis.Ping(ctx).Result(); err != nil {
24        health.Status = "unhealthy"
25        health.Checks["redis"] = CheckResult{
26            Status: "unhealthy",
27            Error:  err.Error(),
28        }
29    } else {
30        health.Checks["redis"] = CheckResult{Status: "healthy"}
31    }
32    
33    status := http.StatusOK
34    if health.Status == "unhealthy" {
35        status = http.StatusServiceUnavailable
36    }
37    
38    w.Header().Set("Content-Type", "application/json")
39    w.WriteHeader(status)
40    json.NewEncoder(w).Encode(health)
41}
42
43// Prometheus metrics
44var (
45    httpRequestsTotal = prometheus.NewCounterVec(
46        prometheus.CounterOpts{
47            Name: "http_requests_total",
48            Help: "Total number of HTTP requests",
49        },
50        []string{"method", "endpoint", "status"},
51    )
52    
53    httpRequestDuration = prometheus.NewHistogramVec(
54        prometheus.HistogramOpts{
55            Name: "http_request_duration_seconds",
56            Help: "HTTP request duration in seconds",
57        },
58        []string{"method", "endpoint"},
59    )
60)
61
62func init() {
63    prometheus.MustRegister(httpRequestsTotal, httpRequestDuration)
64}

Deployment Considerations

Graceful Shutdown

Implement graceful shutdown to handle SIGTERM signals:

 1func (s *Server) Start() error {
 2    server := &http.Server{
 3        Addr:         fmt.Sprintf(":%d", s.config.Server.Port),
 4        Handler:      s.router,
 5        ReadTimeout:  s.config.Server.ReadTimeout,
 6        WriteTimeout: s.config.Server.WriteTimeout,
 7    }
 8    
 9    // Start server in goroutine
10    go func() {
11        s.logger.Info("server starting", "port", s.config.Server.Port)
12        if err := server.ListenAndServe(); err != http.ErrServerClosed {
13            s.logger.Error("server failed", "error", err)
14        }
15    }()
16    
17    // Wait for interrupt signal
18    quit := make(chan os.Signal, 1)
19    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
20    <-quit
21    
22    s.logger.Info("server shutting down")
23    
24    // Graceful shutdown with timeout
25    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
26    defer cancel()
27    
28    if err := server.Shutdown(ctx); err != nil {
29        return fmt.Errorf("server forced to shutdown: %w", err)
30    }
31    
32    s.logger.Info("server stopped")
33    return nil
34}

Conclusion

Building robust microservices with Go requires careful attention to architecture, testing, observability, and deployment practices. The patterns and examples shown here provide a solid foundation for creating maintainable, scalable services that can grow with your organization’s needs.

Remember to always prioritize simplicity, testability, and operational excellence when designing your microservices architecture.

🧠 Knowledge Checkpoint

Test your understanding with these questions:

1. What is the main advantage of using dependency injection in Go microservices?
2. Which HTTP status code should you return when a requested resource is not found?

About the Author

Sashitha Fonseka

Sashitha Fonseka

I'm passionate about building things to better understand tech concepts. In my blogs, I break down and explain complex tech topics in simple terms to help others learn. I'd love to hear your feedback, so feel free to reach out!