- Book: Hexagonal Architecture in Go
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
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
}
// 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
}
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
totaltototal_centsin 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
}
The domain service publishes after a successful save:
// order-service/domain/service.go
type OrderService struct {
repo OrderRepository
publisher EventPublisher
}
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
}
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,
)
}
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
}
// 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
}
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"`
}
// inventory-service/adapters/orderapi/client.go
package orderapi
type Client struct {
baseURL string
httpClient *http.Client
}
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
}
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)
}
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)
}
// 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
// ...
}
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
}
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
}
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)
}
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 │
└─────────────┘ └─────────────┘
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:
- Shared database: Do two services have connection strings to the same database? If yes, one of them is reading tables it does not own.
-
Shared Go module with domain types: Does a
shared,common, ormodelspackage contain structs used by multiple services? If yes, you have compile-time coupling. -
API types in domain packages: Does any domain-layer file import a package from
adapters/,client/, orapi/? If yes, an external type has leaked past the boundary. - 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.

Top comments (1)
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
contractspackage for DTOs and Zod schemas, but had to enforce by lint rule that the contracts package can't import from any service.