ADR-0009: Anti-Corruption Layer per module for bounded context isolation

Status

Accepted

Tags

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

Decision

Each module defines its own local domain model for any external entity it needs (e.g., leave/domain/user.go — not imported from the user module). An Anti-Corruption Layer adapter in the outbound persistence layer converts the external/shared model into the module’s local representation. The module’s port interface is the only contract; the implementation can be swapped between PostgreSQL and HTTP without touching business logic.

Why

Direct imports of another module’s domain package create tight coupling: schema changes in the user module break the leave module, circular imports become possible, and independent deployment is blocked. Giving each module its own bounded view of external entities means:
  • The leave module defines what “User” means for leave purposes (ID, Name, ManagerID, DepartmentID) — independent of the user module’s full model
  • The ACL adapter handles translation; the module never sees the external schema
  • Swapping from a shared PostgreSQL query to a remote HTTP call requires only a new adapter implementation — zero business logic changes

Structure

leave/domain/user.go                                        ← leave's own User model
leave/application/port/leave_ports.go                       ← UserRepository interface
leave/adapter/outbound/persistence/postgresql/user_repo.go  ← ACL: PostgreSQL impl
leave/adapter/outbound/http/user_repo.go                    ← ACL: HTTP impl (microservice mode)
leave/module.go                                             ← wires correct impl via config flag

Rules for agents

  • Each module must have its own domain representation of any external entity it needs — do not import from another module’s domain/ package
  • The ACL conversion function (convertToLeaveUser, etc.) lives in the outbound adapter, not in the domain layer
  • Module wiring (NewModule) selects the adapter based on config (MicroserviceMode); business logic does not branch on this
  • The port interface must be defined in the module’s own application/port/ — not in shared packages
  • Adding a new module that needs user data: create {module}/domain/user.go + {module}/application/port/ interface + PostgreSQL adapter; do not reuse leave’s domain model

Bad pattern (do not generate)

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

func (s *ApprovalService) Approve(ctx context.Context, req userEntity.LeaveRequest) // wrong

Good pattern

// leave/domain/user.go — leave's own bounded view
type User struct {
    ID           string
    Name         string
    ManagerID    string
    DepartmentID string
}

// leave/adapter/outbound/persistence/postgresql/user_repository.go
func (r *UserRepository) convertToLeaveUser(m *sharedDB.User) *domain.User {
    return &domain.User{ID: m.ID, Name: m.Name, ManagerID: m.ManagerID}
}

// leave/module.go — swap implementation via config
if c.Config.MicroserviceMode {
    userRepo = httpRepo.NewUserRepository(c.Config.UserServiceURL)
} else {
    userRepo = pgRepo.NewUserRepository(c.DB)
}