ADR-0004: Shared kernel + Anti-Corruption Layer for inter-module user lookup

Status

Accepted

Tags

architecture, modules, decoupling, ddd, bounded-context, microservice, acl

Decision

Modules must not import each other’s domain packages. Cross-module data access goes through a shared kernel interface in internal/shared/application/port/. The implementing module provides an adapter; wiring happens only in main.go.

Why

Direct module-to-module imports create tight coupling: a breaking change in the user module ripples into every module that imports it, circular dependency risk grows, and independent deployment becomes impossible. The Shared Kernel + ACL pattern breaks this: modules depend on a lightweight interface (no implementation), the owning module provides one adapter, and main.go is the only place that knows which adapter is wired.

Structure

internal/shared/application/port/user_lookup.go   ← shared kernel (interface + DTO only)
internal/modules/user/adapter/outbound/external/user_lookup_adapter.go  ← ACL adapter
internal/app/container.go                          ← holds sharedPort.UserLookupService
cmd/server/main.go                                 ← wires adapter into container
The leave module (and any other consumer) imports only shared/application/port — never the user module.

Trade-offs

True loose couplingNo direct module dependencies
Microservice-readySwap adapter from PostgreSQL → gRPC without touching business logic
Easy to testMock the shared interface; no module dep in test
One more indirectionSlightly more files per cross-module dependency

Rules for agents

  • Never import another module’s domain/ or application/ package from inside a different module
  • Cross-module data access: define an interface in internal/shared/application/port/, add an adapter in the owning module, wire in main.go
  • The shared kernel contains interfaces and DTOs only — no implementation, no framework imports
  • Only main.go imports module-specific adapters; all other files depend on the shared interface
  • To swap to microservice mode, replace the adapter in main.go with an HTTP/gRPC client that implements the same interface — no other files change

Bad pattern (do not generate)

// leave module directly importing user module's domain
import userPorts "github.com/waynecheah/go-core/internal/modules/user/application/port"

type Container struct {
    UserService userPorts.UserService // tight coupling
}

Good pattern

// shared kernel — interface only, no module dependency
// internal/shared/application/port/user_lookup.go
type UserLookupService interface {
    GetUserInfo(ctx context.Context, userID string) (*UserInfo, error)
}

// container depends on shared interface
type Container struct {
    UserLookup sharedPort.UserLookupService
}

// main.go: only place that knows about the adapter
container.UserLookup = userAdapters.NewUserLookupAdapter(userModule.Service)