02/23/26

How to Build Microservices with Go

A practical guide to building distributed systems with type-safe service communication

13 Min Read

Microservices promise independent deployment, team autonomy, and better scalability. They also introduce network calls, distributed transactions, and operational complexity that can slow teams down if the infrastructure isn't handled well. This guide walks through building microservices in Go while keeping things practical and avoiding common pitfalls.

When to Use Microservices

Before diving in, be honest about whether you need microservices:

Start with a monolith if:

  • You're a small team (< 5 developers)
  • You're still figuring out domain boundaries
  • You want to ship quickly without infrastructure overhead

Consider microservices if:

  • Different parts of your system have different scaling needs
  • Teams need to deploy independently
  • You want fault isolation between components
  • Your domain has clear bounded contexts

The good news: with the right framework, you can start simple and split services later without rewriting everything.

Project Setup

We'll use Encore.go because it handles the infrastructure complexity of microservices automatically. Service discovery, networking, databases per service, and distributed tracing all work out of the box. The patterns in this guide apply to other approaches too, but you'd need to configure those pieces yourself.

# Install Encore CLI
brew install encoredev/tap/encore

# Create a new app
encore app create shop-app --example=go/empty
cd shop-app

# Start development
encore run

Your local development dashboard at localhost:9400 shows all services, their APIs, and how they connect. This becomes invaluable as your system grows.

Defining Services

In Encore.go, a service is simply a Go package. There's no special service declaration file needed. The package name becomes the service name, and any function annotated with //encore:api becomes an API endpoint. Let's build an e-commerce backend with three services:

/shop-app
├── encore.app
├── users/
│   ├── users.go
│   ├── db.go
│   └── migrations/
│       └── 1_create_users.up.sql
├── products/
│   ├── products.go
│   ├── db.go
│   └── migrations/
│       └── 1_create_products.up.sql
└── orders/
    ├── orders.go
    ├── db.go
    └── migrations/
        └── 1_create_orders.up.sql

Each service owns its domain and database. This isolation means teams can work independently, and a bug in one service can't corrupt another's data.

Users Service

Create the service directory and files. In Go with Encore, the package name is the service name, so package users declares the "users" service.

mkdir -p users/migrations

Create users/db.go. Each service gets its own database, provisioned automatically. This is the "database per service" pattern that gives you isolation and independent scaling.

package users

import "encore.dev/storage/sqldb"

var db = sqldb.NewDatabase("users", sqldb.DatabaseConfig{
    Migrations: "./migrations",
})

Create users/migrations/1_create_users.up.sql. Migrations run automatically when you start the app or deploy. Encore tracks which migrations have been applied.

CREATE TABLE users (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  email TEXT UNIQUE NOT NULL,
  name TEXT NOT NULL,
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

Create users/users.go with the service's API endpoints. The //encore:api public directive means the endpoint is accessible to external clients. The method and path tags control routing.

package users

import (
    "context"

    "encore.dev/beta/errs"
)

type User struct {
    ID    string `json:"id"`
    Email string `json:"email"`
    Name  string `json:"name"`
}

type CreateUserParams struct {
    Email string `json:"email"`
    Name  string `json:"name"`
}

//encore:api public method=POST path=/users
func Create(ctx context.Context, p *CreateUserParams) (*User, error) {
    user := &User{}
    err := db.QueryRow(ctx, `
        INSERT INTO users (email, name)
        VALUES ($1, $2)
        RETURNING id, email, name
    `, p.Email, p.Name).Scan(&user.ID, &user.Email, &user.Name)
    if err != nil {
        return nil, err
    }
    return user, nil
}

//encore:api public method=GET path=/users/:id
func Get(ctx context.Context, id string) (*User, error) {
    user := &User{}
    err := db.QueryRow(ctx, `
        SELECT id, email, name FROM users WHERE id = $1
    `, id).Scan(&user.ID, &user.Email, &user.Name)
    if err != nil {
        return nil, &errs.Error{Code: errs.NotFound, Message: "user not found"}
    }
    return user, nil
}

Products Service

Follow the same pattern for products. Each service is self-contained with its own database and endpoints.

mkdir -p products/migrations

Create products/db.go:

package products

import "encore.dev/storage/sqldb"

var db = sqldb.NewDatabase("products", sqldb.DatabaseConfig{
    Migrations: "./migrations",
})

Create products/migrations/1_create_products.up.sql:

CREATE TABLE products (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name TEXT NOT NULL,
  price_cents INTEGER NOT NULL,
  inventory INTEGER NOT NULL DEFAULT 0
);

Create products/products.go. Notice the ReserveInventory endpoint doesn't have public in its //encore:api directive, making it an internal API only callable by other services, not external clients.

package products

import (
    "context"

    "encore.dev/beta/errs"
)

type Product struct {
    ID         string `json:"id"`
    Name       string `json:"name"`
    PriceCents int    `json:"priceCents"`
    Inventory  int    `json:"inventory"`
}

type CreateProductParams struct {
    Name       string `json:"name"`
    PriceCents int    `json:"priceCents"`
    Inventory  int    `json:"inventory"`
}

//encore:api public method=POST path=/products
func Create(ctx context.Context, p *CreateProductParams) (*Product, error) {
    product := &Product{}
    err := db.QueryRow(ctx, `
        INSERT INTO products (name, price_cents, inventory)
        VALUES ($1, $2, $3)
        RETURNING id, name, price_cents, inventory
    `, p.Name, p.PriceCents, p.Inventory).Scan(
        &product.ID, &product.Name, &product.PriceCents, &product.Inventory,
    )
    if err != nil {
        return nil, err
    }
    return product, nil
}

//encore:api public method=GET path=/products/:id
func Get(ctx context.Context, id string) (*Product, error) {
    product := &Product{}
    err := db.QueryRow(ctx, `
        SELECT id, name, price_cents, inventory
        FROM products WHERE id = $1
    `, id).Scan(
        &product.ID, &product.Name, &product.PriceCents, &product.Inventory,
    )
    if err != nil {
        return nil, &errs.Error{Code: errs.NotFound, Message: "product not found"}
    }
    return product, nil
}

type ReserveParams struct {
    Quantity int `json:"quantity"`
}

// Internal API — only callable by other services, not external clients
//
//encore:api method=POST path=/products/:id/reserve
func ReserveInventory(ctx context.Context, id string, p *ReserveParams) error {
    result, err := db.Exec(ctx, `
        UPDATE products
        SET inventory = inventory - $1
        WHERE id = $2 AND inventory >= $1
    `, p.Quantity, id)
    if err != nil {
        return err
    }
    if result.RowsAffected() == 0 {
        return &errs.Error{Code: errs.FailedPrecondition, Message: "insufficient inventory"}
    }
    return nil
}

Orders Service

This is where services start talking to each other. The orders service needs to verify users exist and reserve product inventory before creating an order.

mkdir -p orders/migrations

Create orders/db.go:

package orders

import "encore.dev/storage/sqldb"

var db = sqldb.NewDatabase("orders", sqldb.DatabaseConfig{
    Migrations: "./migrations",
})

Create orders/migrations/1_create_orders.up.sql:

CREATE TABLE orders (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL,
  product_id UUID NOT NULL,
  quantity INTEGER NOT NULL,
  total_cents INTEGER NOT NULL,
  status TEXT NOT NULL DEFAULT 'pending',
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

Create orders/orders.go. The key part here is the imports: you call other services by importing them directly via encore.app/servicename. Encore turns those function calls into service-to-service RPCs at runtime, with full type safety at compile time.

package orders

import (
    "context"

    "encore.app/products"
    "encore.app/users"
    "encore.dev/beta/errs"
)

type Order struct {
    ID        string `json:"id"`
    UserID    string `json:"userId"`
    ProductID string `json:"productId"`
    Quantity  int    `json:"quantity"`
    TotalCents int   `json:"totalCents"`
    Status    string `json:"status"`
}

type CreateOrderParams struct {
    UserID    string `json:"userId"`
    ProductID string `json:"productId"`
    Quantity  int    `json:"quantity"`
}

//encore:api public method=POST path=/orders
func Create(ctx context.Context, p *CreateOrderParams) (*Order, error) {
    // Verify user exists (returns error if not found)
    _, err := users.Get(ctx, p.UserID)
    if err != nil {
        return nil, &errs.Error{Code: errs.InvalidArgument, Message: "user does not exist"}
    }

    // Get product details and verify it exists
    product, err := products.Get(ctx, p.ProductID)
    if err != nil {
        return nil, &errs.Error{Code: errs.InvalidArgument, Message: "product does not exist"}
    }

    // Reserve inventory (returns error if insufficient)
    err = products.ReserveInventory(ctx, p.ProductID, &products.ReserveParams{
        Quantity: p.Quantity,
    })
    if err != nil {
        return nil, err
    }

    // Calculate total and create order
    totalCents := product.PriceCents * p.Quantity

    order := &Order{}
    err = db.QueryRow(ctx, `
        INSERT INTO orders (user_id, product_id, quantity, total_cents, status)
        VALUES ($1, $2, $3, $4, 'confirmed')
        RETURNING id, user_id, product_id, quantity, total_cents, status
    `, p.UserID, p.ProductID, p.Quantity, totalCents).Scan(
        &order.ID, &order.UserID, &order.ProductID,
        &order.Quantity, &order.TotalCents, &order.Status,
    )
    if err != nil {
        return nil, err
    }

    return order, nil
}

//encore:api public method=GET path=/orders/:id
func Get(ctx context.Context, id string) (*Order, error) {
    order := &Order{}
    err := db.QueryRow(ctx, `
        SELECT id, user_id, product_id, quantity, total_cents, status
        FROM orders WHERE id = $1
    `, id).Scan(
        &order.ID, &order.UserID, &order.ProductID,
        &order.Quantity, &order.TotalCents, &order.Status,
    )
    if err != nil {
        return nil, &errs.Error{Code: errs.NotFound, Message: "order not found"}
    }
    return order, nil
}

Notice how clean service-to-service calls are:

import (
    "encore.app/users"
    "encore.app/products"
)

// Call another service — it's just a function call
user, err := users.Get(ctx, p.UserID)
product, err := products.Get(ctx, p.ProductID)

You import the other service's package and call its exported functions. Encore handles the networking, serialization, and service discovery. You get compile-time type checking that you're calling APIs correctly, and at runtime the calls become proper service-to-service RPCs with tracing, retries, and error propagation.

Adding Pub/Sub for Async Communication

Synchronous calls work for operations that need immediate results, but some tasks should be async. You don't want order creation to block while sending a confirmation email. Let's add Pub/Sub for event-driven communication.

Add the topic definition to orders/events.go. Topics are declared as package-level variables and can be published to from the service that owns them. The type parameter ensures all published events have the correct shape.

package orders

import "encore.dev/pubsub"

type OrderCreatedEvent struct {
    OrderID string `json:"orderId"`
    UserID  string `json:"userId"`
    Total   int    `json:"total"`
}

var OrderTopic = pubsub.NewTopic[*OrderCreatedEvent]("order-created", pubsub.TopicConfig{
    DeliveryGuarantee: pubsub.AtLeastOnce,
})

Update orders/orders.go to publish the event after creating an order. Add the publish call at the end of the Create function, after the order is persisted.

//encore:api public method=POST path=/orders
func Create(ctx context.Context, p *CreateOrderParams) (*Order, error) {
    // ... existing order creation logic ...

    // Publish event for async processing
    _, err = OrderTopic.Publish(ctx, &OrderCreatedEvent{
        OrderID: order.ID,
        UserID:  p.UserID,
        Total:   totalCents,
    })
    if err != nil {
        return nil, err
    }

    return order, nil
}

Create a notifications service to handle emails. Subscriptions process events asynchronously, with automatic retries on failure.

mkdir notifications

Create notifications/notifications.go. The subscription handler receives events and processes them independently of the original request. The var _ = pattern is idiomatic Go for registering the subscription at init time.

package notifications

import (
    "context"
    "fmt"

    "encore.app/orders"
    "encore.app/users"
    "encore.dev/pubsub"
)

var _ = pubsub.NewSubscription(orders.OrderTopic, "send-order-confirmation",
    pubsub.SubscriptionConfig[*orders.OrderCreatedEvent]{
        Handler: HandleOrderCreated,
    },
)

func HandleOrderCreated(ctx context.Context, event *orders.OrderCreatedEvent) error {
    // Get user details for the email
    user, err := users.Get(ctx, event.UserID)
    if err != nil {
        return fmt.Errorf("failed to get user: %w", err)
    }

    // Send confirmation email
    err = sendEmail(
        user.Email,
        "Order Confirmed",
        fmt.Sprintf("Your order #%s for $%.2f has been confirmed.",
            event.OrderID, float64(event.Total)/100),
    )
    if err != nil {
        return fmt.Errorf("failed to send email: %w", err)
    }

    return nil
}

func sendEmail(to, subject, body string) error {
    // Integrate with SendGrid, Resend, etc.
    fmt.Printf("Email to %s: %s\n", to, subject)
    return nil
}

Now order creation is fast (synchronous database calls and service calls), and email sending happens in the background. If email sending fails, Pub/Sub retries automatically.

Service Architecture Visualization

After building these services, your architecture looks like this:

┌─────────┐     ┌──────────┐     ┌─────────────────┐
│  Users  │◄────│  Orders  │────►│    Products     │
│ Service │     │ Service  │     │    Service      │
└────┬────┘     └────┬─────┘     └────────┬────────┘
     │               │                    │
     ▼               ▼                    ▼
┌─────────┐     ┌──────────┐     ┌─────────────────┐
│ Users   │     │  Orders  │     │    Products     │
│   DB    │     │    DB    │     │       DB        │
└─────────┘     └──────────┘     └─────────────────┘
                     │
                     ▼ (Pub/Sub)
              ┌──────────────┐
              │ Notifications│
              │   Service    │
              └──────────────┘

Encore generates this diagram automatically from your code. Check the local dashboard at localhost:9400 to see it, or view architecture diagrams in Encore Cloud after deploying.

Encore architecture diagram

Distributed Tracing

With multiple services, debugging requires tracing requests across service boundaries. Encore handles this automatically with zero configuration.

Every request gets a trace ID that follows it through all service calls. In the dashboard, click any request to see:

  • Which services were called
  • How long each call took
  • Database queries in each service
  • Pub/Sub message publishing
  • Any errors that occurred

Encore distributed trace

No manual instrumentation needed, no OpenTelemetry setup, no trace context propagation code. Learn more about distributed tracing.

Error Handling Across Services

When service B calls service A and A returns an error, B gets that error. The encore.dev/beta/errs package gives you structured error codes that propagate cleanly across service boundaries.

package orders

import (
    "context"

    "encore.app/users"
    "encore.dev/beta/errs"
)

//encore:api public method=POST path=/orders
func Create(ctx context.Context, p *CreateOrderParams) (*Order, error) {
    // If users.Get returns NotFound, we can catch it and
    // return a more meaningful error for our callers
    _, err := users.Get(ctx, p.UserID)
    if err != nil {
        // Check the error code from the upstream service
        if errs.Code(err) == errs.NotFound {
            return nil, &errs.Error{
                Code:    errs.InvalidArgument,
                Message: "user does not exist",
            }
        }
        return nil, err // Re-propagate unexpected errors
    }

    // ... rest of handler
}

The errs.Code() function extracts the error code from any error returned by an Encore service call, so you can inspect and transform errors from upstream services. Common error codes include errs.NotFound, errs.FailedPrecondition, errs.InvalidArgument, and errs.Internal.

Testing Microservices

Test services by calling their exported functions directly. Encore runs the full infrastructure during tests, so database operations work the same as in production.

package users

import (
    "context"
    "testing"
)

func TestCreateAndGetUser(t *testing.T) {
    ctx := context.Background()

    user, err := Create(ctx, &CreateUserParams{
        Email: "[email protected]",
        Name:  "Test User",
    })
    if err != nil {
        t.Fatal(err)
    }

    if user.Email != "[email protected]" {
        t.Errorf("expected email [email protected], got %s", user.Email)
    }

    fetched, err := Get(ctx, user.ID)
    if err != nil {
        t.Fatal(err)
    }

    if fetched.ID != user.ID {
        t.Errorf("expected id %s, got %s", user.ID, fetched.ID)
    }
}

For integration tests that span services, Encore runs all services together. You can test the full order flow including cross-service calls by importing and calling functions from other service packages.

package orders

import (
    "context"
    "testing"

    "encore.app/products"
    "encore.app/users"
)

func TestCreateOrderFlow(t *testing.T) {
    ctx := context.Background()

    // Set up test data across services
    user, err := users.Create(ctx, &users.CreateUserParams{
        Email: "[email protected]",
        Name:  "Buyer",
    })
    if err != nil {
        t.Fatal(err)
    }

    product, err := products.Create(ctx, &products.CreateProductParams{
        Name:       "Widget",
        PriceCents: 1000,
        Inventory:  10,
    })
    if err != nil {
        t.Fatal(err)
    }

    // Test the cross-service order creation
    order, err := Create(ctx, &CreateOrderParams{
        UserID:    user.ID,
        ProductID: product.ID,
        Quantity:  2,
    })
    if err != nil {
        t.Fatal(err)
    }

    if order.TotalCents != 2000 {
        t.Errorf("expected total 2000, got %d", order.TotalCents)
    }
    if order.Status != "confirmed" {
        t.Errorf("expected status confirmed, got %s", order.Status)
    }
}

Run tests with the Encore CLI:

encore test ./...

Deployment

Deploy all services together with a single command. Encore provisions separate databases for each service, sets up networking, and configures Pub/Sub infrastructure.

git add -A
git commit -m "E-commerce microservices"
git push encore

From the Encore Cloud dashboard you can connect your AWS or Google Cloud account to deploy there. You can decide if you want to deploy all services together, or as separate independently scalable processes.

Encore Cloud dashboard

Common Patterns

API Gateway

Expose a unified API while keeping services internal. Create a gateway package that orchestrates calls to internal services and presents a simplified external API.

package gateway

import (
    "context"

    "encore.app/orders"
    "encore.app/products"
    "encore.app/users"
)

type CheckoutRequest struct {
    UserID    string `json:"userId"`
    ProductID string `json:"productId"`
    Quantity  int    `json:"quantity"`
}

type CheckoutResponse struct {
    OrderID     string `json:"orderId"`
    UserName    string `json:"userName"`
    ProductName string `json:"productName"`
    TotalCents  int    `json:"totalCents"`
}

// Public endpoint that orchestrates internal services
//
//encore:api public method=POST path=/checkout
func Checkout(ctx context.Context, p *CheckoutRequest) (*CheckoutResponse, error) {
    user, err := users.Get(ctx, p.UserID)
    if err != nil {
        return nil, err
    }

    product, err := products.Get(ctx, p.ProductID)
    if err != nil {
        return nil, err
    }

    order, err := orders.Create(ctx, &orders.CreateOrderParams{
        UserID:    p.UserID,
        ProductID: p.ProductID,
        Quantity:  p.Quantity,
    })
    if err != nil {
        return nil, err
    }

    return &CheckoutResponse{
        OrderID:     order.ID,
        UserName:    user.Name,
        ProductName: product.Name,
        TotalCents:  order.TotalCents,
    }, nil
}

Saga Pattern

For operations that span multiple services and need rollback on failure. This ensures consistency even when individual steps can fail.

//encore:api public method=POST path=/checkout
func Checkout(ctx context.Context, p *CheckoutRequest) (*CheckoutResponse, error) {
    // Step 1: Reserve inventory
    err := products.ReserveInventory(ctx, p.ProductID, &products.ReserveParams{
        Quantity: p.Quantity,
    })
    if err != nil {
        return nil, err
    }

    // Step 2: Charge payment
    err = payments.Charge(ctx, &payments.ChargeParams{
        UserID:     p.UserID,
        AmountCents: totalCents,
    })
    if err != nil {
        // Rollback step 1 if payment fails
        products.ReleaseInventory(ctx, p.ProductID, &products.ReleaseParams{
            Quantity: p.Quantity,
        })
        return nil, err
    }

    // Step 3: Create order (only if payment succeeded)
    order, err := orders.Create(ctx, &orders.CreateOrderParams{
        UserID:    p.UserID,
        ProductID: p.ProductID,
        Quantity:  p.Quantity,
    })
    if err != nil {
        return nil, err
    }

    return &CheckoutResponse{OrderID: order.ID}, nil
}

When to Split Services

Start with fewer, larger services. Split when you have a clear reason:

  1. Team boundaries: Different teams own different services
  2. Deployment independence: One part changes frequently, another is stable
  3. Scaling needs: One service needs 10x the resources
  4. Fault isolation: A failure in one area shouldn't affect others

Don't split just because "microservices are better." Every service boundary adds operational complexity. Start simple, split when the pain of not splitting outweighs the cost. With Encore, splitting a service is straightforward since service-to-service calls already look like local function calls, so moving code into a new package is a small refactor rather than a rewrite.

Ready to build your next backend?

Encore is the Open Source framework for building robust type-safe distributed systems with declarative infrastructure.