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.
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:
Results
About the Author

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!