In the world of modern software architecture, systems rarely exist in isolation. Enterprise applications constantly interact with legacy systems, third-party APIs, and external services — each carrying their own data models, domain language, and business rules. Without a deliberate separation strategy, these foreign concepts can corrupt your clean domain model, leading to tangled code, brittle integrations, and a maintenance nightmare.
The Anti-Corruption Layer (ACL) pattern is one of the most powerful tools in a software architect’s toolkit. It acts as a translation boundary between your system and the outside world, ensuring your domain remains pure, expressive, and easy to reason about.
In this in-depth guide, you will learn:
- What the Anti-Corruption Layer pattern is and why it matters
- How to implement it step by step in Golang
- How to containerize the application with Docker
Whether you are a backend engineer dealing with legacy integrations or an architect designing resilient microservices, this guide will give you everything you need to implement the ACL pattern confidently.
Table of Contents
- Introduction
- What Is the Anti-Corruption Layer Pattern?
- When Should You Use the ACL Pattern?
- Core Concepts and Architecture
- Project Overview and Prerequisites
- Step 1: Project Setup and Structure
- Step 2: Defining the Legacy System
- Step 3: Building the Domain Model
- Step 4: Implementing the Anti-Corruption Layer
- Step 5: Building the Application Service
- Step 6: Creating the REST API
- Step 7: Dockerizing and Testing the Application
- Best Practices
- Conclusion
What Is the Anti-Corruption Layer Pattern?
The Anti-Corruption Layer (ACL) was introduced by Eric Evans in his seminal book Domain-Driven Design: Tackling Complexity in the Heart of Software (2003). It is a strategic DDD pattern used to isolate your domain model from external systems whose models differ significantly from your own.
The Problem It Solves
Imagine your company has a modern e-commerce platform. It needs to integrate with a 20-year-old ERP system written in COBOL that tracks orders using cryptic numeric codes, flat data structures, and business terminology from a completely different era.
Without an ACL:
Your Modern Service ←→ Legacy ERP API
↕ ↕
Clean Domain Model Leaks into your codebase
↕
Becomes corrupted with ERP conceptsWith an ACL:
Your Modern Service ←→ Anti-Corruption Layer ←→ Legacy ERP API
↕ ↕ ↕
Clean Domain Model Translation Logic Legacy ModelThe ACL translates, adapts, and maps concepts from the external system into your domain’s language, preventing the external model from bleeding into your business logic.
Key Responsibilities of an ACL

When Should You Use the ACL Pattern?
The ACL pattern is the right choice when:
– Integrating with legacy systems that have outdated or inconsistent data models
– Consuming third-party APIs whose models don’t match your domain
– Migrating from a monolith and extracting bounded contexts
– Working across bounded context boundaries in a microservices architecture
– Preventing model pollution when external concepts are fundamentally different from yours
You should consider skipping the ACL when:
– The external system shares the exact same domain model (a conformist relationship is acceptable)
– The overhead of translation exceeds the benefit (simple, stable, low-risk integrations)
Core Concepts and Architecture
Before diving into code, let’s define the key building blocks of our ACL implementation.
Architecture Overview

Components Explained

Project Overview and Prerequisites
What We’re Building
We will build an Order Management Service that integrates with a simulated legacy ERP system. The legacy system uses:
- Numeric status codes instead of descriptive statuses
- Flat, denormalized data structures
- Cryptic field names (
cust_no,ord_dt,tot_amt) - A different concept of “products” (called “line items” with part numbers)
Our modern service will expose a clean, RESTful API with a rich domain model, completely insulated from the legacy system’s quirks.
Prerequisites
Make sure you have the following installed:
# Check Go installation (1.21+)
go version
# Check Docker installation
docker --version
# Check git installation
git --versionYou will also need:
- Basic familiarity with Go, Docker
Step 1: Project Setup and Structure
1.1 Initialize the Project
Let’s start by creating the project directory and initializing the Go module.
mkdir acl-pattern-demo
cd acl-pattern-demo
git init
go mod init github.com/yourusername/acl-pattern-demo1.2 Create the Project Structure
To make our work easier let’s create a standard folder structure for the project:
mkdir -p \
cmd/server \
internal/domain/order \
internal/domain/customer \
internal/domain/product \
internal/acl/translator \
internal/acl/adapter \
internal/acl/facade \
internal/application/service \
internal/infrastructure/legacy \
internal/api/handler \
internal/api/middleware \
deployments/docker \
pkg/logger \
pkg/errors \
configs1.3 Install Dependencies
go get github.com/gin-gonic/[email protected]
go get github.com/google/[email protected]
go get go.uber.org/[email protected]
go get github.com/stretchr/[email protected]1.4 Create the Configuration File
Create a file named config.yaml in this directory: configs/ .
This file will hold general configurations of our system:
server:
port: 8080
read_timeout: 30s
write_timeout: 30s
legacy_erp:
base_url: "http://legacy-erp-service:9090"
timeout: 10s
api_key: "legacy-secret-key"
logging:
level: "info"
format: "json"1.5 Create the Logger Package
We need to have structured and clean logs for our system, so let’s define our logger using zap in pkg/logger/logger.go:
package logger
import (
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
var globalLogger *zap.Logger
// Initialize sets up the global logger
func Initialize(level string) error {
var zapLevel zapcore.Level
if err := zapLevel.UnmarshalText([]byte(level)); err != nil {
zapLevel = zapcore.InfoLevel
}
config := zap.NewProductionConfig()
config.Level = zap.NewAtomicLevelAt(zapLevel)
config.EncoderConfig.TimeKey = "timestamp"
config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
var err error
globalLogger, err = config.Build()
if err != nil {
return err
}
return nil
}
// Get returns the global logger
func Get() *zap.Logger {
if globalLogger == nil {
globalLogger, _ = zap.NewProduction()
}
return globalLogger
}
// Info logs an info message
func Info(msg string, fields ...zap.Field) {
Get().Info(msg, fields...)
}
// Error logs an error message
func Error(msg string, fields ...zap.Field) {
Get().Error(msg, fields...)
}
// Debug logs a debug message
func Debug(msg string, fields ...zap.Field) {
Get().Debug(msg, fields...)
}
// Warn logs a warning message
func Warn(msg string, fields ...zap.Field) {
Get().Warn(msg, fields...)
}
1.6 Create the Custom Errors Package:
Let’s define clean and custom errors that will be used throughout the project, inside pkg/errors folder create a file named errors.go like this:
package errors
import "fmt"
// ErrorCode represents a domain-specific error code
type ErrorCode string
const (
ErrNotFound ErrorCode = "NOT_FOUND"
ErrInvalidInput ErrorCode = "INVALID_INPUT"
ErrExternalSystem ErrorCode = "EXTERNAL_SYSTEM_ERROR"
ErrTranslation ErrorCode = "TRANSLATION_ERROR"
ErrUnauthorized ErrorCode = "UNAUTHORIZED"
ErrInternalServer ErrorCode = "INTERNAL_SERVER_ERROR"
)
// DomainError represents a structured domain error
type DomainError struct {
Code ErrorCode
Message string
Cause error
}
func (e *DomainError) Error() string {
if e.Cause != nil {
return fmt.Sprintf("[%s] %s: %v", e.Code, e.Message, e.Cause)
}
return fmt.Sprintf("[%s] %s", e.Code, e.Message)
}
func (e *DomainError) Unwrap() error {
return e.Cause
}
// New creates a new DomainError
func New(code ErrorCode, message string) *DomainError {
return &DomainError{Code: code, Message: message}
}
// Wrap wraps an existing error with domain context
func Wrap(code ErrorCode, message string, cause error) *DomainError {
return &DomainError{Code: code, Message: message, Cause: cause}
}
// IsNotFound checks if the error is a not-found error
func IsNotFound(err error) bool {
if de, ok := err.(*DomainError); ok {
return de.Code == ErrNotFound
}
return false
}
// IsExternalSystemError checks if the error is from an external system
func IsExternalSystemError(err error) bool {
if de, ok := err.(*DomainError); ok {
return de.Code == ErrExternalSystem
}
return false
}1.7 Git Commit — Project Setup
git add .
git commit -m "feat: initialize project structure, dependencies, logger, and error packages
- Initialize Go module with project structure
- Add Gin, UUID, Zap, and Testify dependencies
- Create pkg/logger for structured logging with Zap
- Create pkg/errors for domain-specific error handling
- Add project configuration in configs/config.yaml
- Set up directory structure for domain, ACL, application, and infrastructure layers"Step 2: Defining the Legacy System
Before we can build our ACL, we need to simulate the legacy ERP system. This simulates exactly the kind of system you’d find in a real enterprise environment.
2.1 Create the Legacy ERP Data Models
These are the raw, ugly models the legacy system exposes: (internal/infrastructure/legacy/models.go)
package legacy
// LegacyOrder represents the raw order structure from the legacy ERP system.
// Field names are cryptic, types are inconsistent, and the model is denormalized.
type LegacyOrder struct {
OrdNo string `json:"ord_no"` // Order number (e.g., "ORD-00123")
OrdDt string `json:"ord_dt"` // Order date (format: "YYYYMMDD")
OrdSts int `json:"ord_sts"` // Status code: 1=New, 2=Processing, 3=Shipped, 4=Delivered, 9=Cancelled
CustNo string `json:"cust_no"` // Customer number
CustNm string `json:"cust_nm"` // Customer name
CustEml string `json:"cust_eml"` // Customer email
ShpAddr1 string `json:"shp_addr1"` // Shipping address line 1
ShpAddr2 string `json:"shp_addr2"` // Shipping address line 2
ShpCity string `json:"shp_city"` // Shipping city
ShpSt string `json:"shp_st"` // Shipping state
ShpZip string `json:"shp_zip"` // Shipping zip
ShpCntry string `json:"shp_cntry"` // Shipping country code (e.g., "US")
TotAmt float64 `json:"tot_amt"` // Total amount in cents (e.g., 10050 = $100.50)
TaxAmt float64 `json:"tax_amt"` // Tax amount in cents
ShpAmt float64 `json:"shp_amt"` // Shipping amount in cents
CurCd string `json:"cur_cd"` // Currency code (e.g., "USD")
LineItems []LegacyLineItem `json:"line_items"` // Order line items
CrtTs string `json:"crt_ts"` // Created timestamp (Unix epoch as string)
UpdTs string `json:"upd_ts"` // Updated timestamp (Unix epoch as string)
IntnlFlg int `json:"intnl_flg"` // International flag: 0=domestic, 1=international
PriFlg int `json:"pri_flg"` // Priority flag: 0=normal, 1=express, 2=overnight
}
// LegacyLineItem represents a line item in a legacy order
type LegacyLineItem struct {
LineNo int `json:"line_no"` // Line number
PrtNo string `json:"prt_no"` // Part number (their term for product SKU)
PrtDesc string `json:"prt_desc"` // Part description
Qty int `json:"qty"` // Quantity
UntPrc float64 `json:"unt_prc"` // Unit price in cents
LinAmt float64 `json:"lin_amt"` // Line amount in cents
WgtLbs float64 `json:"wgt_lbs"` // Weight in pounds
UPC string `json:"upc"` // Universal Product Code
}
// LegacyCustomer represents a customer in the legacy system
type LegacyCustomer struct {
CustNo string `json:"cust_no"`
CustNm string `json:"cust_nm"`
CustEml string `json:"cust_eml"`
CustPh string `json:"cust_ph"`
CustTypCd string `json:"cust_typ_cd"` // "I" = Individual, "B" = Business
ActvFlg int `json:"actv_flg"` // 0 = inactive, 1 = active
CrdtLmt float64 `json:"crdt_lmt"` // Credit limit in cents
RegDt string `json:"reg_dt"` // Registration date (YYYYMMDD)
}
// LegacyOrderRequest represents the request payload for creating an order
type LegacyOrderRequest struct {
CustNo string `json:"cust_no"`
PriFlg int `json:"pri_flg"`
ShpAddr1 string `json:"shp_addr1"`
ShpAddr2 string `json:"shp_addr2"`
ShpCity string `json:"shp_city"`
ShpSt string `json:"shp_st"`
ShpZip string `json:"shp_zip"`
ShpCntry string `json:"shp_cntry"`
CurCd string `json:"cur_cd"`
LineItems []LegacyLineItem `json:"line_items"`
}
// LegacyResponse is the generic API response wrapper from the legacy system
type LegacyResponse struct {
RtnCd int `json:"rtn_cd"` // Return code: 0 = success
RtnMsg string `json:"rtn_msg"` // Return message
Data interface{} `json:"data"` // Response payload
}
// OrderStatus codes from the legacy system
const (
LegacyStatusNew = 1
LegacyStatusProcessing = 2
LegacyStatusShipped = 3
LegacyStatusDelivered = 4
LegacyStatusCancelled = 9
)
// Priority codes from the legacy system
const (
LegacyPriorityNormal = 0
LegacyPriorityExpress = 1
LegacyPriorityOvernight = 2
)2.2 Create the Legacy ERP HTTP Client
internal/infrastructure/legacy/client.go:
package legacy
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
pkgerrors "github.com/yourusername/acl-pattern-demo/pkg/errors"
"github.com/yourusername/acl-pattern-demo/pkg/logger"
"go.uber.org/zap"
)
// ClientConfig holds the configuration for the legacy ERP client
type ClientConfig struct {
BaseURL string
Timeout time.Duration
APIKey string
}
// Client is the HTTP client for the legacy ERP system
type Client struct {
httpClient *http.Client
config ClientConfig
}
// NewClient creates a new legacy ERP HTTP client
func NewClient(config ClientConfig) *Client {
return &Client{
httpClient: &http.Client{
Timeout: config.Timeout,
},
config: config,
}
}
// GetOrder retrieves a single order from the legacy ERP system
func (c *Client) GetOrder(ctx context.Context, orderNo string) (*LegacyOrder, error) {
logger.Info("Fetching order from legacy ERP", zap.String("order_no", orderNo))
url := fmt.Sprintf("%s/api/v1/orders/%s", c.config.BaseURL, orderNo)
resp, err := c.doRequest(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, pkgerrors.Wrap(pkgerrors.ErrExternalSystem, "failed to fetch order from legacy ERP", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, pkgerrors.New(pkgerrors.ErrNotFound, fmt.Sprintf("order %s not found in legacy ERP", orderNo))
}
if resp.StatusCode != http.StatusOK {
return nil, pkgerrors.New(pkgerrors.ErrExternalSystem,
fmt.Sprintf("legacy ERP returned unexpected status: %d", resp.StatusCode))
}
var legacyResp struct {
RtnCd int `json:"rtn_cd"`
RtnMsg string `json:"rtn_msg"`
Data LegacyOrder `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&legacyResp); err != nil {
return nil, pkgerrors.Wrap(pkgerrors.ErrExternalSystem, "failed to decode legacy ERP response", err)
}
if legacyResp.RtnCd != 0 {
return nil, pkgerrors.New(pkgerrors.ErrExternalSystem,
fmt.Sprintf("legacy ERP error: %s", legacyResp.RtnMsg))
}
return &legacyResp.Data, nil
}
// ListOrdersByCustomer retrieves all orders for a customer from the legacy ERP system
func (c *Client) ListOrdersByCustomer(ctx context.Context, customerNo string) ([]LegacyOrder, error) {
logger.Info("Listing orders from legacy ERP", zap.String("customer_no", customerNo))
url := fmt.Sprintf("%s/api/v1/orders?cust_no=%s", c.config.BaseURL, customerNo)
resp, err := c.doRequest(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, pkgerrors.Wrap(pkgerrors.ErrExternalSystem, "failed to list orders from legacy ERP", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, pkgerrors.New(pkgerrors.ErrExternalSystem,
fmt.Sprintf("legacy ERP returned unexpected status: %d", resp.StatusCode))
}
var legacyResp struct {
RtnCd int `json:"rtn_cd"`
RtnMsg string `json:"rtn_msg"`
Data []LegacyOrder `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&legacyResp); err != nil {
return nil, pkgerrors.Wrap(pkgerrors.ErrExternalSystem, "failed to decode legacy ERP response", err)
}
return legacyResp.Data, nil
}
// CreateOrder creates a new order in the legacy ERP system
func (c *Client) CreateOrder(ctx context.Context, req *LegacyOrderRequest) (*LegacyOrder, error) {
logger.Info("Creating order in legacy ERP", zap.String("customer_no", req.CustNo))
url := fmt.Sprintf("%s/api/v1/orders", c.config.BaseURL)
resp, err := c.doRequest(ctx, http.MethodPost, url, req)
if err != nil {
return nil, pkgerrors.Wrap(pkgerrors.ErrExternalSystem, "failed to create order in legacy ERP", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
return nil, pkgerrors.New(pkgerrors.ErrExternalSystem,
fmt.Sprintf("legacy ERP returned unexpected status: %d, body: %s", resp.StatusCode, string(body)))
}
var legacyResp struct {
RtnCd int `json:"rtn_cd"`
RtnMsg string `json:"rtn_msg"`
Data LegacyOrder `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&legacyResp); err != nil {
return nil, pkgerrors.Wrap(pkgerrors.ErrExternalSystem, "failed to decode legacy ERP response", err)
}
if legacyResp.RtnCd != 0 {
return nil, pkgerrors.New(pkgerrors.ErrExternalSystem,
fmt.Sprintf("legacy ERP error: %s", legacyResp.RtnMsg))
}
return &legacyResp.Data, nil
}
// GetCustomer retrieves a customer from the legacy ERP system
func (c *Client) GetCustomer(ctx context.Context, customerNo string) (*LegacyCustomer, error) {
logger.Info("Fetching customer from legacy ERP", zap.String("customer_no", customerNo))
url := fmt.Sprintf("%s/api/v1/customers/%s", c.config.BaseURL, customerNo)
resp, err := c.doRequest(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, pkgerrors.Wrap(pkgerrors.ErrExternalSystem, "failed to fetch customer from legacy ERP", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, pkgerrors.New(pkgerrors.ErrNotFound, fmt.Sprintf("customer %s not found in legacy ERP", customerNo))
}
if resp.StatusCode != http.StatusOK {
return nil, pkgerrors.New(pkgerrors.ErrExternalSystem,
fmt.Sprintf("legacy ERP returned unexpected status: %d", resp.StatusCode))
}
var legacyResp struct {
RtnCd int `json:"rtn_cd"`
RtnMsg string `json:"rtn_msg"`
Data LegacyCustomer `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&legacyResp); err != nil {
return nil, pkgerrors.Wrap(pkgerrors.ErrExternalSystem, "failed to decode legacy ERP response", err)
}
return &legacyResp.Data, nil
}
// doRequest performs an HTTP request with authentication and logging
func (c *Client) doRequest(ctx context.Context, method, url string, body interface{}) (*http.Response, error) {
var reqBody io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
reqBody = bytes.NewBuffer(data)
}
req, err := http.NewRequestWithContext(ctx, method, url, reqBody)
if err != nil {
return nil, fmt.Errorf("failed to create HTTP request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("X-API-Key", c.config.APIKey)
logger.Debug("Making request to legacy ERP",
zap.String("method", method),
zap.String("url", url),
)
return c.httpClient.Do(req)
}2.3 Create the Legacy ERP Simulator
In a real project, this would be your actual legacy system. For our demo, we’ll build a small HTTP server that simulates it: (internal/infrastructure/legacy/simulator.go)
package legacy
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
)
// SimulatorServer simulates the legacy ERP system for testing purposes
type SimulatorServer struct {
router *gin.Engine
orders map[string]*LegacyOrder
customers map[string]*LegacyCustomer
}
// NewSimulatorServer creates a new legacy ERP simulator
func NewSimulatorServer() *SimulatorServer {
gin.SetMode(gin.ReleaseMode)
s := &SimulatorServer{
router: gin.New(),
orders: make(map[string]*LegacyOrder),
customers: make(map[string]*LegacyCustomer),
}
s.seedData()
s.setupRoutes()
return s
}
func (s *SimulatorServer) seedData() {
// Seed some legacy customers
s.customers["CUST-001"] = &LegacyCustomer{
CustNo: "CUST-001",
CustNm: "John Doe",
CustEml: "[email protected]",
CustPh: "555-0101",
CustTypCd: "I",
ActvFlg: 1,
CrdtLmt: 500000, // $5000.00
RegDt: "20200115",
}
s.customers["CUST-002"] = &LegacyCustomer{
CustNo: "CUST-002",
CustNm: "Acme Corporation",
CustEml: "[email protected]",
CustPh: "555-0202",
CustTypCd: "B",
ActvFlg: 1,
CrdtLmt: 5000000, // $50,000.00
RegDt: "20180301",
}
// Seed some legacy orders
s.orders["ORD-00001"] = &LegacyOrder{
OrdNo: "ORD-00001",
OrdDt: "20231015",
OrdSts: LegacyStatusDelivered,
CustNo: "CUST-001",
CustNm: "John Doe",
CustEml: "[email protected]",
ShpAddr1: "123 Main St",
ShpAddr2: "Apt 4B",
ShpCity: "New York",
ShpSt: "NY",
ShpZip: "10001",
ShpCntry: "US",
TotAmt: 15099, // $150.99
TaxAmt: 1299, // $12.99
ShpAmt: 999, // $9.99
CurCd: "USD",
IntnlFlg: 0,
PriFlg: LegacyPriorityNormal,
CrtTs: fmt.Sprintf("%d", time.Now().Add(-72*time.Hour).Unix()),
UpdTs: fmt.Sprintf("%d", time.Now().Add(-24*time.Hour).Unix()),
LineItems: []LegacyLineItem{
{
LineNo: 1,
PrtNo: "SKU-A100",
PrtDesc: "Wireless Keyboard",
Qty: 1,
UntPrc: 7999, // $79.99
LinAmt: 7999,
WgtLbs: 1.5,
UPC: "012345678901",
},
{
LineNo: 2,
PrtNo: "SKU-B200",
PrtDesc: "USB Mouse",
Qty: 2,
UntPrc: 2999, // $29.99
LinAmt: 5998,
WgtLbs: 0.5,
UPC: "012345678902",
},
},
}
}
func (s *SimulatorServer) setupRoutes() {
api := s.router.Group("/api/v1")
{
api.GET("/orders/:order_no", s.getOrder)
api.GET("/orders", s.listOrders)
api.POST("/orders", s.createOrder)
api.GET("/customers/:customer_no", s.getCustomer)
}
}
func (s *SimulatorServer) getOrder(c *gin.Context) {
orderNo := c.Param("order_no")
order, exists := s.orders[orderNo]
if !exists {
c.JSON(http.StatusNotFound, LegacyResponse{
RtnCd: 404,
RtnMsg: fmt.Sprintf("Order %s not found", orderNo),
})
return
}
c.JSON(http.StatusOK, gin.H{
"rtn_cd": 0,
"rtn_msg": "SUCCESS",
"data": order,
})
}
func (s *SimulatorServer) listOrders(c *gin.Context) {
custNo := c.Query("cust_no")
var result []LegacyOrder
for _, order := range s.orders {
if custNo == "" || order.CustNo == custNo {
result = append(result, *order)
}
}
c.JSON(http.StatusOK, gin.H{
"rtn_cd": 0,
"rtn_msg": "SUCCESS",
"data": result,
})
}
func (s *SimulatorServer) createOrder(c *gin.Context) {
// Validate API key
apiKey := c.GetHeader("X-API-Key")
if apiKey != "legacy-secret-key" {
c.JSON(http.StatusUnauthorized, LegacyResponse{
RtnCd: 401,
RtnMsg: "UNAUTHORIZED",
})
return
}
var req LegacyOrderRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, LegacyResponse{
RtnCd: 400,
RtnMsg: fmt.Sprintf("Invalid request: %s", err.Error()),
})
return
}
// Generate order number
orderNo := fmt.Sprintf("ORD-%05d", len(s.orders)+1)
// Calculate totals from line items
var totalAmt float64
for _, item := range req.LineItems {
totalAmt += item.LinAmt
}
taxAmt := totalAmt * 0.08 // 8% tax
shpAmt := 999.0 // Flat $9.99 shipping
// Look up customer info
cust, exists := s.customers[req.CustNo]
custNm := "Unknown Customer"
custEml := ""
if exists {
custNm = cust.CustNm
custEml = cust.CustEml
}
now := time.Now()
order := &LegacyOrder{
OrdNo: orderNo,
OrdDt: now.Format("20060102"),
OrdSts: LegacyStatusNew,
CustNo: req.CustNo,
CustNm: custNm,
CustEml: custEml,
ShpAddr1: req.ShpAddr1,
ShpAddr2: req.ShpAddr2,
ShpCity: req.ShpCity,
ShpSt: req.ShpSt,
ShpZip: req.ShpZip,
ShpCntry: req.ShpCntry,
TotAmt: totalAmt + taxAmt + shpAmt,
TaxAmt: taxAmt,
ShpAmt: shpAmt,
CurCd: req.CurCd,
PriFlg: req.PriFlg,
IntnlFlg: boolToInt(strings.ToUpper(req.ShpCntry) != "US"),
CrtTs: fmt.Sprintf("%d", now.Unix()),
UpdTs: fmt.Sprintf("%d", now.Unix()),
LineItems: req.LineItems,
}
s.orders[orderNo] = order
// Simulate legacy response with some weird formatting
respBody := map[string]interface{}{
"rtn_cd": 0,
"rtn_msg": "ORD-CREATED-OK",
"data": order,
}
respJSON, _ := json.Marshal(respBody)
c.Data(http.StatusCreated, "application/json", respJSON)
}
func (s *SimulatorServer) getCustomer(c *gin.Context) {
customerNo := c.Param("customer_no")
customer, exists := s.customers[customerNo]
if !exists {
c.JSON(http.StatusNotFound, LegacyResponse{
RtnCd: 404,
RtnMsg: fmt.Sprintf("Customer %s not found", customerNo),
})
return
}
c.JSON(http.StatusOK, gin.H{
"rtn_cd": 0,
"rtn_msg": "SUCCESS",
"data": customer,
})
}
func (s *SimulatorServer) Start(addr string) error {
return s.router.Run(addr)
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}2.4 Git Commit — Legacy System Definition
git add .
git commit -m "feat: define legacy ERP system models, HTTP client, and simulator
- Add LegacyOrder, LegacyLineItem, LegacyCustomer data models with cryptic field names
- Add legacy status and priority constants
- Implement legacy ERP HTTP client with authentication and error handling
- Create SimulatorServer to simulate the legacy ERP system for local development
- Seed simulator with sample customer and order data"Step 3: Building the Domain Model
Now we build our clean, expressive domain model — completely independent of the legacy system. This is what our business logic will work with.
3.1 Create the Order Status Value Object
internal/domain/order/status.go:
package order
import (
pkgerrors "github.com/yourusername/acl-pattern-demo/pkg/errors"
)
// Status represents the lifecycle status of an order using clear, descriptive language
type Status string
const (
StatusPending Status = "PENDING"
StatusProcessing Status = "PROCESSING"
StatusShipped Status = "SHIPPED"
StatusDelivered Status = "DELIVERED"
StatusCancelled Status = "CANCELLED"
)
// IsValid checks if the status value is a valid order status
func (s Status) IsValid() bool {
switch s {
case StatusPending, StatusProcessing, StatusShipped, StatusDelivered, StatusCancelled:
return true
}
return false
}
// String returns the string representation of the status
func (s Status) String() string {
return string(s)
}
// CanTransitionTo checks if a status transition is valid
func (s Status) CanTransitionTo(next Status) bool {
transitions := map[Status][]Status{
StatusPending: {StatusProcessing, StatusCancelled},
StatusProcessing: {StatusShipped, StatusCancelled},
StatusShipped: {StatusDelivered},
StatusDelivered: {},
StatusCancelled: {},
}
allowed, ok := transitions[s]
if !ok {
return false
}
for _, a := range allowed {
if a == next {
return true
}
}
return false
}
// ParseStatus parses a string into a Status value object
func ParseStatus(s string) (Status, error) {
status := Status(s)
if !status.IsValid() {
return "", pkgerrors.New(pkgerrors.ErrInvalidInput,
"invalid order status: "+s)
}
return status, nil
}3.2 Create the Priority Value Object
internal/domain/order/priority.go:
package order
import pkgerrors "github.com/yourusername/acl-pattern-demo/pkg/errors"
// Priority represents the shipping priority of an order
type Priority string
const (
PriorityStandard Priority = "STANDARD"
PriorityExpress Priority = "EXPRESS"
PriorityOvernight Priority = "OVERNIGHT"
)
// IsValid checks if the priority value is valid
func (p Priority) IsValid() bool {
switch p {
case PriorityStandard, PriorityExpress, PriorityOvernight:
return true
}
return false
}
// String returns the string representation of the priority
func (p Priority) String() string {
return string(p)
}
// EstimatedDeliveryDays returns an estimated number of business days for delivery
func (p Priority) EstimatedDeliveryDays() int {
switch p {
case PriorityStandard:
return 5
case PriorityExpress:
return 2
case PriorityOvernight:
return 1
}
return 5
}
// ParsePriority parses a string into a Priority value object
func ParsePriority(s string) (Priority, error) {
priority := Priority(s)
if !priority.IsValid() {
return "", pkgerrors.New(pkgerrors.ErrInvalidInput, "invalid order priority: "+s)
}
return priority, nil
}3.3 Create the Money Value Object
internal/domain/order/money.go:
package order
import (
"fmt"
pkgerrors "github.com/yourusername/acl-pattern-demo/pkg/errors"
)
// Money represents a monetary value with currency
// Amounts are stored as integers (cents) to avoid floating-point precision issues
type Money struct {
AmountCents int64
Currency string
}
// NewMoney creates a new Money value object
func NewMoney(amountCents int64, currency string) (Money, error) {
if amountCents < 0 {
return Money{}, pkgerrors.New(pkgerrors.ErrInvalidInput, "money amount cannot be negative")
}
if len(currency) != 3 {
return Money{}, pkgerrors.New(pkgerrors.ErrInvalidInput, "currency must be a 3-letter ISO code")
}
return Money{AmountCents: amountCents, Currency: currency}, nil
}
// NewMoneyFromFloat creates a Money object from a float64 dollar amount
func NewMoneyFromFloat(amount float64, currency string) (Money, error) {
return NewMoney(int64(amount*100), currency)
}
// Add adds two Money values (must have the same currency)
func (m Money) Add(other Money) (Money, error) {
if m.Currency != other.Currency {
return Money{}, pkgerrors.New(pkgerrors.ErrInvalidInput,
fmt.Sprintf("cannot add different currencies: %s and %s", m.Currency, other.Currency))
}
return Money{AmountCents: m.AmountCents + other.AmountCents, Currency: m.Currency}, nil
}
// ToFloat converts the money amount to a float64 dollar value
func (m Money) ToFloat() float64 {
return float64(m.AmountCents) / 100.0
}
// String returns a human-readable string representation
func (m Money) String() string {
return fmt.Sprintf("%s %.2f", m.Currency, m.ToFloat())
}
// IsZero checks if the money value is zero
func (m Money) IsZero() bool {
return m.AmountCents == 0
}3.4 Create the Address Value Object
internal/domain/order/address.go:
package order
import (
"fmt"
pkgerrors "github.com/yourusername/acl-pattern-demo/pkg/errors"
)
// Address represents a shipping or billing address
type Address struct {
Line1 string
Line2 string
City string
State string
PostalCode string
CountryCode string
}
// NewAddress creates a validated Address value object
func NewAddress(line1, line2, city, state, postalCode, countryCode string) (Address, error) {
if line1 == "" {
return Address{}, pkgerrors.New(pkgerrors.ErrInvalidInput, "address line 1 is required")
}
if city == "" {
return Address{}, pkgerrors.New(pkgerrors.ErrInvalidInput, "city is required")
}
if postalCode == "" {
return Address{}, pkgerrors.New(pkgerrors.ErrInvalidInput, "postal code is required")
}
if len(countryCode) != 2 {
return Address{}, pkgerrors.New(pkgerrors.ErrInvalidInput, "country code must be a 2-letter ISO code")
}
return Address{
Line1: line1,
Line2: line2,
City: city,
State: state,
PostalCode: postalCode,
CountryCode: countryCode,
}, nil
}
// IsInternational returns true if the address is outside the US
func (a Address) IsInternational() bool {
return a.CountryCode != "US"
}
// String returns a formatted address string
func (a Address) String() string {
addr := a.Line1
if a.Line2 != "" {
addr += "\n" + a.Line2
}
addr += fmt.Sprintf("\n%s, %s %s", a.City, a.State, a.PostalCode)
addr += "\n" + a.CountryCode
return addr
}3.5 Create the Order Domain Entity
internal/domain/order/order.go:
package order
import (
"time"
pkgerrors "github.com/yourusername/acl-pattern-demo/pkg/errors"
)
// OrderItem represents a line item in an order
type OrderItem struct {
SKU string
Name string
Quantity int
UnitPrice Money
TotalPrice Money
WeightKg float64
Barcode string
}
// Order is the central aggregate in our domain
// It represents an order in our system's ubiquitous language
type Order struct {
ID string
LegacyOrderNo string // Reference to the legacy system's order number
Status Status
Priority Priority
Customer Customer
ShippingAddress Address
Items []OrderItem
Subtotal Money
Tax Money
ShippingCost Money
Total Money
IsInternational bool
PlacedAt time.Time
UpdatedAt time.Time
}
// Customer is an embedded value object within an order
// (a simplified projection of the customer aggregate)
type Customer struct {
ID string
Name string
Email string
}
// NewOrder creates a new Order with validation
func NewOrder(
id string,
customer Customer,
items []OrderItem,
shippingAddress Address,
priority Priority,
currency string,
) (*Order, error) {
if id == "" {
return nil, pkgerrors.New(pkgerrors.ErrInvalidInput, "order ID is required")
}
if customer.ID == "" {
return nil, pkgerrors.New(pkgerrors.ErrInvalidInput, "customer is required")
}
if len(items) == 0 {
return nil, pkgerrors.New(pkgerrors.ErrInvalidInput, "order must have at least one item")
}
// Calculate subtotal from items
subtotal, err := NewMoney(0, currency)
if err != nil {
return nil, err
}
for _, item := range items {
subtotal, err = subtotal.Add(item.TotalPrice)
if err != nil {
return nil, err
}
}
return &Order{
ID: id,
Status: StatusPending,
Priority: priority,
Customer: customer,
ShippingAddress: shippingAddress,
Items: items,
Subtotal: subtotal,
IsInternational: shippingAddress.IsInternational(),
PlacedAt: time.Now(),
UpdatedAt: time.Now(),
}, nil
}
// UpdateStatus transitions the order to a new status
func (o *Order) UpdateStatus(newStatus Status) error {
if !o.Status.CanTransitionTo(newStatus) {
return pkgerrors.New(pkgerrors.ErrInvalidInput,
"invalid status transition from "+o.Status.String()+" to "+newStatus.String())
}
o.Status = newStatus
o.UpdatedAt = time.Now()
return nil
}
// ItemCount returns the total number of items in the order
func (o *Order) ItemCount() int {
total := 0
for _, item := range o.Items {
total += item.Quantity
}
return total
}
// TotalWeight returns the total weight of all items in kilograms
func (o *Order) TotalWeight() float64 {
var total float64
for _, item := range o.Items {
total += item.WeightKg * float64(item.Quantity)
}
return total
}
// IsDelivered checks if the order has been delivered
func (o *Order) IsDelivered() bool {
return o.Status == StatusDelivered
}
// IsCancellable checks if the order can be cancelled
func (o *Order) IsCancellable() bool {
return o.Status == StatusPending || o.Status == StatusProcessing
}3.6 Create the Order Repository Interface
internal/domain/order/repository.go:
package order
import "context"
// Repository defines the contract for order persistence.
// This interface lives in the domain and is implemented in the infrastructure layer.
type Repository interface {
// FindByID retrieves an order by its domain ID
FindByID(ctx context.Context, id string) (*Order, error)
// FindByLegacyOrderNo retrieves an order by the legacy system's order number
FindByLegacyOrderNo(ctx context.Context, legacyOrderNo string) (*Order, error)
// FindByCustomerID retrieves all orders for a customer
FindByCustomerID(ctx context.Context, customerID string) ([]*Order, error)
// Save persists an order (create or update)
Save(ctx context.Context, order *Order) error
}3.7 Git Commit — Domain Model
git add .
git commit -m "feat: implement clean domain model for orders
- Add Status value object with valid transitions and validation
- Add Priority value object with estimated delivery days
- Add Money value object using integer cents to avoid float precision issues
- Add Address value object with international shipping support
- Add Order aggregate with OrderItem, Customer embedded types
- Implement NewOrder factory with validation rules
- Add status transition logic and business rule methods
- Define Repository interface for order persistence (dependency inversion)"Step 4: Implementing the Anti-Corruption Layer
This is the heart of our article. The ACL consists of three components: the Facade, the Translator, and the Adapter.
4.1 Create the Legacy ERP Facade
The Facade simplifies the complex legacy API and adds stability to its interface: (internal/acl/facade/erp_facade.go)
package facade
import (
"context"
"github.com/yourusername/acl-pattern-demo/internal/infrastructure/legacy"
pkgerrors "github.com/yourusername/acl-pattern-demo/pkg/errors"
"github.com/yourusername/acl-pattern-demo/pkg/logger"
"go.uber.org/zap"
)
// ERPOrderData is the facade's simplified view of an order from the legacy ERP.
// This is NOT the domain model — it's an intermediate representation that is
// cleaner than the legacy model but hasn't yet been translated to domain concepts.
type ERPOrderData struct {
LegacyOrderNo string
LegacyOrder *legacy.LegacyOrder
}
// ERPCustomerData is the facade's simplified view of a customer from the legacy ERP
type ERPCustomerData struct {
LegacyCustomerNo string
LegacyCustomer *legacy.LegacyCustomer
}
// ERPCreateOrderData holds the data needed to create an order in the legacy ERP
type ERPCreateOrderData struct {
CustomerNo string
PriorityCode int
ShippingAddress ERPAddressData
Currency string
LineItems []ERPLineItemData
}
// ERPAddressData holds address information for ERP operations
type ERPAddressData struct {
Line1, Line2, City, State, PostalCode, CountryCode string
}
// ERPLineItemData holds line item data for ERP operations
type ERPLineItemData struct {
PartNumber string
Description string
Quantity int
UnitPrice float64 // In cents
TotalPrice float64 // In cents
WeightLbs float64
Barcode string
}
// LegacyERPFacade provides a simplified, stable interface to the legacy ERP system.
// It hides the complexity of the raw legacy client and provides meaningful error messages.
type LegacyERPFacade struct {
client *legacy.Client
}
// NewLegacyERPFacade creates a new LegacyERPFacade
func NewLegacyERPFacade(client *legacy.Client) *LegacyERPFacade {
return &LegacyERPFacade{client: client}
}
// FetchOrder retrieves an order from the legacy ERP system.
// The Facade handles connection errors, retries, and provides clean error semantics.
func (f *LegacyERPFacade) FetchOrder(ctx context.Context, legacyOrderNo string) (*ERPOrderData, error) {
if legacyOrderNo == "" {
return nil, pkgerrors.New(pkgerrors.ErrInvalidInput, "legacy order number is required")
}
logger.Info("Facade: fetching order from legacy ERP", zap.String("legacy_order_no", legacyOrderNo))
legacyOrder, err := f.client.GetOrder(ctx, legacyOrderNo)
if err != nil {
return nil, err // Already wrapped by the client
}
return &ERPOrderData{
LegacyOrderNo: legacyOrderNo,
LegacyOrder: legacyOrder,
}, nil
}
// FetchOrdersByCustomer retrieves all orders for a customer from the legacy ERP
func (f *LegacyERPFacade) FetchOrdersByCustomer(ctx context.Context, legacyCustomerNo string) ([]*ERPOrderData, error) {
if legacyCustomerNo == "" {
return nil, pkgerrors.New(pkgerrors.ErrInvalidInput, "legacy customer number is required")
}
logger.Info("Facade: fetching orders for customer", zap.String("customer_no", legacyCustomerNo))
legacyOrders, err := f.client.ListOrdersByCustomer(ctx, legacyCustomerNo)
if err != nil {
return nil, err
}
result := make([]*ERPOrderData, 0, len(legacyOrders))
for i := range legacyOrders {
result = append(result, &ERPOrderData{
LegacyOrderNo: legacyOrders[i].OrdNo,
LegacyOrder: &legacyOrders[i],
})
}
return result, nil
}
// SubmitOrder creates a new order in the legacy ERP system
func (f *LegacyERPFacade) SubmitOrder(ctx context.Context, data *ERPCreateOrderData) (*ERPOrderData, error) {
if data == nil {
return nil, pkgerrors.New(pkgerrors.ErrInvalidInput, "create order data is required")
}
logger.Info("Facade: submitting order to legacy ERP",
zap.String("customer_no", data.CustomerNo),
zap.Int("item_count", len(data.LineItems)),
)
// Map facade data to the legacy request format
legacyLineItems := make([]legacy.LegacyLineItem, 0, len(data.LineItems))
for i, item := range data.LineItems {
legacyLineItems = append(legacyLineItems, legacy.LegacyLineItem{
LineNo: i + 1,
PrtNo: item.PartNumber,
PrtDesc: item.Description,
Qty: item.Quantity,
UntPrc: item.UnitPrice,
LinAmt: item.TotalPrice,
WgtLbs: item.WeightLbs,
UPC: item.Barcode,
})
}
legacyReq := &legacy.LegacyOrderRequest{
CustNo: data.CustomerNo,
PriFlg: data.PriorityCode,
ShpAddr1: data.ShippingAddress.Line1,
ShpAddr2: data.ShippingAddress.Line2,
ShpCity: data.ShippingAddress.City,
ShpSt: data.ShippingAddress.State,
ShpZip: data.ShippingAddress.PostalCode,
ShpCntry: data.ShippingAddress.CountryCode,
CurCd: data.Currency,
LineItems: legacyLineItems,
}
createdOrder, err := f.client.CreateOrder(ctx, legacyReq)
if err != nil {
return nil, err
}
return &ERPOrderData{
LegacyOrderNo: createdOrder.OrdNo,
LegacyOrder: createdOrder,
}, nil
}
// FetchCustomer retrieves a customer from the legacy ERP
func (f *LegacyERPFacade) FetchCustomer(ctx context.Context, legacyCustomerNo string) (*ERPCustomerData, error) {
if legacyCustomerNo == "" {
return nil, pkgerrors.New(pkgerrors.ErrInvalidInput, "customer number is required")
}
logger.Info("Facade: fetching customer from legacy ERP", zap.String("customer_no", legacyCustomerNo))
legacyCustomer, err := f.client.GetCustomer(ctx, legacyCustomerNo)
if err != nil {
return nil, err
}
return &ERPCustomerData{
LegacyCustomerNo: legacyCustomerNo,
LegacyCustomer: legacyCustomer,
}, nil
}4.2 Create the Translator
The Translator is the core of the ACL — it converts between external and internal models: (internal/acl/translator/order_translator.go)
package translator
import (
"fmt"
"strconv"
"time"
"github.com/yourusername/acl-pattern-demo/internal/acl/facade"
"github.com/yourusername/acl-pattern-demo/internal/domain/order"
"github.com/yourusername/acl-pattern-demo/internal/infrastructure/legacy"
pkgerrors "github.com/yourusername/acl-pattern-demo/pkg/errors"
"github.com/yourusername/acl-pattern-demo/pkg/logger"
"github.com/google/uuid"
"go.uber.org/zap"
)
// OrderTranslator is responsible for translating between legacy ERP data
// and our clean domain model. This is the core of the Anti-Corruption Layer.
type OrderTranslator struct{}
// NewOrderTranslator creates a new OrderTranslator
func NewOrderTranslator() *OrderTranslator {
return &OrderTranslator{}
}
// ToDomain translates a legacy ERP order into our clean domain Order model.
// This is the most critical method in the ACL — it ensures that NONE of
// the legacy system's concepts leak into our domain.
func (t *OrderTranslator) ToDomain(erpData *facade.ERPOrderData) (*order.Order, error) {
if erpData == nil || erpData.LegacyOrder == nil {
return nil, pkgerrors.New(pkgerrors.ErrTranslation, "ERP order data is nil")
}
legacyOrder := erpData.LegacyOrder
logger.Debug("Translating legacy order to domain",
zap.String("legacy_order_no", legacyOrder.OrdNo),
zap.Int("legacy_status", legacyOrder.OrdSts),
)
// Translate status code → domain Status
domainStatus, err := t.translateStatus(legacyOrder.OrdSts)
if err != nil {
return nil, pkgerrors.Wrap(pkgerrors.ErrTranslation, "failed to translate order status", err)
}
// Translate priority code → domain Priority
domainPriority, err := t.translatePriority(legacyOrder.PriFlg)
if err != nil {
return nil, pkgerrors.Wrap(pkgerrors.ErrTranslation, "failed to translate order priority", err)
}
// Translate shipping address
shippingAddress, err := order.NewAddress(
legacyOrder.ShpAddr1,
legacyOrder.ShpAddr2,
legacyOrder.ShpCity,
legacyOrder.ShpSt,
legacyOrder.ShpZip,
legacyOrder.ShpCntry,
)
if err != nil {
return nil, pkgerrors.Wrap(pkgerrors.ErrTranslation, "failed to translate shipping address", err)
}
// Translate currency
currency := legacyOrder.CurCd
if currency == "" {
currency = "USD"
}
// Translate line items ("part numbers" → "products")
items, err := t.translateLineItems(legacyOrder.LineItems, currency)
if err != nil {
return nil, pkgerrors.Wrap(pkgerrors.ErrTranslation, "failed to translate line items", err)
}
// Translate financial amounts: legacy stores as cents in float64
subtotal, err := order.NewMoney(int64(legacyOrder.TotAmt-legacyOrder.TaxAmt-legacyOrder.ShpAmt), currency)
if err != nil {
return nil, pkgerrors.Wrap(pkgerrors.ErrTranslation, "failed to translate subtotal", err)
}
tax, err := order.NewMoney(int64(legacyOrder.TaxAmt), currency)
if err != nil {
return nil, pkgerrors.Wrap(pkgerrors.ErrTranslation, "failed to translate tax", err)
}
shippingCost, err := order.NewMoney(int64(legacyOrder.ShpAmt), currency)
if err != nil {
return nil, pkgerrors.Wrap(pkgerrors.ErrTranslation, "failed to translate shipping cost", err)
}
total, err := order.NewMoney(int64(legacyOrder.TotAmt), currency)
if err != nil {
return nil, pkgerrors.Wrap(pkgerrors.ErrTranslation, "failed to translate total", err)
}
// Translate timestamps (legacy uses Unix epoch as string)
placedAt, err := t.translateTimestamp(legacyOrder.CrtTs)
if err != nil {
logger.Warn("Failed to parse created timestamp, using zero value",
zap.String("raw_timestamp", legacyOrder.CrtTs))
placedAt = time.Time{}
}
updatedAt, err := t.translateTimestamp(legacyOrder.UpdTs)
if err != nil {
updatedAt = placedAt
}
// Translate order date (legacy format: YYYYMMDD)
// (already captured in placedAt from CrtTs above)
// Build the clean domain order
domainOrder := &order.Order{
ID: uuid.NewString(), // Generate a new domain ID
LegacyOrderNo: legacyOrder.OrdNo,
Status: domainStatus,
Priority: domainPriority,
Customer: order.Customer{
ID: legacyOrder.CustNo,
Name: legacyOrder.CustNm,
Email: legacyOrder.CustEml,
},
ShippingAddress: shippingAddress,
Items: items,
Subtotal: subtotal,
Tax: tax,
ShippingCost: shippingCost,
Total: total,
IsInternational: legacyOrder.IntnlFlg == 1,
PlacedAt: placedAt,
UpdatedAt: updatedAt,
}
logger.Debug("Successfully translated legacy order to domain",
zap.String("domain_id", domainOrder.ID),
zap.String("status", domainOrder.Status.String()),
zap.String("priority", domainOrder.Priority.String()),
)
return domainOrder, nil
}
// ToDomainList translates a slice of legacy ERP orders into domain orders
func (t *OrderTranslator) ToDomainList(erpOrders []*facade.ERPOrderData) ([]*order.Order, error) {
result := make([]*order.Order, 0, len(erpOrders))
for _, erpOrder := range erpOrders {
domainOrder, err := t.ToDomain(erpOrder)
if err != nil {
logger.Warn("Failed to translate order, skipping",
zap.String("legacy_order_no", erpOrder.LegacyOrderNo),
zap.Error(err),
)
continue // Log and skip bad records rather than failing the entire list
}
result = append(result, domainOrder)
}
return result, nil
}
// ToERPCreateData translates a domain create request into ERP-compatible data.
// This is the reverse translation — from domain → legacy.
func (t *OrderTranslator) ToERPCreateData(
customerLegacyNo string,
items []order.OrderItem,
shippingAddress order.Address,
priority order.Priority,
currency string,
) (*facade.ERPCreateOrderData, error) {
// Translate domain Priority → legacy priority code
legacyPriority, err := t.translatePriorityToLegacy(priority)
if err != nil {
return nil, pkgerrors.Wrap(pkgerrors.ErrTranslation, "failed to translate priority to legacy", err)
}
// Translate domain OrderItems → legacy line items
legacyLineItems := make([]facade.ERPLineItemData, 0, len(items))
for _, item := range items {
// Convert kg weight to lbs for the legacy system
weightLbs := item.WeightKg * 2.20462
legacyLineItems = append(legacyLineItems, facade.ERPLineItemData{
PartNumber: item.SKU,
Description: item.Name,
Quantity: item.Quantity,
UnitPrice: float64(item.UnitPrice.AmountCents),
TotalPrice: float64(item.TotalPrice.AmountCents),
WeightLbs: weightLbs,
Barcode: item.Barcode,
})
}
return &facade.ERPCreateOrderData{
CustomerNo: customerLegacyNo,
PriorityCode: legacyPriority,
ShippingAddress: facade.ERPAddressData{
Line1: shippingAddress.Line1,
Line2: shippingAddress.Line2,
City: shippingAddress.City,
State: shippingAddress.State,
PostalCode: shippingAddress.PostalCode,
CountryCode: shippingAddress.CountryCode,
},
Currency: currency,
LineItems: legacyLineItems,
}, nil
}
// translateStatus converts a legacy status code to a domain Status.
// This is where the "translation dictionary" lives — the mapping from
// the legacy world's numeric codes to our domain's expressive language.
func (t *OrderTranslator) translateStatus(legacyStatus int) (order.Status, error) {
statusMap := map[int]order.Status{
legacy.LegacyStatusNew: order.StatusPending,
legacy.LegacyStatusProcessing: order.StatusProcessing,
legacy.LegacyStatusShipped: order.StatusShipped,
legacy.LegacyStatusDelivered: order.StatusDelivered,
legacy.LegacyStatusCancelled: order.StatusCancelled,
}
domainStatus, ok := statusMap[legacyStatus]
if !ok {
return "", pkgerrors.New(pkgerrors.ErrTranslation,
fmt.Sprintf("unknown legacy status code: %d", legacyStatus))
}
return domainStatus, nil
}
// translatePriority converts a legacy priority code to a domain Priority
func (t *OrderTranslator) translatePriority(legacyPriority int) (order.Priority, error) {
priorityMap := map[int]order.Priority{
legacy.LegacyPriorityNormal: order.PriorityStandard,
legacy.LegacyPriorityExpress: order.PriorityExpress,
legacy.LegacyPriorityOvernight: order.PriorityOvernight,
}
domainPriority, ok := priorityMap[legacyPriority]
if !ok {
// Default to standard priority for unknown codes
logger.Warn("Unknown legacy priority code, defaulting to STANDARD",
zap.Int("legacy_priority", legacyPriority))
return order.PriorityStandard, nil
}
return domainPriority, nil
}
// translatePriorityToLegacy converts a domain Priority to a legacy priority code
func (t *OrderTranslator) translatePriorityToLegacy(priority order.Priority) (int, error) {
priorityMap := map[order.Priority]int{
order.PriorityStandard: legacy.LegacyPriorityNormal,
order.PriorityExpress: legacy.LegacyPriorityExpress,
order.PriorityOvernight: legacy.LegacyPriorityOvernight,
}
code, ok := priorityMap[priority]
if !ok {
return 0, pkgerrors.New(pkgerrors.ErrTranslation,
fmt.Sprintf("unknown domain priority: %s", priority))
}
return code, nil
}
// translateLineItems converts legacy line items to domain OrderItems.
// Note the semantic shift: "part numbers" become "products" with "SKUs"
func (t *OrderTranslator) translateLineItems(
legacyItems []legacy.LegacyLineItem,
currency string,
) ([]order.OrderItem, error) {
items := make([]order.OrderItem, 0, len(legacyItems))
for _, legacyItem := range legacyItems {
// Legacy stores price in cents as float64
unitPrice, err := order.NewMoney(int64(legacyItem.UntPrc), currency)
if err != nil {
return nil, fmt.Errorf("failed to translate unit price for item %s: %w",
legacyItem.PrtNo, err)
}
totalPrice, err := order.NewMoney(int64(legacyItem.LinAmt), currency)
if err != nil {
return nil, fmt.Errorf("failed to translate total price for item %s: %w",
legacyItem.PrtNo, err)
}
// Convert lbs to kg for our domain model
weightKg := legacyItem.WgtLbs * 0.453592
items = append(items, order.OrderItem{
SKU: legacyItem.PrtNo, // "PrtNo" (part number) → "SKU"
Name: legacyItem.PrtDesc, // "PrtDesc" → "Name"
Quantity: legacyItem.Qty,
UnitPrice: unitPrice,
TotalPrice: totalPrice,
WeightKg: weightKg,
Barcode: legacyItem.UPC,
})
}
return items, nil
}
// translateTimestamp converts a Unix epoch string to a time.Time
func (t *OrderTranslator) translateTimestamp(epochStr string) (time.Time, error) {
if epochStr == "" {
return time.Time{}, fmt.Errorf("empty timestamp string")
}
epoch, err := strconv.ParseInt(epochStr, 10, 64)
if err != nil {
return time.Time{}, fmt.Errorf("failed to parse epoch string '%s': %w", epochStr, err)
}
return time.Unix(epoch, 0), nil
}4.3 Create the Order Adapter
The Adapter implements domain-defined interfaces using the Facade and Translator: (internal/acl/adapter/order_adapter.go)
package adapter
import (
"context"
"github.com/yourusername/acl-pattern-demo/internal/acl/facade"
"github.com/yourusername/acl-pattern-demo/internal/acl/translator"
"github.com/yourusername/acl-pattern-demo/internal/domain/order"
pkgerrors "github.com/yourusername/acl-pattern-demo/pkg/errors"
"github.com/yourusername/acl-pattern-demo/pkg/logger"
"go.uber.org/zap"
)
// ERPOrderAdapter implements the order.Repository interface by translating
// all interactions through the Anti-Corruption Layer.
//
// This is where everything comes together:
// - The Facade provides a clean gateway to the legacy system
// - The Translator converts between legacy and domain models
// - The Adapter implements the domain-defined Repository contract
//
// The application layer only sees the order.Repository interface —
// it never knows anything about the legacy ERP system.
type ERPOrderAdapter struct {
facade *facade.LegacyERPFacade
translator *translator.OrderTranslator
}
// NewERPOrderAdapter creates a new ERPOrderAdapter
func NewERPOrderAdapter(
erpFacade *facade.LegacyERPFacade,
orderTranslator *translator.OrderTranslator,
) *ERPOrderAdapter {
return &ERPOrderAdapter{
facade: erpFacade,
translator: orderTranslator,
}
}
// FindByID retrieves an order by domain ID.
// In this implementation, the domain ID maps to the legacy order number.
func (a *ERPOrderAdapter) FindByID(ctx context.Context, id string) (*order.Order, error) {
return a.FindByLegacyOrderNo(ctx, id)
}
// FindByLegacyOrderNo retrieves an order by the legacy system's order number.
// This is the primary lookup mechanism when we know the legacy order number.
func (a *ERPOrderAdapter) FindByLegacyOrderNo(ctx context.Context, legacyOrderNo string) (*order.Order, error) {
logger.Info("Adapter: finding order by legacy order number",
zap.String("legacy_order_no", legacyOrderNo),
)
// Step 1: Use the Facade to fetch from the legacy ERP
erpData, err := a.facade.FetchOrder(ctx, legacyOrderNo)
if err != nil {
return nil, err // Error is already well-structured from the Facade
}
// Step 2: Use the Translator to convert to our domain model
domainOrder, err := a.translator.ToDomain(erpData)
if err != nil {
return nil, pkgerrors.Wrap(pkgerrors.ErrTranslation,
"failed to translate ERP order to domain model", err)
}
logger.Info("Adapter: successfully retrieved and translated order",
zap.String("legacy_order_no", legacyOrderNo),
zap.String("domain_id", domainOrder.ID),
zap.String("status", domainOrder.Status.String()),
)
return domainOrder, nil
}
// FindByCustomerID retrieves all orders for a customer.
// The customerID here is the legacy customer number.
func (a *ERPOrderAdapter) FindByCustomerID(ctx context.Context, customerID string) ([]*order.Order, error) {
logger.Info("Adapter: finding orders by customer ID",
zap.String("customer_id", customerID),
)
// Step 1: Fetch from the legacy ERP via the Facade
erpOrders, err := a.facade.FetchOrdersByCustomer(ctx, customerID)
if err != nil {
return nil, err
}
if len(erpOrders) == 0 {
return []*order.Order{}, nil
}
// Step 2: Translate all orders (bad records are logged and skipped)
domainOrders, err := a.translator.ToDomainList(erpOrders)
if err != nil {
return nil, pkgerrors.Wrap(pkgerrors.ErrTranslation,
"failed to translate ERP orders to domain models", err)
}
logger.Info("Adapter: successfully retrieved and translated orders",
zap.String("customer_id", customerID),
zap.Int("order_count", len(domainOrders)),
)
return domainOrders, nil
}
// Save creates or updates an order in the legacy ERP system.
// For this implementation, we only support creating new orders
// (the legacy system doesn't support updates through the API).
func (a *ERPOrderAdapter) Save(ctx context.Context, o *order.Order) error {
if o == nil {
return pkgerrors.New(pkgerrors.ErrInvalidInput, "order cannot be nil")
}
// Only handle new orders (legacy system doesn't support updates via API)
if o.LegacyOrderNo != "" {
logger.Warn("Adapter: update not supported for existing legacy orders",
zap.String("legacy_order_no", o.LegacyOrderNo),
)
return pkgerrors.New(pkgerrors.ErrExternalSystem,
"the legacy ERP system does not support order updates via API")
}
logger.Info("Adapter: saving new order to legacy ERP",
zap.String("customer_id", o.Customer.ID),
zap.Int("item_count", len(o.Items)),
)
// Step 1: Translate domain order to ERP create data
erpCreateData, err := a.translator.ToERPCreateData(
o.Customer.ID,
o.Items,
o.ShippingAddress,
o.Priority,
o.Total.Currency,
)
if err != nil {
return pkgerrors.Wrap(pkgerrors.ErrTranslation,
"failed to translate domain order to ERP create data", err)
}
// Step 2: Submit to the legacy ERP via the Facade
erpResult, err := a.facade.SubmitOrder(ctx, erpCreateData)
if err != nil {
return pkgerrors.Wrap(pkgerrors.ErrExternalSystem,
"failed to submit order to legacy ERP", err)
}
// Step 3: Update the domain order with the legacy-assigned order number
o.LegacyOrderNo = erpResult.LegacyOrderNo
logger.Info("Adapter: order successfully saved to legacy ERP",
zap.String("legacy_order_no", o.LegacyOrderNo),
)
return nil
}4.4 Git Commit — Anti-Corruption Layer
git add .
git commit -m "feat: implement Anti-Corruption Layer (Facade, Translator, Adapter)
Facade (erp_facade.go):
- LegacyERPFacade simplifies the legacy API into a clean interface
- Provides FetchOrder, FetchOrdersByCustomer, SubmitOrder, FetchCustomer
- Hides raw legacy client complexity from higher layers
Translator (order_translator.go):
- Core ACL component: translates between legacy and domain models
- Maps legacy numeric status codes (1,2,3,4,9) → domain Status (PENDING, etc.)
- Maps legacy priority codes (0,1,2) → domain Priority (STANDARD, EXPRESS, OVERNIGHT)
- Converts legacy 'part numbers' → domain 'SKUs'
- Translates Unix epoch strings → time.Time
- Converts lbs ↔ kg weight measurements
- Handles legacy float64 cents → Money value objects
- Translates YYYYMMDD dates to structured time values
Adapter (order_adapter.go):
- Implements the domain's Repository interface using Facade + Translator
- Bridges the gap between clean domain contracts and legacy system reality
- Application layer is completely unaware of the legacy ERP"Step 5: Building the Application Service
The Application Service orchestrates use cases using the domain and the ACL.
5.1 Create the Order Application Service
(internal/application/service/order_service.go):
package service
import (
"context"
"github.com/google/uuid"
"github.com/yourusername/acl-pattern-demo/internal/domain/order"
pkgerrors "github.com/yourusername/acl-pattern-demo/pkg/errors"
"github.com/yourusername/acl-pattern-demo/pkg/logger"
"go.uber.org/zap"
)
// CreateOrderRequest is the input DTO for creating an order
type CreateOrderRequest struct {
CustomerID string
ShippingAddress CreateOrderAddressRequest
Items []CreateOrderItemRequest
Priority string
Currency string
}
// CreateOrderAddressRequest holds address data for order creation
type CreateOrderAddressRequest struct {
Line1, Line2, City, State, PostalCode, CountryCode string
}
// CreateOrderItemRequest holds item data for order creation
type CreateOrderItemRequest struct {
SKU string
Name string
Quantity int
UnitPrice float64 // In dollars
WeightKg float64
Barcode string
}
// OrderResponse is the output DTO returned by the application service
type OrderResponse struct {
ID string
LegacyOrderNo string
Status string
Priority string
EstimatedDays int
Customer CustomerResponse
ShippingAddress AddressResponse
Items []OrderItemResponse
Subtotal float64
Tax float64
ShippingCost float64
Total float64
Currency string
IsInternational bool
PlacedAt string
UpdatedAt string
}
// CustomerResponse holds customer data in responses
type CustomerResponse struct {
ID string
Name string
Email string
}
// AddressResponse holds address data in responses
type AddressResponse struct {
Line1, Line2, City, State, PostalCode, CountryCode string
}
// OrderItemResponse holds order item data in responses
type OrderItemResponse struct {
SKU string
Name string
Quantity int
UnitPrice float64
TotalPrice float64
WeightKg float64
Barcode string
}
// OrderService contains the application use cases for order management
type OrderService struct {
orderRepository order.Repository
}
// NewOrderService creates a new OrderService
func NewOrderService(orderRepo order.Repository) *OrderService {
return &OrderService{
orderRepository: orderRepo,
}
}
// GetOrder retrieves a single order by its legacy order number
func (s *OrderService) GetOrder(ctx context.Context, legacyOrderNo string) (*OrderResponse, error) {
logger.Info("Application service: getting order", zap.String("legacy_order_no", legacyOrderNo))
domainOrder, err := s.orderRepository.FindByLegacyOrderNo(ctx, legacyOrderNo)
if err != nil {
return nil, err
}
return s.toOrderResponse(domainOrder), nil
}
// GetOrdersByCustomer retrieves all orders for a customer
func (s *OrderService) GetOrdersByCustomer(ctx context.Context, customerID string) ([]*OrderResponse, error) {
logger.Info("Application service: getting orders for customer",
zap.String("customer_id", customerID))
orders, err := s.orderRepository.FindByCustomerID(ctx, customerID)
if err != nil {
return nil, err
}
responses := make([]*OrderResponse, 0, len(orders))
for _, o := range orders {
responses = append(responses, s.toOrderResponse(o))
}
return responses, nil
}
// CreateOrder creates a new order
func (s *OrderService) CreateOrder(ctx context.Context, req *CreateOrderRequest) (*OrderResponse, error) {
logger.Info("Application service: creating order",
zap.String("customer_id", req.CustomerID),
zap.Int("item_count", len(req.Items)),
)
// Validate and parse priority
priority, err := order.ParsePriority(req.Priority)
if err != nil {
return nil, pkgerrors.Wrap(pkgerrors.ErrInvalidInput, "invalid priority value", err)
}
// Set default currency
currency := req.Currency
if currency == "" {
currency = "USD"
}
// Build shipping address
shippingAddress, err := order.NewAddress(
req.ShippingAddress.Line1,
req.ShippingAddress.Line2,
req.ShippingAddress.City,
req.ShippingAddress.State,
req.ShippingAddress.PostalCode,
req.ShippingAddress.CountryCode,
)
if err != nil {
return nil, pkgerrors.Wrap(pkgerrors.ErrInvalidInput, "invalid shipping address", err)
}
// Build order items
orderItems, err := s.buildOrderItems(req.Items, currency)
if err != nil {
return nil, err
}
// Create the domain order
newOrder, err := order.NewOrder(
uuid.NewString(),
order.Customer{ID: req.CustomerID},
orderItems,
shippingAddress,
priority,
currency,
)
if err != nil {
return nil, pkgerrors.Wrap(pkgerrors.ErrInvalidInput, "failed to create order", err)
}
// Persist via the repository (which goes through the ACL → legacy ERP)
if err := s.orderRepository.Save(ctx, newOrder); err != nil {
return nil, pkgerrors.Wrap(pkgerrors.ErrExternalSystem, "failed to save order", err)
}
logger.Info("Application service: order created successfully",
zap.String("domain_id", newOrder.ID),
zap.String("legacy_order_no", newOrder.LegacyOrderNo),
)
return s.toOrderResponse(newOrder), nil
}
// buildOrderItems converts request DTOs to domain OrderItems
func (s *OrderService) buildOrderItems(items []CreateOrderItemRequest, currency string) ([]order.OrderItem, error) {
if len(items) == 0 {
return nil, pkgerrors.New(pkgerrors.ErrInvalidInput, "at least one item is required")
}
orderItems := make([]order.OrderItem, 0, len(items))
for _, item := range items {
if item.SKU == "" {
return nil, pkgerrors.New(pkgerrors.ErrInvalidInput, "item SKU is required")
}
if item.Quantity <= 0 {
return nil, pkgerrors.New(pkgerrors.ErrInvalidInput, "item quantity must be greater than zero")
}
// Convert dollar amounts to cents for Money value object
unitPriceCents := int64(item.UnitPrice * 100)
totalPriceCents := unitPriceCents * int64(item.Quantity)
unitPrice, err := order.NewMoney(unitPriceCents, currency)
if err != nil {
return nil, pkgerrors.Wrap(pkgerrors.ErrInvalidInput, "invalid unit price", err)
}
totalPrice, err := order.NewMoney(totalPriceCents, currency)
if err != nil {
return nil, pkgerrors.Wrap(pkgerrors.ErrInvalidInput, "invalid total price", err)
}
orderItems = append(orderItems, order.OrderItem{
SKU: item.SKU,
Name: item.Name,
Quantity: item.Quantity,
UnitPrice: unitPrice,
TotalPrice: totalPrice,
WeightKg: item.WeightKg,
Barcode: item.Barcode,
})
}
return orderItems, nil
}
// toOrderResponse maps a domain Order to an OrderResponse DTO.
// This prevents domain objects from leaking into the API layer.
func (s *OrderService) toOrderResponse(o *order.Order) *OrderResponse {
items := make([]OrderItemResponse, 0, len(o.Items))
for _, item := range o.Items {
items = append(items, OrderItemResponse{
SKU: item.SKU,
Name: item.Name,
Quantity: item.Quantity,
UnitPrice: item.UnitPrice.ToFloat(),
TotalPrice: item.TotalPrice.ToFloat(),
WeightKg: item.WeightKg,
Barcode: item.Barcode,
})
}
return &OrderResponse{
ID: o.ID,
LegacyOrderNo: o.LegacyOrderNo,
Status: o.Status.String(),
Priority: o.Priority.String(),
EstimatedDays: o.Priority.EstimatedDeliveryDays(),
Customer: CustomerResponse{
ID: o.Customer.ID,
Name: o.Customer.Name,
Email: o.Customer.Email,
},
ShippingAddress: AddressResponse{
Line1: o.ShippingAddress.Line1,
Line2: o.ShippingAddress.Line2,
City: o.ShippingAddress.City,
State: o.ShippingAddress.State,
PostalCode: o.ShippingAddress.PostalCode,
CountryCode: o.ShippingAddress.CountryCode,
},
Items: items,
Subtotal: o.Subtotal.ToFloat(),
Tax: o.Tax.ToFloat(),
ShippingCost: o.ShippingCost.ToFloat(),
Total: o.Total.ToFloat(),
Currency: o.Total.Currency,
IsInternational: o.IsInternational,
PlacedAt: o.PlacedAt.Format("2006-01-02T15:04:05Z07:00"),
UpdatedAt: o.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
}
}5.2 Git Commit — Application Service
git add .
git commit -m "feat: implement order application service with use cases
- Add CreateOrderRequest, CreateOrderAddressRequest, CreateOrderItemRequest DTOs
- Add OrderResponse and nested response DTOs (never exposes domain objects to API layer)
- Implement GetOrder use case: fetches via repository → ACL → legacy ERP
- Implement GetOrdersByCustomer use case with result mapping
- Implement CreateOrder use case with full validation pipeline
- Add buildOrderItems helper with proper Money value object construction
- Add toOrderResponse mapper to keep domain objects internal"Step 6: Creating the REST API
Now, we are in the part that we want to implement the routes and endpoints and the main entrypoint for our application. Let’s do it.
6.1 Create the Request/Response Types
Let’s define the handles for Order alongside the request and response schemas.
internal/api/handler/order_handler.go:
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/yourusername/acl-pattern-demo/internal/application/service"
pkgerrors "github.com/yourusername/acl-pattern-demo/pkg/errors"
"github.com/yourusername/acl-pattern-demo/pkg/logger"
"go.uber.org/zap"
)
// CreateOrderAPIRequest is the JSON body for the create order endpoint
type CreateOrderAPIRequest struct {
CustomerID string `json:"customer_id" binding:"required"`
ShippingAddress ShippingAddressJSON `json:"shipping_address" binding:"required"`
Items []OrderItemJSON `json:"items" binding:"required,min=1"`
Priority string `json:"priority" binding:"required"`
Currency string `json:"currency"`
}
// ShippingAddressJSON is the JSON representation of an address
type ShippingAddressJSON struct {
Line1 string `json:"line1" binding:"required"`
Line2 string `json:"line2"`
City string `json:"city" binding:"required"`
State string `json:"state"`
PostalCode string `json:"postal_code" binding:"required"`
CountryCode string `json:"country_code" binding:"required,len=2"`
}
// OrderItemJSON is the JSON representation of an order item
type OrderItemJSON struct {
SKU string `json:"sku" binding:"required"`
Name string `json:"name" binding:"required"`
Quantity int `json:"quantity" binding:"required,min=1"`
UnitPrice float64 `json:"unit_price" binding:"required,gt=0"`
WeightKg float64 `json:"weight_kg"`
Barcode string `json:"barcode"`
}
// APIResponse is the standard envelope for all API responses
type APIResponse struct {
Success bool `json:"success"`
Data interface{} `json:"data,omitempty"`
Error *APIError `json:"error,omitempty"`
}
// APIError represents a structured API error response
type APIError struct {
Code string `json:"code"`
Message string `json:"message"`
}
// OrderHandler handles HTTP requests for order operations
type OrderHandler struct {
orderService *service.OrderService
}
// NewOrderHandler creates a new OrderHandler
func NewOrderHandler(orderService *service.OrderService) *OrderHandler {
return &OrderHandler{orderService: orderService}
}
// GetOrder godoc
// @Summary Get an order by legacy order number
// @Param order_no path string true "Legacy Order Number"
// @Success 200 {object} APIResponse
// @Router /api/v1/orders/{order_no} [get]
func (h *OrderHandler) GetOrder(c *gin.Context) {
orderNo := c.Param("order_no")
if orderNo == "" {
c.JSON(http.StatusBadRequest, errorResponse(pkgerrors.ErrInvalidInput, "order number is required"))
return
}
logger.Info("API: get order request", zap.String("order_no", orderNo))
orderResp, err := h.orderService.GetOrder(c.Request.Context(), orderNo)
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, APIResponse{Success: true, Data: orderResp})
}
// GetOrdersByCustomer retrieves all orders for a specific customer
func (h *OrderHandler) GetOrdersByCustomer(c *gin.Context) {
customerID := c.Param("customer_id")
if customerID == "" {
c.JSON(http.StatusBadRequest, errorResponse(pkgerrors.ErrInvalidInput, "customer ID is required"))
return
}
logger.Info("API: get customer orders request", zap.String("customer_id", customerID))
orders, err := h.orderService.GetOrdersByCustomer(c.Request.Context(), customerID)
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, APIResponse{
Success: true,
Data: gin.H{
"customer_id": customerID,
"total_orders": len(orders),
"orders": orders,
},
})
}
// CreateOrder creates a new order
func (h *OrderHandler) CreateOrder(c *gin.Context) {
var req CreateOrderAPIRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, errorResponse(pkgerrors.ErrInvalidInput,
"invalid request body: "+err.Error()))
return
}
logger.Info("API: create order request",
zap.String("customer_id", req.CustomerID),
zap.Int("item_count", len(req.Items)),
)
// Map API request to application service request
serviceReq := &service.CreateOrderRequest{
CustomerID: req.CustomerID,
ShippingAddress: service.CreateOrderAddressRequest{
Line1: req.ShippingAddress.Line1,
Line2: req.ShippingAddress.Line2,
City: req.ShippingAddress.City,
State: req.ShippingAddress.State,
PostalCode: req.ShippingAddress.PostalCode,
CountryCode: req.ShippingAddress.CountryCode,
},
Priority: req.Priority,
Currency: req.Currency,
}
for _, item := range req.Items {
serviceReq.Items = append(serviceReq.Items, service.CreateOrderItemRequest{
SKU: item.SKU,
Name: item.Name,
Quantity: item.Quantity,
UnitPrice: item.UnitPrice,
WeightKg: item.WeightKg,
Barcode: item.Barcode,
})
}
orderResp, err := h.orderService.CreateOrder(c.Request.Context(), serviceReq)
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusCreated, APIResponse{Success: true, Data: orderResp})
}
// HealthCheck handles health check requests
func HealthCheck(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "healthy",
"service": "order-management-service",
"version": "1.0.0",
})
}
// handleError maps domain errors to appropriate HTTP responses
func (h *OrderHandler) handleError(c *gin.Context, err error) {
logger.Error("API error occurred", zap.Error(err))
if de, ok := err.(*pkgerrors.DomainError); ok {
switch de.Code {
case pkgerrors.ErrNotFound:
c.JSON(http.StatusNotFound, errorResponse(de.Code, de.Message))
case pkgerrors.ErrInvalidInput:
c.JSON(http.StatusBadRequest, errorResponse(de.Code, de.Message))
case pkgerrors.ErrExternalSystem:
c.JSON(http.StatusBadGateway, errorResponse(de.Code, "external system error: "+de.Message))
case pkgerrors.ErrTranslation:
c.JSON(http.StatusInternalServerError, errorResponse(de.Code, "data translation error"))
case pkgerrors.ErrUnauthorized:
c.JSON(http.StatusUnauthorized, errorResponse(de.Code, de.Message))
default:
c.JSON(http.StatusInternalServerError, errorResponse(pkgerrors.ErrInternalServer, "an unexpected error occurred"))
}
return
}
c.JSON(http.StatusInternalServerError, errorResponse(pkgerrors.ErrInternalServer, "an unexpected error occurred"))
}
func errorResponse(code pkgerrors.ErrorCode, message string) APIResponse {
return APIResponse{
Success: false,
Error: &APIError{
Code: string(code),
Message: message,
},
}
}
6.2 Create the Middleware
We need middlewares for logging, cors and request tracing:
internal/api/middleware/middleware.go:
package middleware
import (
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/yourusername/acl-pattern-demo/pkg/logger"
"go.uber.org/zap"
)
// RequestID adds a unique request ID to each request
func RequestID() gin.HandlerFunc {
return func(c *gin.Context) {
requestID := c.GetHeader("X-Request-ID")
if requestID == "" {
requestID = uuid.NewString()
}
c.Set("request_id", requestID)
c.Header("X-Request-ID", requestID)
c.Next()
}
}
// Logger logs each HTTP request with structured logging
func Logger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
duration := time.Since(start)
requestID, _ := c.Get("request_id")
logger.Info("HTTP Request",
zap.String("method", c.Request.Method),
zap.String("path", c.Request.URL.Path),
zap.Int("status", c.Writer.Status()),
zap.Duration("duration", duration),
zap.String("client_ip", c.ClientIP()),
zap.Any("request_id", requestID),
zap.String("user_agent", c.Request.UserAgent()),
)
}
}
// Recovery handles panics gracefully
func Recovery() gin.HandlerFunc {
return gin.RecoveryWithWriter(nil, func(c *gin.Context, recovered interface{}) {
logger.Error("Panic recovered",
zap.Any("error", recovered),
zap.String("path", c.Request.URL.Path),
)
c.AbortWithStatusJSON(500, gin.H{
"success": false,
"error": gin.H{
"code": "INTERNAL_SERVER_ERROR",
"message": "an unexpected error occurred",
},
})
})
}
// CORS handles Cross-Origin Resource Sharing headers
func CORS() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Request-ID")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}6.3 Create the Main Application Entry Point
Finally the main entrypoint of our application is like this:
cmd/server/main.go:
package main
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/gin-gonic/gin"
"github.com/yourusername/acl-pattern-demo/internal/acl/adapter"
"github.com/yourusername/acl-pattern-demo/internal/acl/facade"
"github.com/yourusername/acl-pattern-demo/internal/acl/translator"
"github.com/yourusername/acl-pattern-demo/internal/api/handler"
"github.com/yourusername/acl-pattern-demo/internal/api/middleware"
"github.com/yourusername/acl-pattern-demo/internal/application/service"
"github.com/yourusername/acl-pattern-demo/internal/infrastructure/legacy"
"github.com/yourusername/acl-pattern-demo/pkg/logger"
"go.uber.org/zap"
)
func main() {
// Initialize logger
if err := logger.Initialize("info"); err != nil {
panic(fmt.Sprintf("failed to initialize logger: %v", err))
}
defer logger.Get().Sync()
logger.Info("Starting Order Management Service with Anti-Corruption Layer")
// ─── Start the Legacy ERP Simulator ────────────────────────────────────
// In production, this would be the actual legacy system at its configured URL
simulator := legacy.NewSimulatorServer()
go func() {
logger.Info("Starting Legacy ERP Simulator", zap.String("addr", ":9090"))
if err := simulator.Start(":9090"); err != nil {
logger.Error("Legacy ERP simulator failed", zap.Error(err))
}
}()
// Give the simulator a moment to start
time.Sleep(500 * time.Millisecond)
// ─── Wire Up Dependencies (Dependency Injection) ────────────────────────
// Notice the layered construction: Client → Facade → Translator → Adapter → Service → Handler
// Infrastructure: Legacy ERP HTTP Client
legacyClient := legacy.NewClient(legacy.ClientConfig{
BaseURL: getEnv("LEGACY_ERP_URL", "http://localhost:9090"),
Timeout: 10 * time.Second,
APIKey: getEnv("LEGACY_ERP_API_KEY", "legacy-secret-key"),
})
// ACL Layer: Facade
erpFacade := facade.NewLegacyERPFacade(legacyClient)
// ACL Layer: Translator
orderTranslator := translator.NewOrderTranslator()
// ACL Layer: Adapter (implements domain's Repository interface)
orderAdapter := adapter.NewERPOrderAdapter(erpFacade, orderTranslator)
// Application Layer: Service
orderService := service.NewOrderService(orderAdapter)
// API Layer: Handler
orderHandler := handler.NewOrderHandler(orderService)
// ─── Set Up HTTP Router ─────────────────────────────────────────────────
gin.SetMode(getEnv("GIN_MODE", "debug"))
router := gin.New()
// Global middleware
router.Use(middleware.Recovery())
router.Use(middleware.RequestID())
router.Use(middleware.Logger())
router.Use(middleware.CORS())
// Health check (no versioning needed)
router.GET("/health", handler.HealthCheck)
router.GET("/ready", handler.HealthCheck)
// API v1 routes
v1 := router.Group("/api/v1")
{
orders := v1.Group("/orders")
{
orders.GET("/:order_no", orderHandler.GetOrder)
orders.POST("", orderHandler.CreateOrder)
}
customers := v1.Group("/customers")
{
customers.GET("/:customer_id/orders", orderHandler.GetOrdersByCustomer)
}
}
// ─── Start HTTP Server with Graceful Shutdown ───────────────────────────
port := getEnv("SERVER_PORT", "8080")
srv := &http.Server{
Addr: ":" + port,
Handler: router,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
// Start server in a goroutine
go func() {
logger.Info("Order Management Service started",
zap.String("port", port),
zap.String("legacy_erp_url", getEnv("LEGACY_ERP_URL", "http://localhost:9090")),
)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("Server failed to start", zap.Error(err))
os.Exit(1)
}
}()
// Wait for interrupt signal for graceful shutdown
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
logger.Info("Shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
logger.Error("Server forced to shutdown", zap.Error(err))
}
logger.Info("Server shutdown complete")
}
// getEnv retrieves an environment variable or returns a default value
func getEnv(key, defaultValue string) string {
if value, exists := os.LookupEnv(key); exists {
return value
}
return defaultValue
}6.4 Git Commit — REST API and Main Entry Point
git add .
git commit -m "feat: implement REST API layer and application entry point
Handler (order_handler.go):
- CreateOrderAPIRequest with binding validation
- GetOrder, GetOrdersByCustomer, CreateOrder endpoints
- Structured error mapping (domain errors → HTTP status codes)
- HealthCheck endpoint for k8s liveness/readiness probes
Middleware (middleware.go):
- RequestID: adds/propagates X-Request-ID header
- Logger: structured HTTP request logging with Zap
- Recovery: graceful panic handling with structured error response
- CORS: cross-origin resource sharing headers
Main (cmd/server/main.go):
- Full dependency injection wiring: Client→Facade→Translator→Adapter→Service→Handler
- Starts legacy ERP simulator on port 9090
- Configurable via environment variables (LEGACY_ERP_URL, SERVER_PORT, etc.)
- Graceful shutdown with 30-second timeout on SIGINT/SIGTERM"Step 7: Dockerizing the Application
7.1 Create the Multi-Stage Dockerfile
deployments/docker/Dockerfile:
# ─── Stage 1: Builder ────────────────────────────────────────────────────────
FROM golang:1.21-alpine AS builder
# Install build dependencies
RUN apk add --no-cache git ca-certificates tzdata
# Create non-root user for the final image
RUN adduser -D -g '' appuser
WORKDIR /app
# Copy dependency files first for better layer caching
COPY go.mod go.sum ./
RUN go mod download && go mod verify
# Copy source code
COPY . .
# Build the binary with optimizations
# CGO_ENABLED=0 produces a static binary
# -ldflags strips debug info for smaller binary
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
-ldflags='-w -s -extldflags "-static"' \
-a \
-o /app/bin/server \
./cmd/server
# ─── Stage 2: Final Image ─────────────────────────────────────────────────────
FROM scratch
# Import from builder
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /etc/passwd /etc/passwd
# Copy the compiled binary
COPY --from=builder /app/bin/server /server
# Copy configuration
COPY --from=builder /app/configs /configs
# Use non-root user
USER appuser
# Expose application ports
EXPOSE 8080 9090
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD ["/server", "--health-check"] || exit 1
ENTRYPOINT ["/server"]7.2 Create the Docker Ignore File
.dockerignore:
# Git
.git
.gitignore
# Go build artifacts
*.exe
*.dll
*.so
*.dylib
bin/
# Test files
*_test.go
testdata/
# Documentation
*.md
docs/
# IDE files
.idea/
.vscode/
*.swp
*.swo
# OS files
.DS_Store
Thumbs.db
# Docker
deployments/docker/
deployments/kubernetes/
# CI/CD
.github/7.3 Create Docker Compose for Local Development
deployments/docker/docker-compose.yml:
services:
# ─── Order Management Service (includes legacy ERP simulator) ─────────────
order-service:
build:
context: ../..
dockerfile: deployments/docker/Dockerfile
container_name: order-management-service
ports:
- "8080:8080" # Main API
- "9090:9090" # Legacy ERP Simulator
environment:
- SERVER_PORT=8080
- LEGACY_ERP_URL=http://localhost:9090
- LEGACY_ERP_API_KEY=legacy-secret-key
- GIN_MODE=release
- LOG_LEVEL=info
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
restart: unless-stopped
networks:
- acl-network
# ─── Optional: A simple curl-based test runner ────────────────────────────
api-tester:
image: curlimages/curl:latest
container_name: api-tester
depends_on:
order-service:
condition: service_healthy
networks:
- acl-network
entrypoint: ["sh", "-c", "
echo '=== Testing Order Management Service ===';
echo '--- Health Check ---';
curl -s http://order-service:8080/health | head -c 200;
echo '';
echo '--- Get Order ORD-00001 ---';
curl -s http://order-service:8080/api/v1/orders/ORD-00001 | head -c 500;
echo '';
echo '--- Get Customer Orders ---';
curl -s http://order-service:8080/api/v1/customers/CUST-001/orders | head -c 500;
echo '';
echo '=== Tests Complete ===';
"]
networks:
acl-network:
driver: bridge7.4 Build and Test with Docker
# Build the Docker image
docker build -t acl-pattern-demo:latest -f deployments/docker/Dockerfile .
# Run with Docker Compose
docker compose -f deployments/docker/docker-compose.yml up -d
# Check the logs
docker compose -f deployments/docker/docker-compose.yml logs -f order-service
# Test the running container
curl -s http://localhost:8080/health | jq .
curl -s http://localhost:8080/api/v1/orders/ORD-00001 | jq .
# Stop the containers
dockercompose -f deployments/docker/docker-compose.yml down
7.5 Git Commit — Docker Configuration
git add .
git commit -m "feat: add Docker multi-stage build and Docker Compose configuration
Dockerfile (deployments/docker/Dockerfile):
- Two-stage build: golang:1.21-alpine builder → scratch final image
- CGO_ENABLED=0 for fully static binary with -ldflags strip
- Runs as non-root 'appuser' for security
- Final image is minimal (scratch-based) for reduced attack surface
- Built-in HEALTHCHECK instruction
.dockerignore:
- Excludes test files, IDE config, git history, docs, and build artifacts
- Reduces build context size for faster builds
docker-compose.yml:
- order-service with health check and restart policy
- Configurable via environment variables
- api-tester service for smoke testing
- Isolated acl-network bridge network"Project Summary:
We saw how we can implement the Anti Corruption Layer using Golang to cleanly integrate legacy and modern systems, You can access the source code of the project in this Github repository:
https://github.com/mjmichael73/go-acl-pattern
Best Practices
Having built the complete implementation, here are the key best practices distilled from experience.
1. Keep the Translation Logic Centralized
Don’t scatter translation logic across multiple files:
// BAD: Translation leaking into the handler
func (h *Handler) GetOrder(c *gin.Context) {
legacyOrder, _ := h.legacyClient.GetOrder(id)
// Translating right here in the handler — wrong!
if legacyOrder.OrdSts == 1 {
response.Status = "PENDING"
}
}Do centralize all translation in the Translator:
// GOOD: Handler delegates to service, service to adapter, adapter to translator
func (h *Handler) GetOrder(c *gin.Context) {
order, _ := h.service.GetOrder(ctx, id)
c.JSON(200, h.toResponse(order)) // Already clean domain object
}2. Never Expose Legacy Types Beyond the ACL Boundary
// BAD: Domain service depending on legacy type
type OrderService struct {
legacyClient *legacy.Client // Legacy type leaking into domain
}
// GOOD: Domain service depends only on domain-defined interface
type OrderService struct {
orderRepository order.Repository // Domain-defined interface
}3. Use Defensive Translation
Always handle edge cases in translation — legacy systems are often inconsistent:
// GOOD: Defensive translation with sensible defaults
func (t *Translator) translatePriority(legacyCode int) (order.Priority, error) {
priorityMap := map[int]order.Priority{...}
priority, ok := priorityMap[legacyCode]
if !ok {
// Log the anomaly and return a safe default
logger.Warn("Unknown priority code, defaulting to STANDARD",
zap.Int("code", legacyCode))
return order.PriorityStandard, nil
}
return priority, nil
}4. Test the Translation Logic Thoroughly
The Translator is the most critical part of the ACL. Test every mapping:
// Test every status code, every priority, every edge case
func TestAllStatusTranslations(t *testing.T) {
for legacyCode, expectedStatus := range statusMapping {
t.Run(expectedStatus.String(), func(t *testing.T) {
// Verify each mapping works correctly
})
}
}5. Add Structured Logging at Each ACL Boundary
// Log when entering and leaving each ACL component
logger.Info("Translator: converting legacy order",
zap.String("legacy_order_no", legacyOrder.OrdNo),
zap.Int("legacy_status", legacyOrder.OrdSts),
)
// ... translation ...
logger.Info("Translator: conversion complete",
zap.String("domain_status", domainOrder.Status.String()),
)Conclusion
You’ve now built a complete, production-ready implementation of the Anti-Corruption Layer pattern using Golang, Docker, and Kubernetes. Let’s recap what we built and why each piece matters.
Key Takeaways

When This Pattern Will Save You
The ACL pattern truly shines when:
- A legacy system changes its status codes (only the Translator changes)
- You need to swap the legacy system for a new one (only the Adapter changes)
- You want to test business logic without a real legacy system (mock the Repository)
- You need to add a second external system (add a new ACL alongside the existing one)
The Anti-Corruption Layer is not just a code pattern — it is a philosophy of respectful boundaries. It says: “I respect that your system exists and has its own model, but I am not willing to let your model corrupt mine.”
That boundary, maintained consistently, is what separates systems that remain maintainable for decades from those that collapse under their own weight.
Further Reading
- Domain-Driven Design by Eric Evans
- Implementing Domain-Driven Design by Vaughn Vernon
- Go Project Layout Standard



