Essential Guide to Golang Microservices and Docker Standards

Building modern microservices with Go requires a well-structured approach and consistent standards. After years of working with Go microservices in production, here are the key principles and practices that make a significant difference.

Project Organization: The Foundation of Maintainable Code

The backbone of any successful Go microservice is its project structure. We follow a domain-driven design approach with a clear separation of concerns:

Standard Project Layout

service-name/
├── cmd/                    # Main applications
│   └── service-name/      # Main application entry point
├── internal/              # Private code
│   ├── domain/           # Domain models
│   ├── repository/       # Data access layer
│   ├── service/          # Business logic
│   ├── handler/          # HTTP handlers
│   └── middleware/       # HTTP middleware
├── pkg/                  # Public library code
├── api/                  # OpenAPI/Swagger specs, JSON schema files, protocol definition files
├── configs/              # Configuration files
├── scripts/             # Scripts for development, CI/CD
├── test/                # Integration and end-to-end tests
└── deployments/         # Deployment configurations (Docker, K8s)

Dependencies Management

  • Use go.mod for dependency management
  • Pin dependency versions explicitly
  • Regularly update dependencies and audit security
  • Maintain a tools.go file for development tools

Code Organization

Package Design Principles

  • Follow single responsibility principle
  • Use domain-driven design concepts
  • Keep packages focused and cohesive
  • Avoid circular dependencies
  • Use dependency injection

Interface Design

// Interfaces should be small and focused
type UserRepository interface {
  GetByID(ctx context.Context, id string) (*User, error)
  Create(ctx context.Context, user *User) error
  Update(ctx context.Context, user *User) error
}

Naming Conventions

General Naming

  • Use camelCase for private functions/variables
  • Use PascalCase for exported functions/variables
  • Use lowercase for package names
  • Avoid abbreviations unless widely known

File Naming

  • Use snake_case for file names
  • Append _test.go for test files
  • Use descriptive names that reflect content

Interface Naming

  • Single method interfaces: method name + ‘er’
  • Multiple method interfaces: descriptive of behavior
type Reader interface {
    Read(p []byte) (n int, err error)
}

type UserService interface {
    CreateUser(ctx context.Context, user *User) error
    GetUser(ctx context.Context, id string) (*User, error)
}

Microservices Best Practices: Small, Focused, and Independent

Our microservices philosophy centers on three core principles:

  • Single Responsibility: Each service handles one business capability
  • Independent Deployability: Services can be deployed without affecting others
  • Data Isolation: Each service owns its data and exposes it via APIs

Service Design

  • Keep services small and focused
  • Design around business capabilities
  • Implement service discovery
  • Use health checks and circuit breakers
  • Implement proper logging and monitoring

API Design

// Use consistent URL patterns
// /api/v1/resources/{id}
func (h *Handler) SetupRoutes(r *gin.Engine) {
    v1 := r.Group("/api/v1")
    {
        v1.GET("/users/:id", h.GetUser)
        v1.POST("/users", h.CreateUser)
        v1.PUT("/users/:id", h.UpdateUser)
    }
}

Configuration

  • Use environment variables for configuration
  • Implement feature flags
  • Use secure configuration management
  • Implement proper versioning

The Power of Gin Framework

We chose Gin for its performance and simplicity. Key advantages include:

  • Middleware-based architecture for cross-cutting concerns
  • Built-in input validation
  • Excellent error handling capabilities
  • Strong community support

Router Setup

func SetupRouter() *gin.Engine {
    r := gin.New()

    // Use recommended middleware
    r.Use(gin.Recovery())
    r.Use(gin.Logger())
    r.Use(middleware.CORS())

    return r
}

Middleware Best Practices

func AuthMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        token := c.GetHeader("Authorization")
        if token == "" {
            c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
                "error": "unauthorized",
            })
            return
        }
        // Validate token and set user in context
        c.Next()
    }
}

Request Validation

type CreateUserRequest struct {
    Name     string `json:"name" binding:"required,min=2"`
    Email    string `json:"email" binding:"required,email"`
    Password string `json:"password" binding:"required,min=8"`
}

Message Queue Patterns That Scale

Our message queue naming convention follows:

Queue Naming Conventions

<environment>.<service>.<event-type>.<action>

Examples:

  • prod.user-service.user.created
  • dev.payment-service.transaction.completed
  • stage.notification-service.email.sent

Exchange Naming

<environment>.<service>.<domain>

Examples:

  • prod.user-service.users
  • dev.payment-service.transactions

Message Structure

type Message struct {
    ID        string      `json:"id"`
    Version   string      `json:"version"`
    Timestamp time.Time   `json:"timestamp"`
    Type      string      `json:"type"`
    Data      interface{} `json:"data"`
}

Queue Configuration

type QueueConfig struct {
    Name              string
    Durable           bool
    DeleteWhenUnused  bool
    Exclusive         bool
    NoWait            bool
    Args              amqp.Table
}

Error Handling

Error Types

type DomainError struct {
    Code    string
    Message string
    Err     error
}

func (e *DomainError) Error() string {
    return fmt.Sprintf("%s: %s", e.Code, e.Message)
}

Error Handling Patterns

func (s *service) CreateUser(ctx context.Context, user *User) error {
    if err := user.Validate(); err != nil {
        return &DomainError{
            Code:    "VALIDATION_ERROR",
            Message: "invalid user data",
            Err:     err,
        }
    }

    // Continue with operation
    return nil
}

Testing Standards

Unit Tests

func TestUserService_CreateUser(t *testing.T) {
    tests := []struct {
        name    string
        user    *User
        wantErr bool
    }{
        {
            name: "valid user",
            user: &User{
                Name:  "John Doe",
                Email: "[email protected]",
            },
            wantErr: false,
        },
        // Add more test cases
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            // Test implementation
        })
    }
}

Integration Tests

  • Use Docker Compose for dependencies
  • Implement cleanup in teardown
  • Use test containers when possible
  • Maintain separate test configuration

Documentation

Code Documentation

// UserService provides user management operations
type UserService interface {
    // CreateUser creates a new user in the system
    // Returns ErrDuplicateEmail if email already exists
    CreateUser(ctx context.Context, user *User) error

    // GetUser retrieves a user by ID
    // Returns ErrUserNotFound if user doesn't exist
    GetUser(ctx context.Context, id string) (*User, error)
}

API Documentation

  • Use OpenAPI/Swagger for API documentation
  • Document all endpoints, parameters, and responses
  • Include example requests and responses
  • Document error codes and scenarios

README Standards

  • Include project description
  • Document setup instructions
  • List dependencies
  • Provide usage examples
  • Include contribution guidelines

Docker Multi-Stage Builds: Lean and Secure

Our Docker builds focus on three key aspects:

  • Security: Running as non-root, minimal base images
  • Size: Multi-stage builds to minimize image size
  • Speed: Layer optimization for faster builds

Multi-Stage Build Pattern

# Stage 1: Build
FROM golang:1.21-alpine AS builder

# Set working directory
WORKDIR /app

# Install build dependencies
RUN apk add --no-cache git make build-base

# Copy go mod files
COPY go.mod go.sum ./
RUN go mod download

# Copy source code
COPY . .

# Build the application
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /app/service ./cmd/service-name

# Stage 2: Runtime
FROM alpine:3.19

# Add CA certificates and timezone data
RUN apk add --no-cache ca-certificates tzdata

# Create non-root user
RUN adduser -D -g '' appuser

# Set working directory
WORKDIR /app

# Copy binary from builder
COPY --from=builder /app/service .
COPY --from=builder /app/configs ./configs

# Use non-root user
USER appuser

# Expose necessary port
EXPOSE 8080

# Set entrypoint
ENTRYPOINT ["/app/service"]

Docker Build Standards

Build Arguments
ARG GO_VERSION=1.21
ARG ALPINE_VERSION=3.19

FROM golang:${GO_VERSION}-alpine AS builder
FROM alpine:${ALPINE_VERSION} AS runtime
Layer Optimization
  • Order instructions from least to most frequently changing
  • Group related commands using && to reduce layers
  • Use .dockerignore to exclude unnecessary files
# Good practice - combining related commands
RUN apk add --no-cache 
    ca-certificates 
    tzdata && rm -rf /var/cache/apk/*

# Bad practice - separate commands create unnecessary layers
RUN apk add --no-cache ca-certificates
RUN apk add --no-cache tzdata
RUN rm -rf /var/cache/apk/*
Security Standards
  • Use specific base image versions
  • Run as non-root user
  • Scan images for vulnerabilities
  • Minimize image size
  • Remove unnecessary tools and packages
Environment Variables
# Runtime configuration
ENV APP_ENV=production
ENV SERVER_PORT=8080
ENV LOG_LEVEL=info

# Good practice - group related environment variables
ENV APP_USER=appuser 
    APP_UID=1000 
    APP_GID=1000

Development vs Production Builds

Development Dockerfile
# dev.Dockerfile
FROM golang:1.21-alpine

WORKDIR /app

RUN go install github.com/cosmtrek/air@latest

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

COPY . .

CMD ["air", "-c", ".air.toml"]
Production Multi-Stage Build
# prod.Dockerfile
FROM golang:1.21-alpine AS builder

WORKDIR /app

# Build dependencies and optimizations
RUN apk add --no-cache git make build-base
COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN make build

FROM alpine:3.19

# Production-specific setup
COPY --from=builder /app/bin/service /usr/local/bin/
USER nonroot
ENTRYPOINT ["/usr/local/bin/service"]

Docker Compose Standards

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
      args:
        - GO_VERSION=1.21
    environment:
      - APP_ENV=development
      - DB_HOST=postgres
    ports:
      - "8080:8080"
    depends_on:
      - postgres
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
      interval: 30s
      timeout: 10s
      retries: 3

  postgres:
    image: postgres:15-alpine
    environment:
      - POSTGRES_USER=myapp
      - POSTGRES_PASSWORD=secret
      - POSTGRES_DB=myapp
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  postgres_data:

Image Tagging Standards

  • Use semantic versioning
  • Include git commit hash
  • Tag with environment/stage
# Example tagging pattern
<registry>/<organization>/<service>:<semantic-version>-<git-hash>-<environment>

Example:

docker tag myapp:latest gcr.io/myorg/user-service:1.2.3-a1b2c3d-prod

Key Takeaways

  • Structure matters: A well-organized project saves countless hours
  • Standards should enable, not restrict: Focus on patterns that increase productivity
  • Security is built-in: From Docker to code organization, security is a first-class citizen
  • Automation is key: From testing to deployment, automate everything possible

Discover more from FastCode

Subscribe to get the latest posts sent to your email.

Leave a Reply

Index