DEV Community

Cover image for The Coupling Problem Hiding in Every Go Microservice Codebase
Gabriel Anhaia
Gabriel Anhaia

Posted on

The Coupling Problem Hiding in Every Go Microservice Codebase


You split the monolith into six services, each with its own repo and deploy pipeline. The team celebrates.

Three months later, a column rename in the orders table breaks the billing service. A deploy of the inventory service requires a simultaneous deploy of the shipping service. A junior developer changes a struct in a shared Go module and triggers a cascade of failures across four services that takes the on-call engineer until 2 AM to untangle.

You built microservices. You got a distributed monolith.

The architecture boundary was a lie. The services share a database, import each other's types, and pass domain objects across the wire. Every coupling path that existed in the monolith still exists. You just added network latency to it.

This is not rare. A colleague of mine called it "microservices theater" — the org chart says distributed, the dependency graph says monolith. In Go codebases specifically, three patterns show up over and over.

Symptom 1: The Shared Database

This is the most common one. Two services read from and write to the same PostgreSQL database. Maybe different tables. Maybe the same table.

// order-service/repo/orders.go
func (r *Repo) Save(
    ctx context.Context, o Order,
) error {
    _, err := r.db.ExecContext(ctx,
        `INSERT INTO orders 
         (id, customer_id, total, status)
         VALUES ($1, $2, $3, $4)`,
        o.ID, o.CustomerID, o.Total, o.Status,
    )
    return err
}
Enter fullscreen mode Exit fullscreen mode
// billing-service/repo/billing.go
func (r *Repo) GetOrderTotal(
    ctx context.Context, orderID string,
) (int64, error) {
    var total int64
    err := r.db.QueryRowContext(ctx,
        `SELECT total FROM orders 
         WHERE id = $1`, orderID,
    ).Scan(&total)
    return total, err
}
Enter fullscreen mode Exit fullscreen mode

Two services. Same orders table. The billing service reaches directly into the order service's data to read a column. No API call. No contract. Just raw SQL against a table it does not own.

The problems compound:

  • Rename total to total_cents in the orders service and billing breaks at runtime, not compile time.
  • Add a column-level constraint and billing's reads suddenly fail with unexpected errors.
  • Try to move the orders service to a different database and you discover billing is welded to it.

The coupling is invisible until it breaks. There is no import statement. There is no function signature. A string in a SQL query that matches a column name in a table another team owns.

The fix: an event port

The order service owns its data exclusively. When something happens that other services care about, it publishes an event. The billing service subscribes and maintains its own read model.

First, define the event port in the order service's domain:

// order-service/domain/ports.go
type OrderEvent struct {
    OrderID    string
    CustomerID string
    TotalCents int64
    Status     string
    OccurredAt time.Time
}

type EventPublisher interface {
    PublishOrderCreated(
        ctx context.Context, evt OrderEvent,
    ) error
}
Enter fullscreen mode Exit fullscreen mode

The domain service publishes after a successful save:

// order-service/domain/service.go
type OrderService struct {
    repo      OrderRepository
    publisher EventPublisher
}
Enter fullscreen mode Exit fullscreen mode

The domain service holds a repo and a publisher. After a successful save, it fires an event:

func (s *OrderService) PlaceOrder(
    ctx context.Context, req PlaceOrderReq,
) (Order, error) {
    order := Order{
        ID:         NewID(),
        CustomerID: req.CustomerID,
        TotalCents: req.TotalCents,
        Status:     "pending",
    }

    if err := s.repo.Save(ctx, order); err != nil {
        return Order{}, fmt.Errorf(
            "saving order: %w", err,
        )
    }

    evt := OrderEvent{
        OrderID:    order.ID,
        CustomerID: order.CustomerID,
        TotalCents: order.TotalCents,
        Status:     order.Status,
        OccurredAt: time.Now(),
    }
    if err := s.publisher.PublishOrderCreated(
        ctx, evt,
    ); err != nil {
        return Order{}, fmt.Errorf(
            "publishing event: %w", err,
        )
    }

    return order, nil
}
Enter fullscreen mode Exit fullscreen mode

The billing service consumes the event and stores what it needs in its own database:

// billing-service/consumer/orders.go
func (c *OrderConsumer) HandleOrderCreated(
    ctx context.Context, evt OrderCreatedMsg,
) error {
    return c.billingRepo.RecordOrderTotal(
        ctx, evt.OrderID, evt.TotalCents,
    )
}
Enter fullscreen mode Exit fullscreen mode

The billing service has its own billing_order_totals table. It never touches the orders table. The order service can rename columns, change schemas, even swap databases. The contract between the two services is the event shape, and that is versioned and explicit.

Symptom 2: The Direct Service Import

Go makes it easy to share code. Too easy, sometimes. You create a shared or common module. Both services import it. The shared module starts small — a few utility functions — and grows until it contains domain types.

// shared/models/order.go
package models

type Order struct {
    ID         string
    CustomerID string
    Items      []LineItem
    Total      int64
    Status     string
    CreatedAt  time.Time
    // 47 more fields added over 18 months
}
Enter fullscreen mode Exit fullscreen mode
// inventory-service/handler/reserve.go
func (h *Handler) Reserve(
    w http.ResponseWriter, r *http.Request,
) {
    var order models.Order
    json.NewDecoder(r.Body).Decode(&order)
    // Uses order.Items to reserve inventory
}
Enter fullscreen mode Exit fullscreen mode

The inventory service only cares about Items. But it imports the entire Order type. Every field addition, every tag change, every validation rule added to Order now requires the inventory service to update its dependency, recompile, and redeploy.

You have recreated compile-time coupling across service boundaries. The shared module is the monolith's ghost.

The fix: an API adapter with its own types

Each service defines the types it needs. The inventory service does not import the order service's domain. It defines a slim contract for what it receives over the wire.

// inventory-service/adapters/orderapi/types.go
package orderapi

type ReservationRequest struct {
    OrderID string     `json:"order_id"`
    Items   []LineItem `json:"items"`
}

type LineItem struct {
    ProductID string `json:"product_id"`
    Quantity  int    `json:"quantity"`
}
Enter fullscreen mode Exit fullscreen mode
// inventory-service/adapters/orderapi/client.go
package orderapi

type Client struct {
    baseURL    string
    httpClient *http.Client
}
Enter fullscreen mode Exit fullscreen mode

The FetchItemsForOrder method calls the order service's items endpoint and decodes only the fields the inventory service needs:

func (c *Client) FetchItemsForOrder(
    ctx context.Context, orderID string,
) ([]LineItem, error) {
    url := fmt.Sprintf(
        "%s/orders/%s/items", c.baseURL, orderID,
    )
    req, err := http.NewRequestWithContext(
        ctx, http.MethodGet, url, nil,
    )
    if err != nil {
        return nil, err
    }

    resp, err := c.httpClient.Do(req)
    if err != nil {
        return nil, fmt.Errorf(
            "calling order service: %w", err,
        )
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        return nil, fmt.Errorf(
            "order service returned %d",
            resp.StatusCode,
        )
    }

    var items []LineItem
    if err := json.NewDecoder(
        resp.Body,
    ).Decode(&items); err != nil {
        return nil, err
    }
    return items, nil
}
Enter fullscreen mode Exit fullscreen mode

The inventory service knows nothing about the Order struct. It knows there is an endpoint that returns line items. It has its own LineItem type with exactly the fields it needs. If the order service adds 30 new fields to its response, the inventory service ignores them. json.Decoder silently drops unknown fields by default.

The domain port stays clean:

// inventory-service/domain/ports.go
type OrderItemsFetcher interface {
    FetchItemsForOrder(
        ctx context.Context, orderID string,
    ) ([]LineItem, error)
}
Enter fullscreen mode Exit fullscreen mode

The adapter implements this port. The domain never sees http.Client, json, or the order service's URL. Swap the HTTP adapter for a gRPC adapter or a message-queue adapter and the domain code does not change.

Symptom 3: Leaked Domain Types Across Boundaries

This one is subtler. You avoided the shared module. Your services communicate over HTTP or gRPC. But the response shape from service A is used directly as a domain type inside service B.

// shipping-service/handler/ship.go
func (h *Handler) CreateShipment(
    ctx context.Context, orderID string,
) error {
    // Call order service API
    resp, err := h.orderClient.GetOrder(
        ctx, orderID,
    )
    if err != nil {
        return err
    }

    // Pass the API response directly to domain
    return h.shipService.Ship(ctx, resp)
}
Enter fullscreen mode Exit fullscreen mode
// shipping-service/domain/service.go
func (s *ShipService) Ship(
    ctx context.Context,
    order orderapi.OrderResponse,
) error {
    // Domain logic uses an external API type
    addr := order.ShippingAddress
    // ...
}
Enter fullscreen mode Exit fullscreen mode

The domain imports orderapi.OrderResponse. An API type from an external service has leaked into the core business logic. The shipping domain now changes when the order API changes — even if the shipping logic does not need the changed fields.

The fix: an anti-corruption layer

The adapter translates the external type into a domain type. The domain never sees the API shape.

Define what the domain actually needs:

// shipping-service/domain/types.go
type ShipmentRequest struct {
    OrderID string
    Address Address
    Weight  WeightGrams
}

type Address struct {
    Street string
    City   string
    Zip    string
    Country string
}
Enter fullscreen mode Exit fullscreen mode

The adapter translates:

// shipping-service/adapters/orderapi/adapter.go
func (a *Adapter) GetShipmentRequest(
    ctx context.Context, orderID string,
) (domain.ShipmentRequest, error) {
    resp, err := a.client.GetOrder(ctx, orderID)
    if err != nil {
        return domain.ShipmentRequest{}, err
    }

    return domain.ShipmentRequest{
        OrderID: resp.ID,
        Address: domain.Address{
            Street:  resp.ShippingAddress.Line1,
            City:    resp.ShippingAddress.City,
            Zip:     resp.ShippingAddress.PostalCode,
            Country: resp.ShippingAddress.CountryCode,
        },
        Weight: domain.WeightGrams(resp.TotalWeightG),
    }, nil
}
Enter fullscreen mode Exit fullscreen mode

The order API calls it Line1; the shipping domain calls it Street. The order API calls it PostalCode; the domain calls it Zip. These are different bounded contexts with different language. The anti-corruption layer translates between them.

The domain service now takes its own types:

// shipping-service/domain/service.go
func (s *ShipService) Ship(
    ctx context.Context, req ShipmentRequest,
) error {
    if req.Address.Country == "" {
        return ErrMissingCountry
    }
    // Business logic using domain types only
    return s.carrier.SchedulePickup(ctx, req)
}
Enter fullscreen mode Exit fullscreen mode

No external imports. The domain owns its types. If the order service renames PostalCode to ZipCode, only the adapter changes. The domain, the tests, the business rules — untouched.

The Pattern Behind All Three Fixes

Every fix follows the same principle: the port belongs to the consumer, not the provider.

The order service does not define an interface that billing must implement. Billing defines what it needs (an event with a total). The inventory service does not import the order service's types. It defines a LineItem struct with two fields. The shipping domain does not accept external response types. It defines ShipmentRequest with the fields it cares about.

This is hexagonal architecture applied at the system level. Each service is a hexagon. The ports face outward. The adapters translate. The domain stays clean.

┌─────────────┐    event    ┌─────────────┐
│   Orders    │───────────→ │   Billing   │
│  (domain)   │             │  (domain)   │
│             │             │             │
│  pub port ──┤             ├── sub port  │
└─────────────┘             └─────────────┘

┌─────────────┐    HTTP     ┌─────────────┐
│   Orders    │ ←───────────│  Inventory  │
│  (domain)   │             │  (domain)   │
│             │             │             │
│  API ───────┤             ├── adapter   │
└─────────────┘             └─────────────┘
Enter fullscreen mode Exit fullscreen mode

The arrows represent dependency direction, not data flow. Billing depends on the event shape, not on the orders database. Inventory depends on an HTTP contract, not on the orders domain module.

How to Spot It in Your Codebase

Run these checks against your services:

  1. Shared database: Do two services have connection strings to the same database? If yes, one of them is reading tables it does not own.
  2. Shared Go module with domain types: Does a shared, common, or models package contain structs used by multiple services? If yes, you have compile-time coupling.
  3. API types in domain packages: Does any domain-layer file import a package from adapters/, client/, or api/? If yes, an external type has leaked past the boundary.
  4. Deploy coupling: Can you deploy one service without deploying another? If not, trace the dependency — one of these three bugs is hiding underneath.

Each of these checks takes five minutes. The fixes take longer, but you can apply them incrementally. Pick the coupling that breaks most often and fix that one first.


If this was useful

This post covers the system-level version of hexagonal architecture — ports and adapters across service boundaries, not just within one binary. The book goes deeper on every piece: port design, adapter patterns, anti-corruption layers, event-driven adapters, the Unit of Work pattern for cross-repository atomicity. It also covers when to skip all of it because your service is small enough that a flat main.go is the right call.

If your Go microservices feel harder to change than the monolith they replaced, that is the book this problem led me to write.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (1)

Collapse
 
mickyarun profile image
arun rajkumar

All three patterns are alive and well in our NestJS stack — the "shared database" one bit us hardest. We started with one Postgres for everything and called the service boundaries "logical." Then a schema migration in the merchant service silently changed a column the payments service was reading directly. Took us a week to root-cause because the payments service didn't even know it was reading from the merchants table. The fix wasn't really technical — it was a hard rule: no service reads another service's tables, full stop. Cross-service data goes through events or a published API contract. The pain of writing the migration to enforce that was real, but it forced ownership clarity that no architecture diagram ever did. Direct-import coupling is sneakier — we use a shared contracts package for DTOs and Zod schemas, but had to enforce by lint rule that the contracts package can't import from any service.