Building a Microservice Architecture with Go

Team 8 min read

#go

#microservices

#architecture

#grpc

#kubernetes

Microservices are about small, independently deployable services that communicate over well-defined contracts. Go is a natural fit: fast startup, low memory, strong concurrency, and a solid standard library.

This article walks through a pragmatic baseline for Go microservices: service boundaries, contracts (HTTP/gRPC), messaging, observability, reliability patterns, and container/Kubernetes deployment. Along the way, we’ll build a minimal Orders service you can use as a template.

What you will learn

  • How to choose boundaries and contracts for services
  • A clean project layout for Go microservices
  • HTTP and gRPC basics, plus async events with NATS
  • Observability with structured logs and tracing
  • Reliability patterns: timeouts, retries, idempotency
  • Packaging with Docker and deploying to Kubernetes

When microservices make sense

  • Independent scaling or distinct performance profiles
  • Separate release cadence and ownership by small teams
  • Clear bounded contexts in your domain
  • Need for polyglot tech choices per service

Avoid microservices if you lack platform maturity (observability, automation), or your domain is still rapidly changing without clear boundaries.

Reference architecture

  • Edge: API Gateway/Ingress (Traefik/Envoy) exposes public HTTP/JSON
  • Internal comms: gRPC for service-to-service, HTTP/JSON for edge
  • Async events: NATS or Kafka for decoupled workflows
  • Data: each service owns its database (no cross-service DB access)
  • Observability: OpenTelemetry tracing, metrics, structured logs
  • Discovery/Config: Kubernetes Services, environment variables
  • Security: mTLS between services, JWT/OIDC at the edge

Project layout Use a modular layout that keeps implementation details internal and contracts explicit.

  • orders/
    • cmd/orders/main.go
    • internal/
      • app/ business services (use cases)
      • domain/ entities and domain logic
      • httpapi/ HTTP handlers and routing
      • grpcapi/ gRPC server implementations
      • repo/ repositories (db, memory)
      • mq/ messaging publishers/subscribers
      • config/ config loading and validation
      • instrumentation/ logging, tracing, metrics
    • proto/ protobuf definitions
    • Dockerfile
    • go.mod

Domain model and service Define entities and ports (interfaces) to decouple infrastructure.

// internal/domain/order.go
package domain

import (
	"time"
)

type OrderStatus string

const (
	OrderPending  OrderStatus = "PENDING"
	OrderCreated  OrderStatus = "CREATED"
	OrderCanceled OrderStatus = "CANCELED"
)

type Item struct {
	SKU      string
	Quantity int
	PriceCents int64
}

type Order struct {
	ID         string
	CustomerID string
	Items      []Item
	TotalCents int64
	Status     OrderStatus
	CreatedAt  time.Time
}

func (o *Order) ComputeTotal() {
	var sum int64
	for _, it := range o.Items {
		sum += int64(it.Quantity) * it.PriceCents
	}
	o.TotalCents = sum
}
// internal/app/service.go
package app

import (
	"context"
	"time"

	"github.com/yourorg/orders/internal/domain"
)

type OrderRepository interface {
	Save(ctx context.Context, o *domain.Order) error
	Get(ctx context.Context, id string) (*domain.Order, error)
}

type Publisher interface {
	PublishOrderCreated(ctx context.Context, o *domain.Order) error
}

type IDGen func() string
type Clock func() time.Time

type Service struct {
	repo OrderRepository
	pub  Publisher
	id   IDGen
	now  Clock
}

func NewService(r OrderRepository, p Publisher, id IDGen, now Clock) *Service {
	return &Service{repo: r, pub: p, id: id, now: now}
}

type CreateOrderInput struct {
	CustomerID string
	Items      []domain.Item
}

func (s *Service) CreateOrder(ctx context.Context, in CreateOrderInput) (*domain.Order, error) {
	o := &domain.Order{
		ID:         s.id(),
		CustomerID: in.CustomerID,
		Items:      in.Items,
		Status:     domain.OrderCreated,
		CreatedAt:  s.now(),
	}
	o.ComputeTotal()

	if err := s.repo.Save(ctx, o); err != nil {
		return nil, err
	}
	if err := s.pub.PublishOrderCreated(ctx, o); err != nil {
		// Log and continue or use outbox pattern in production
	}
	return o, nil
}

Repository and publisher (in-memory for local dev):

// internal/repo/memory.go
package repo

import (
	"context"
	"errors"
	"sync"

	"github.com/yourorg/orders/internal/domain"
)

type MemoryRepo struct {
	mu     sync.RWMutex
	orders map[string]*domain.Order
}

func NewMemoryRepo() *MemoryRepo {
	return &MemoryRepo{orders: map[string]*domain.Order{}}
}

func (r *MemoryRepo) Save(ctx context.Context, o *domain.Order) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	r.orders[o.ID] = o
	return nil
}

func (r *MemoryRepo) Get(ctx context.Context, id string) (*domain.Order, error) {
	r.mu.RLock()
	defer r.mu.RUnlock()
	o, ok := r.orders[id]
	if !ok {
		return nil, errors.New("not found")
	}
	return o, nil
}
// internal/mq/nop.go
package mq

import (
	"context"

	"github.com/yourorg/orders/internal/domain"
)

type NopPublisher struct{}

func (NopPublisher) PublishOrderCreated(ctx context.Context, o *domain.Order) error {
	return nil
}

HTTP API Use net/http or a lightweight router. Include timeouts and request IDs.

// internal/httpapi/http.go
package httpapi

import (
	"context"
	"encoding/json"
	"log/slog"
	"net/http"
	"time"

	"github.com/go-chi/chi/v5"
	"github.com/google/uuid"
	"github.com/yourorg/orders/internal/app"
	"github.com/yourorg/orders/internal/domain"
)

type Server struct {
	svc  *app.Service
	log  *slog.Logger
}

func NewServer(svc *app.Service, log *slog.Logger) *Server { return &Server{svc: svc, log: log} }

func (s *Server) Router() http.Handler {
	r := chi.NewRouter()
	r.Use(requestID)
	r.Use(timeout(5 * time.Second))
	r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
	r.Post("/orders", s.createOrder)
	return r
}

func (s *Server) createOrder(w http.ResponseWriter, r *http.Request) {
	var in struct {
		CustomerID string        `json:"customerId"`
		Items      []domain.Item `json:"items"`
	}
	if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
		http.Error(w, "bad request", http.StatusBadRequest); return
	}
	o, err := s.svc.CreateOrder(r.Context(), app.CreateOrderInput{
		CustomerID: in.CustomerID, Items: in.Items,
	})
	if err != nil {
		http.Error(w, "failed to create", http.StatusInternalServerError); return
	}
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusCreated)
	_ = json.NewEncoder(w).Encode(o)
}

func requestID(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		id := r.Header.Get("X-Request-ID")
		if id == "" { id = uuid.NewString() }
		w.Header().Set("X-Request-ID", id)
		next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKey("rid"), id)))
	})
}

func timeout(d time.Duration) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.TimeoutHandler(next, d, "request timeout")
	}
}

type ctxKey string

gRPC API Prefer gRPC for internal contracts; generate code with buf or protoc.

// proto/orders/v1/orders.proto
syntax = "proto3";
package orders.v1;
option go_package = "github.com/yourorg/orders/proto/gen/go/orders/v1;ordersv1";

message Item { string sku = 1; int32 quantity = 2; int64 price_cents = 3; }
message CreateOrderRequest { string customer_id = 1; repeated Item items = 2; }
message Order {
  string id = 1;
  string customer_id = 2;
  repeated Item items = 3;
  int64 total_cents = 4;
  string status = 5;
  string created_at = 6;
}

service Orders {
  rpc CreateOrder(CreateOrderRequest) returns (Order);
}

Messaging Emit domain events to decouple consumers. Example with NATS JetStream:

// internal/mq/nats.go
package mq

import (
	"context"
	"encoding/json"

	"github.com/nats-io/nats.go"
	"github.com/yourorg/orders/internal/domain"
)

type NATSPublisher struct{ js nats.JetStreamContext }

func NewNATSPublisher(js nats.JetStreamContext) *NATSPublisher { return &NATSPublisher{js: js} }

func (p *NATSPublisher) PublishOrderCreated(ctx context.Context, o *domain.Order) error {
	b, _ := json.Marshal(o)
	_, err := p.js.Publish("orders.created", b, nats.Context(ctx))
	return err
}

Service main Wire components, enable graceful shutdown, timeouts, and basic logging.

// cmd/orders/main.go
package main

import (
	"context"
	"log/slog"
	"net/http"
	"os"
	"os/signal"
	"time"
	"syscall"

	"github.com/google/uuid"
	"github.com/yourorg/orders/internal/app"
	"github.com/yourorg/orders/internal/httpapi"
	"github.com/yourorg/orders/internal/mq"
	"github.com/yourorg/orders/internal/repo"
)

func main() {
	log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))

	r := repo.NewMemoryRepo()
	pub := mq.NopPublisher{}
	svc := app.NewService(r, pub, func() string { return uuid.NewString() }, time.Now)

	httpSrv := &http.Server{
		Addr:              ":8080",
		Handler:           httpapi.NewServer(svc, log).Router(),
		ReadHeaderTimeout: 5 * time.Second,
	}

	go func() {
		log.Info("http server starting", "addr", httpSrv.Addr)
		if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			log.Error("server error", "err", err)
			os.Exit(1)
		}
	}()

	// Graceful shutdown
	stop := make(chan os.Signal, 1)
	signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
	<-stop
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	_ = httpSrv.Shutdown(ctx)
	log.Info("server stopped")
}

Reliability patterns

  • Timeouts and context: All external calls should accept context and use reasonable deadlines. The HTTP middleware above sets per-request timeouts.
  • Retries and backoff: Retry only idempotent operations. Use exponential backoff with jitter.
  • Circuit breaker: Prevent cascading failures. Libraries: gobreaker, resiliency.
  • Idempotency: For POST-like create operations, accept an Idempotency-Key and store it server-side to dedupe.
  • Outbox pattern: Persist events with the same transaction as state changes, then publish asynchronously to guarantee delivery.

Observability

  • Logs: Structured logs with request IDs and correlation IDs.
  • Traces: Propagate trace context via W3C traceparent or gRPC metadata. Use OpenTelemetry SDK to export to Jaeger/Tempo.
  • Metrics: Expose Prometheus metrics endpoint, track latency, error rates, and queue lag.

Security

  • mTLS between services; issue certificates via cert-manager.
  • Auth at the edge using JWT/OIDC; pass user claims to downstream services only as needed.
  • Principle of least privilege for DB credentials and message topics.
  • Validate input and enforce resource limits.

Docker and local development Dockerfile:

# Dockerfile
FROM golang:1.22-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/orders ./cmd/orders

FROM gcr.io/distroless/base-debian12
COPY --from=build /out/orders /orders
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/orders"]

docker-compose for local infra:

version: "3.9"
services:
  orders:
    build: .
    ports: ["8080:8080"]
    environment:
      - LOG_LEVEL=info
  nats:
    image: nats:2
    command: ["-js"]
    ports: ["4222:4222", "8222:8222"]

Kubernetes manifests Deployment and Service with readiness/liveness:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: orders
spec:
  replicas: 2
  selector:
    matchLabels: { app: orders }
  template:
    metadata:
      labels: { app: orders }
    spec:
      containers:
        - name: orders
          image: your-registry/orders:1.0.0
          ports: [{ containerPort: 8080 }]
          readinessProbe:
            httpGet: { path: /healthz, port: 8080 }
            initialDelaySeconds: 2
          livenessProbe:
            httpGet: { path: /healthz, port: 8080 }
            initialDelaySeconds: 5
          resources:
            requests: { cpu: "50m", memory: "64Mi" }
            limits: { cpu: "500m", memory: "256Mi" }
---
apiVersion: v1
kind: Service
metadata:
  name: orders
spec:
  selector: { app: orders }
  ports:
    - name: http
      port: 80
      targetPort: 8080

Testing strategy

  • Unit tests: Focus on domain and app layers with fake repositories.
  • Contract tests: For gRPC/HTTP, use golden test cases or Buf breaking-change checks for proto evolution.
  • Integration tests: Spin up service with docker-compose; test end-to-end with real DB or NATS.
  • Load tests: k6 or Vegeta to validate latency and throughput targets.

Evolving and versioning

  • Backward compatibility first: additive changes to JSON/proto; avoid field removals.
  • Version internal APIs when necessary (e.g., orders.v2).
  • Database migrations with a tool like golang-migrate; roll forward, avoid down migrations in production.

Common pitfalls

  • Sharing databases across services; prefer published events and APIs.
  • Chatty synchronous calls; prefer coarse-grained requests or async workflows.
  • Ignoring timeouts and retries; leads to thread exhaustion and cascades.
  • Over-normalizing the domain too early; keep services cohesive and small, but not tiny.

Next steps

  • Add persistence (PostgreSQL) and replace MemoryRepo.
  • Implement gRPC server and client with TLS and tracing.
  • Switch NopPublisher to NATS and add an outbox.
  • Add OpenTelemetry for traces and Prometheus for metrics.
  • Automate CI/CD with build, test, lint, container scan, and canary deploy.

With a clean boundary-driven design and a minimal but solid platform stack, Go lets you build microservices that are fast, observable, and easy to operate.