DEV Community

Cover image for Bounded Contexts in a Go Monorepo: How `internal/` Becomes the Boundary
Gabriel Anhaia
Gabriel Anhaia

Posted on

Bounded Contexts in a Go Monorepo: How `internal/` Becomes the Boundary


You moved four services into one Go monorepo. Orders, billing, inventory, shipping. The pitch was DDD: every service is a bounded context that owns its own types and rules. The team agreed on the boundaries in a whiteboard session that lasted a full afternoon.

Three weeks later, billing imports services/orders/domain to read an Order struct. A pull request goes in with the comment "easier than copying the type." Reviewers approve. The boundary is gone before the sticky notes from the whiteboard are dry.

This is the part DDD literature does not fix for you. Bounded contexts are an agreement between humans, and humans take shortcuts when the deadline is Friday. You need the compiler on your side.

Go has the mechanism built in. It is a single directory name: internal/. The compiler refuses imports that cross it. You can use it to make every bounded context in your monorepo a hard boundary that fails at go build, not at code review.

What internal/ actually does

The rule is in the Go spec for internal packages and it is short:

An import of a path containing the element "internal" is disallowed if the importing code is outside the tree rooted at the parent of the "internal" directory.

In practice: a package at services/orders/internal/domain can be imported from anywhere under services/orders/. Anything outside that subtree gets a compile error. The Go toolchain enforces it. No linter, no CI script, no Slack message to the junior who tried.

That is the entire mechanic. Now apply it to bounded contexts.

The monorepo layout

The shape that works:

acme/
├── go.mod
├── shared/
│   └── ids/
│       └── ids.go
├── services/
│   ├── orders/
│   │   ├── cmd/
│   │   │   └── server/main.go
│   │   └── internal/
│   │       ├── domain/
│   │       ├── port/
│   │       └── adapter/
│   ├── billing/
│   │   ├── cmd/
│   │   │   └── server/main.go
│   │   └── internal/
│   │       ├── domain/
│   │       ├── port/
│   │       └── adapter/
│   ├── inventory/
│   │   └── internal/...
│   └── shipping/
│       └── internal/...
Enter fullscreen mode Exit fullscreen mode

One module at the root:

// go.mod
module github.com/acme/platform

go 1.23
Enter fullscreen mode Exit fullscreen mode

Four bounded contexts under services/. Each one keeps its domain inside its own internal/. The cmd/server/main.go for that service is the only place that wires the context's adapters together.

shared/ sits outside any internal/ on purpose. We get to that part below.

The compile-time fence

Here is what billing wants to do:

// services/billing/internal/domain/invoice.go
package domain

import (
    "github.com/acme/platform/services/orders/internal/domain"
)

type Invoice struct {
    OrderID    string
    LineItems  []domain.OrderLine // wrong package
    TotalCents int64
}
Enter fullscreen mode Exit fullscreen mode

Run go build ./... and you get:

services/billing/internal/domain/invoice.go:4:5:
  use of internal package
  github.com/acme/platform/services/orders/internal/domain
  not allowed
Enter fullscreen mode Exit fullscreen mode

The build fails. No PR can merge it without bypassing CI. The compiler did the policing the team kept failing to do at review time.

Try it from the orders side too:

// services/orders/internal/domain/order.go
package domain

import (
    "github.com/acme/platform/services/billing/internal/domain"
)
Enter fullscreen mode Exit fullscreen mode

Same error in reverse. The fence is symmetric. Neither context can reach into the other's domain. They are forced to communicate through their own ports and adapters, the same way two separately deployed services would.

Each context owns its language

Once the fence is up, each context can name things the way the business names them inside that context, without worrying about a clash.

// services/orders/internal/domain/order.go
package domain

type Order struct {
    ID         string
    CustomerID string
    Lines      []OrderLine
    Status     Status
}

type OrderLine struct {
    SKU        string
    Quantity   int
    PriceCents int64
}

type Status string

const (
    StatusPending   Status = "pending"
    StatusConfirmed Status = "confirmed"
)
Enter fullscreen mode Exit fullscreen mode
// services/billing/internal/domain/invoice.go
package domain

type Invoice struct {
    ID         string
    CustomerID string
    Lines      []InvoiceLine
    Status     Status
}

type InvoiceLine struct {
    Description string
    AmountCents int64
    TaxCents    int64
}

type Status string

const (
    StatusDraft   Status = "draft"
    StatusIssued  Status = "issued"
    StatusPaid    Status = "paid"
    StatusVoided  Status = "voided"
)
Enter fullscreen mode Exit fullscreen mode

Both packages export Status. Both export a line type. The names overlap because the words mean the same thing inside each context — but the values, the transitions, and the rules are different. An order is confirmed. An invoice is issued. The two should never be the same enum.

In a single shared domain package this would be impossible. You would end up with OrderStatus, InvoiceStatus, OrderLine, InvoiceLine, all jammed together, all importing each other. With internal/ you get the language each context wants without the collision. This is what DDD calls bounded language, and the compiler now enforces it.

Talking across contexts: ports, not imports

When billing needs something from orders, it does not import the orders domain. It defines a port for what it needs, and an adapter that fills it from outside the internal/ fence.

// services/billing/internal/port/orders.go
package port

import "context"

type OrderSummary struct {
    OrderID    string
    CustomerID string
    TotalCents int64
}

type OrderLookup interface {
    LookupOrder(
        ctx context.Context, id string,
    ) (OrderSummary, error)
}
Enter fullscreen mode Exit fullscreen mode

The billing context defines OrderSummary with the three fields it actually uses. Not the 30 fields the orders domain has. Not the Status enum, the line items, the timestamps. Three fields.

The adapter sits in services/billing/internal/adapter/. In a monorepo where the two contexts are deployed as one binary it can call the orders service through a public, non-internal API exposed for that purpose:

services/orders/
├── api/                  # public, importable from anywhere
│   └── orders.go
└── internal/...          # private to the orders service
Enter fullscreen mode Exit fullscreen mode
// services/orders/api/orders.go
package api

import "context"

type OrderView struct {
    ID         string
    CustomerID string
    TotalCents int64
}

type OrderQuery interface {
    GetOrder(
        ctx context.Context, id string,
    ) (OrderView, error)
}
Enter fullscreen mode Exit fullscreen mode

Anything under services/orders/api/ is fair game for other contexts to import. It is the orders service's published interface. The adapter on the billing side wires port.OrderLookup to api.OrderQuery:

// services/billing/internal/adapter/orders.go
package adapter

import (
    "context"

    ordersapi "github.com/acme/platform/services/orders/api"
    "github.com/acme/platform/services/billing/internal/port"
)

type OrdersAdapter struct {
    q ordersapi.OrderQuery
}

func NewOrdersAdapter(
    q ordersapi.OrderQuery,
) *OrdersAdapter {
    return &OrdersAdapter{q: q}
}

func (a *OrdersAdapter) LookupOrder(
    ctx context.Context, id string,
) (port.OrderSummary, error) {
    v, err := a.q.GetOrder(ctx, id)
    if err != nil {
        return port.OrderSummary{}, err
    }
    return port.OrderSummary{
        OrderID:    v.ID,
        CustomerID: v.CustomerID,
        TotalCents: v.TotalCents,
    }, nil
}
Enter fullscreen mode Exit fullscreen mode

The adapter is the only file in billing that knows the orders service exists. The billing domain talks to port.OrderLookup. If orders moves to a separate repo tomorrow, the only thing that changes is what OrderQuery resolves to: a function call, an HTTP client, a gRPC stub. The rest of the billing code does not care.

Shared kernel: tiny, outside internal/, on purpose

There is one piece of code two contexts genuinely share: things that are not domain at all. UUID generation. A Money type. A correlation-ID helper. These go in shared/, not in any internal/ directory:

// shared/ids/ids.go
package ids

import "github.com/google/uuid"

func New() string {
    return uuid.NewString()
}
Enter fullscreen mode Exit fullscreen mode

Both services/orders/internal/domain and services/billing/internal/domain can import shared/ids because it is outside any internal/ fence. The compiler allows it.

The temptation is to grow shared/. Resist that. The DDD term is shared kernel, and the rule is: it must be small, stable, and not the place anyone reaches when they need something from another context. If you find yourself moving Order into shared/ to "let billing read it", you are putting the bug back. The whole point of the internal/ fence was to stop exactly that.

A useful test: would you let two unrelated services in two different repos depend on this code unchanged? If yes, it can live in shared/. If no, it belongs inside one context's internal/. (If it changes when the orders domain changes, it changes with that context.)

The anti-pattern: a single root domain package

The version of this monorepo that does not work:

acme/
├── domain/          # one package for all contexts
│   ├── order.go
│   ├── invoice.go
│   ├── product.go
│   └── status.go
└── services/
    ├── orders/...
    ├── billing/...
    └── ...
Enter fullscreen mode Exit fullscreen mode

Every service imports acme/domain. The Order type is shared. The Status enum has every state from every workflow. Two engineers add fields in the same file in the same week and someone has to merge it.

The compiler does not stop you, because there is no internal/ between the contexts. The architecture diagram still shows four boxes and four arrows. The dependency graph shows one giant blob in the middle that every box depends on.

This is the distributed-monolith bug, but at the source-code level instead of the deploy level. Every change to domain/order.go triggers a recompile and a redeploy of every service that imports it. You have a monorepo that ships like a monolith because you shaped your packages like one.

Move every domain type back behind its service's internal/. Let the compiler tell you which imports were illegitimate.

A CI check the compiler does not give you

The compiler catches the direct cases. A main.go in services/billing/cmd/ that imports services/orders/internal/... is outside the orders subtree, so the build fails. Same for any file in billing's adapter or domain that imports orders' internal/. Good.

The case it does not catch is indirect. The orders binary imports its own adapter. That adapter imports a helper from shared/. The helper, recently grown, imports a billing port type "for convenience". The orders binary now transitively depends on services/billing/internal/... through that chain. Every package in the chain individually obeys the internal/ rule. The compiler is happy. The architecture is not.

A single go vet rule covers it:

#!/usr/bin/env bash
set -euo pipefail

for svc in services/*/; do
  name=$(basename "$svc")
  bad=$(go list -deps "./${svc}cmd/..." \
    | grep -E "services/[^/]+/internal" \
    | grep -v "services/${name}/internal" \
    || true)
  if [ -n "$bad" ]; then
    echo "FAIL: $name binary depends on another context"
    echo "$bad"
    exit 1
  fi
done
Enter fullscreen mode Exit fullscreen mode

For each service binary, list its full transitive imports, and assert none of them point at another service's internal/. Run it in CI. Combined with the compiler's internal/ rule, you have a two-layer fence: the compiler stops direct imports, the CI script stops indirect ones.

What you get

Each context owns its language. Status, Line, Customer mean what they mean inside the context they live in, and the compiler keeps them from colliding. Cross-context calls go through ports, not type imports, which means you can split a context out into its own service with one git mv and a swapped adapter. Shared kernel stays small because the easy escape hatch — "just import the other domain" — is closed.

The boundary is not a team agreement anymore. It is a build error.


If this was useful

Bounded contexts, ports, and the import graph that holds them together are the spine of Hexagonal Architecture in Go. The book walks the same internal/ mechanic at scale: when to split a context out into its own module, how to evolve the shared kernel without growing it, and the patterns for crossing context boundaries with events, queries, and anti-corruption layers.

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

Top comments (0)