← All Posts

Go Microservices Architecture — Patterns That Actually Work

Matthias Bruns · · 7 min read
go microservices architecture backend

The Microservices Reality Check

Everyone talks about microservices. Few talk about why most microservices architectures fail in practice — not because of technology, but because of boundaries drawn in the wrong places.

Go is arguably the best language for microservices today. But Go alone won’t save you from a distributed monolith. What matters is how you structure services, how they talk to each other, and how you handle failure.

This post covers patterns we use in production. No theory-only concepts. No “it depends” without follow-through.

Why Go Fits Microservices

Go was practically built for this:

  • Single binary deployment. No runtime, no dependency hell. A 15 MB Docker image that starts in milliseconds.
  • First-class concurrency. Goroutines and channels map naturally to handling concurrent requests across services.
  • Fast compilation. CI pipelines that build 20 services in under a minute.
  • Explicit error handling. In a distributed system, every call can fail. Go forces you to deal with that.
// A typical service binary: small, self-contained, fast to start
func main() {
    cfg := config.Load()
    db := database.Connect(cfg.DatabaseURL)
    defer db.Close()

    svc := order.NewService(db, inventory.NewClient(cfg.InventoryURL))
    srv := server.New(cfg.Port, svc)

    if err := srv.ListenAndServe(); err != nil {
        log.Fatal(err)
    }
}

Compare that with a Spring Boot service: 200+ MB image, 15-second startup, classpath conflicts, annotation magic hiding half the control flow. Go services are transparent and fast.

Service Boundaries: Get This Wrong, Get Everything Wrong

The single most important decision is where to cut. Bad boundaries create services that constantly need to call each other for basic operations — a distributed monolith with network latency added for free.

Rules We Follow

1. One service owns one business capability.

Not “one service per database table.” Not “one service per team.” One service per business capability: orders, inventory, billing, notifications. If two concepts always change together, they belong in the same service.

2. Services communicate through events, not synchronous chains.

If Service A calls Service B, which calls Service C, which calls Service D — you don’t have microservices. You have a distributed function call with four points of failure.

// Bad: synchronous chain
func (s *OrderService) CreateOrder(ctx context.Context, req CreateOrderRequest) error {
    // This fails if inventory is down
    stock, err := s.inventoryClient.CheckStock(ctx, req.ProductID)
    if err != nil {
        return fmt.Errorf("check stock: %w", err)
    }
    // This fails if billing is down
    payment, err := s.billingClient.ChargeCard(ctx, req.PaymentInfo)
    if err != nil {
        return fmt.Errorf("charge card: %w", err)
    }
    // Three services must be up simultaneously
    return s.repo.SaveOrder(ctx, stock, payment)
}
// Better: publish event, let consumers react
func (s *OrderService) CreateOrder(ctx context.Context, req CreateOrderRequest) error {
    order, err := s.repo.CreatePendingOrder(ctx, req)
    if err != nil {
        return fmt.Errorf("create order: %w", err)
    }
    // Other services react asynchronously
    return s.events.Publish(ctx, events.OrderCreated{
        OrderID:   order.ID,
        ProductID: req.ProductID,
        Amount:    req.Amount,
    })
}

3. Shared databases are not allowed.

If two services read from the same table, they’re one service pretending to be two. Each service owns its data. Period.

Communication Patterns

gRPC for Service-to-Service

For synchronous calls between services (yes, sometimes you need them), gRPC with Protocol Buffers is the standard choice in Go:

  • Type-safe contracts. Proto files are your API documentation and your code generation source.
  • Streaming support. Bidirectional streaming for real-time data flows.
  • Performance. Binary serialization is 5-10x faster than JSON. Matters at scale.
// inventory/v1/inventory.proto
service InventoryService {
  rpc GetStock(GetStockRequest) returns (GetStockResponse);
  rpc ReserveStock(ReserveStockRequest) returns (ReserveStockResponse);
}

message GetStockRequest {
  string product_id = 1;
}

message GetStockResponse {
  int32 available = 1;
  int32 reserved = 2;
}

Go’s gRPC tooling (google.golang.org/grpc) generates server and client code from proto files. Type-safe, versioned, no guessing.

NATS or Kafka for Events

For asynchronous communication, the choice depends on your scale:

  • NATS: Lightweight, easy to operate, JetStream for persistence. Great for most workloads.
  • Apache Kafka: Battle-tested at massive scale, but operationally heavy. Use when you genuinely need it.
// Publishing an event with NATS JetStream
func (p *Publisher) Publish(ctx context.Context, event events.OrderCreated) error {
    data, err := json.Marshal(event)
    if err != nil {
        return fmt.Errorf("marshal event: %w", err)
    }
    _, err = p.js.Publish(ctx, "orders.created", data)
    if err != nil {
        return fmt.Errorf("publish orders.created: %w", err)
    }
    return nil
}

Error Propagation Across Services

In a monolith, you throw an exception and something catches it. In microservices, errors cross network boundaries. Go’s explicit error handling actually helps here.

Pattern: Structured Error Codes

// Shared error types across services
type ServiceError struct {
    Code    ErrorCode `json:"code"`
    Message string    `json:"message"`
    Service string    `json:"service"`
}

type ErrorCode string

const (
    ErrNotFound     ErrorCode = "NOT_FOUND"
    ErrConflict     ErrorCode = "CONFLICT"
    ErrUnavailable  ErrorCode = "UNAVAILABLE"
    ErrInternal     ErrorCode = "INTERNAL"
)

Map these to gRPC status codes at the transport layer. Business logic stays clean, transport concerns stay at the edges.

Circuit Breakers

When a downstream service is failing, stop calling it. sony/gobreaker is the standard Go circuit breaker:

cb := gobreaker.NewCircuitBreaker(gobreaker.Settings{
    Name:        "inventory-service",
    MaxRequests: 3,
    Interval:    10 * time.Second,
    Timeout:     30 * time.Second,
    ReadyToTrip: func(counts gobreaker.Counts) bool {
        return counts.ConsecutiveFailures > 5
    },
})

stock, err := cb.Execute(func() (any, error) {
    return inventoryClient.GetStock(ctx, productID)
})

Project Structure

Every Go microservice in our projects follows the same layout:

service-name/
├── cmd/
│   └── server/
│       └── main.go          # Entry point
├── internal/
│   ├── domain/              # Business types, no dependencies
│   ├── service/             # Business logic
│   ├── repository/          # Data access (Postgres, Redis)
│   └── transport/           # HTTP/gRPC handlers
├── proto/                   # Protocol buffer definitions
├── migrations/              # SQL migrations
├── Dockerfile
└── go.mod

Key rules:

  • internal/domain has zero imports from other packages. Pure business types.
  • internal/service depends only on interfaces, never concrete implementations.
  • internal/transport is the only layer that knows about HTTP or gRPC.
  • cmd/ wires everything together.

This isn’t novel. It’s hexagonal architecture applied to Go. The point is consistency: every service looks the same, every developer knows where to find things.

Observability: Non-Negotiable

You cannot run microservices without proper observability. Three pillars, no exceptions:

Structured Logging

// Use slog (standard library since Go 1.21)
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("order created",
    slog.String("order_id", order.ID),
    slog.String("customer_id", order.CustomerID),
    slog.Duration("latency", time.Since(start)),
)

Distributed Tracing

OpenTelemetry is the standard. Propagate trace context through every service call:

ctx, span := tracer.Start(ctx, "CreateOrder")
defer span.End()

span.SetAttributes(
    attribute.String("order.product_id", req.ProductID),
    attribute.Int("order.quantity", req.Quantity),
)

Metrics

Expose Prometheus metrics from every service. Track request rate, error rate, and latency (the RED method):

var requestDuration = promauto.NewHistogramVec(
    prometheus.HistogramOpts{
        Name:    "http_request_duration_seconds",
        Help:    "HTTP request duration in seconds",
        Buckets: prometheus.DefBuckets,
    },
    []string{"method", "path", "status"},
)

When Not to Use Microservices

If your team has fewer than 5 backend developers, start with a modular monolith. Seriously.

Microservices solve organizational scaling problems — independent teams deploying independently. If you don’t have that problem, you don’t need microservices. You need good module boundaries inside a monolith.

Go makes this easy too. Use internal/ packages to enforce boundaries. When you actually need to split, the boundaries are already there.

Bottom Line

Go microservices work well when you:

  1. Draw service boundaries around business capabilities, not technical layers.
  2. Default to asynchronous communication (events), use synchronous calls (gRPC) only when necessary.
  3. Treat observability as a requirement, not an afterthought.
  4. Keep each service simple, self-contained, and independently deployable.
  5. Don’t start with microservices unless your team size and deployment needs justify it.

The tooling exists. The patterns are proven. The hard part is discipline — and Go’s simplicity makes discipline easier to maintain.

Reader settings

Font size