# Go Microservices Architecture — Patterns That Actually Work

## 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.

```go
// 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.

```go
// 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)
}
```

```go
// 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](https://grpc.io/) 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.

```protobuf
// 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`](https://pkg.go.dev/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](https://nats.io/):** Lightweight, easy to operate, JetStream for persistence. Great for most workloads.
- **[Apache Kafka](https://kafka.apache.org/):** Battle-tested at massive scale, but operationally heavy. Use when you genuinely need it.

```go
// 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

```go
// 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](https://github.com/sony/gobreaker) is the standard Go circuit breaker:

```go
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](https://alistair.cockburn.us/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

```go
// 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](https://opentelemetry.io/) is the standard. Propagate trace context through every service call:

```go
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](https://grafana.com/blog/2018/08/02/the-red-method-how-to-instrument-your-services/)):

```go
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.
