Building a Microservice Architecture with Go
#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.