This is an article written by Claude but guided by me. I hate a lot of the domain-driven design articles because IMO they all talk around the issue but it's never actionable (read: helpful).
Hopefully you think this is different.
The Anti-Corruption Layer in Go: One Flight Model, Many Vendors
Every airline operations system eventually integrates with something it didn't design. A legacy movement-message feed, a crew rostering package bought in 2009, a ground handler's REST API that returns "9999-12-31" when it means "unknown." Each of these ships a model of the world along with its data, and that model is almost never yours.
An anti-corruption layer (ACL) is the code that stops the vendor's model from leaking into your domain. It's a translation boundary: foreign representations in, your aggregates out, and nothing partial in between.
What you actually get from it
Three things, in order of importance:
One model instead of N. Without an ACL, STATUS_CD == "OUT" shows up in your scheduling service, your delay reporting, and your gate display logic. Now the vendor's enum is your domain vocabulary, and it will diverge from what your business actually means. With an ACL, that string is resolved to a flight.Status exactly once.
Invariants enforced at a single door. Your Flight aggregate has rules: no off-block time without an assigned tail, no delay over fifteen minutes without a reason code. An ACL makes translation go through those rules rather than around them. Foreign data becomes a candidate state that the aggregate accepts or rejects.
A named place for corruption to fail. Sentinel dates, empty strings meaning "unknown," a status code the vendor added last Tuesday — these become explicit translation errors at the edge, instead of a zero-valued time.Time quietly propagating into a delay report.
The domain, expressed on its own terms
The aggregate depends on nothing outside the standard library. That constraint is the whole design.
package flight
type Status int
const (
Scheduled Status = iota
OffBlocks
Airborne
OnBlocks
Cancelled
)
// Flight is the aggregate root. All fields unexported: no
// caller can assemble an invalid Flight.
type Flight struct {
id LegID
designator Designator // e.g. UA 1174
origin, dest Station
scheduledOut time.Time // always UTC
actualOut *time.Time
tail Tail // zero until an aircraft is assigned
delay Delay
status Status
}
func (f *Flight) RecordOffBlocks(at time.Time) error {
if f.status != Scheduled {
return fmt.Errorf("off-blocks from status %v: %w", f.status, ErrIllegalTransition)
}
if f.tail.IsZero() {
return ErrNoAircraftAssigned
}
f.actualOut, f.status = &at, OffBlocks
if d := at.Sub(f.scheduledOut); d > 15*time.Minute && f.delay.reason == "" {
return ErrDelayReasonRequired
}
return nil
}
The aggregate also needs a way to be reconstituted from outside state — from your own database, and from a vendor. Give it exactly one such door:
// Snapshot is inbound state awaiting validation. It is not a
// Flight; it is a request to become one.
type Snapshot struct {
ID LegID
Designator Designator
Origin, Dest Station
ScheduledOut time.Time
ActualOut *time.Time
Tail Tail
Delay Delay
Status Status
}
func Rehydrate(s Snapshot) (*Flight, error) {
if s.ScheduledOut.IsZero() || s.ScheduledOut.Location() != time.UTC {
return nil, ErrScheduleTimeInvalid
}
if s.Status >= OffBlocks && s.Status != Cancelled {
if s.ActualOut == nil {
return nil, ErrMissingActualOut // "OUT" with no OUT time
}
if s.Tail.IsZero() {
return nil, ErrNoAircraftAssigned
}
}
if s.Delay.Minutes > 15 && s.Delay.Reason == "" {
return nil, ErrDelayReasonRequired
}
// ... remaining checks
return &Flight{id: s.ID, /* ... */ status: s.Status}, nil
}
Rehydrate is what makes this a real anti-corruption layer and not just a mapper. If the translator could fill in a Flight's fields directly, it could build a flight your rules say is impossible — one already off the gate with no aircraft assigned to it. Because all it can hand over is a Snapshot, the domain gets to say no.
The port belongs to the consumer
OpsHub is the ground handler's operations platform: their system of record for flight legs, on their infrastructure, versioned on their release schedule. They ship a generated Go client for its API, and you own the adapter package that wraps that client. So when your scheduler needs one leg before reassigning an aircraft, what the vendor offers you is this — OpsHub's key in, OpsHub's record out:
// package opshubsdk — the vendor's generated client, not yours
func (c *Client) GetFlightLegV2(ctx context.Context, key string) (*legDTO, error)
Calling that from the scheduler would bind the scheduler to legDTO and to OpsHub's idea of a key. The fix is to put the interface where it is used rather than where it is implemented — the domain declares the capability it needs, in its own terms:
package flight
// Named and owned here; implemented elsewhere.
type LegRepository interface {
Find(ctx context.Context, id LegID) (*Flight, error)
}
Go implementations don't declare which interfaces they satisfy, so opshub.Adapter can fit LegRepository without the domain ever mentioning opshub. Pin that in the adapter package, where the dependency is allowed to exist:
var _ flight.LegRepository = (*Adapter)(nil)
Dependencies now point inward — opshub imports flight, never the reverse, and flight imports only the standard library. That arrow is the mechanical difference between an ACL and a shared "types" package, and the compiler enforces it: an import ".../opshub" inside flight is a review-stopping defect. Swap ground handlers and LegRepository and every caller above it are untouched; you write one new adapter.
The foreign model, quarantined
Everything below is code you own: the opshub package is the anti-corruption layer. It holds three types with three separate jobs. legDTO mirrors the vendor's JSON exactly, warts included, and stays unexported so nothing outside the package can depend on its shape. Translator does the mapping — one legDTO in, one flight.Snapshot out, no I/O anywhere in it, which is why it's trivial to test. Adapter wires the two together and is the type that satisfies flight.LegRepository: fetch, translate, hand to the domain.
package opshub
type legDTO struct {
LegKey string `json:"LEG_KEY"`
FltNbr string `json:"FLT_NBR"` // "1174", sometimes " 174"
DepStn string `json:"DEP_STN"`
ArrStn string `json:"ARR_STN"`
SkdOut string `json:"SKD_OUT"` // naive station-local time
ActOut string `json:"ACT_OUT"` // "" or "9999-12-31 00:00"
StatusCd string `json:"STATUS_CD"`
AcReg string `json:"AC_REG"`
DlyMins int `json:"DLY_MINS"`
DlyRsnCd string `json:"DLY_RSN_CD"`
}
var statuses = map[string]flight.Status{
"SKD": flight.Scheduled,
"OUT": flight.OffBlocks,
"OFF": flight.Airborne,
"ON": flight.Airborne, // wheels-on is not a state we model
"IN": flight.OnBlocks,
"CNL": flight.Cancelled,
}
type Translator struct {
zones ZoneResolver // station -> IANA time zone
}
func (t Translator) toSnapshot(d legDTO) (flight.Snapshot, error) {
status, ok := statuses[d.StatusCd]
if !ok {
// Vendor enum drift. Refuse; do not guess.
return flight.Snapshot{}, fmt.Errorf("%w: STATUS_CD %q", ErrUnmappable, d.StatusCd)
}
origin, err := flight.NewStation(d.DepStn)
if err != nil {
return flight.Snapshot{}, fmt.Errorf("DEP_STN: %w", err)
}
skdOut, err := t.toUTC(d.SkdOut, origin)
if err != nil {
return flight.Snapshot{}, fmt.Errorf("SKD_OUT: %w", err)
}
var actOut *time.Time
if !isNullish(d.ActOut) { // "", "9999-12-31 00:00", "0000-00-00"
v, err := t.toUTC(d.ActOut, origin)
if err != nil {
return flight.Snapshot{}, fmt.Errorf("ACT_OUT: %w", err)
}
actOut = &v
}
return flight.Snapshot{
ID: flight.LegID(d.LegKey),
Origin: origin,
ScheduledOut: skdOut,
ActualOut: actOut,
Tail: flight.ParseTail(d.AcReg), // zero if ""
Delay: flight.Delay{Minutes: d.DlyMins, Reason: t.mapReason(d.DlyRsnCd)},
Status: status,
// ...
}, nil
}
// Find is the port implementation: vendor call, translation, then the domain.
func (a Adapter) Find(ctx context.Context, id flight.LegID) (*flight.Flight, error) {
dto, err := a.client.fetchLeg(ctx, string(id))
if err != nil {
return nil, err
}
snap, err := a.translator.toSnapshot(dto)
if err != nil {
return nil, err
}
return flight.Rehydrate(snap) // domain has final say
}
Note what the ACL absorbed: naive local times and the station-to-time-zone lookup they require, whitespace in flight numbers, three spellings of null, a wheels-on state your business doesn't track, and vendor delay codes mapped to your own taxonomy. None of that is visible past the Find method's signature.
Note also what it refuses to do. An unknown STATUS_CD is not defaulted to Scheduled. A "OUT" leg with no ACT_OUT does not become an OffBlocks flight with a zero timestamp — Rehydrate rejects it. Translation is total or it fails: every foreign value either maps to a domain concept or produces an error naming the field. Silent defaulting is how vendor corruption gets into a delay-cost report, and by then the trail is cold.
Testing at the boundary
The ACL is the cheapest thing in your system to test, because it's a pure function over bytes.
-
Golden payloads. Keep real (scrubbed) vendor responses in
testdata/and assert the resultingSnapshot. When the vendor changes something in production, the diff shows up here first. -
An unknown-code case. Assert that a novel
STATUS_CDreturnsErrUnmappable. This test is the one that pays for the layer. -
Domain rules tested separately, with hand-built
Snapshots and no vendor JSON in sight.RehydrateandRecordOffBlocksdon't know OpsHub exists.
When the payload updates an aggregate you already have
Rehydrate covers the read-through case: you hold no state of your own, so you build a Flight out of the vendor's picture of it. Movement messages are the harder case. OpsHub pushes an update for a leg you already have, and your copy holds decisions the vendor knows nothing about — the delay reason a controller keyed in, the tail your own scheduler assigned.
The tempting move is to translate the payload into a full Snapshot and Rehydrate over the top. Don't. That makes the vendor win every field, and its blanks quietly erase your data.
Translate the payload into an intention instead — a command, in domain vocabulary:
package flight
// A Command is a claim about what happened, not a new state.
type Command interface{ ApplyTo(*Flight) error }
type OffBlocksReported struct{ At time.Time }
func (c OffBlocksReported) ApplyTo(f *Flight) error {
return f.RecordOffBlocks(c.At) // the aggregate may still refuse
}
The translator's job becomes deciding which domain command the payload represents:
package opshub
func (t Translator) toCommand(d legDTO) (flight.Command, error) {
switch d.StatusCd {
case "OUT":
at, err := t.toUTC(d.ActOut, /* origin station */)
if err != nil {
return nil, fmt.Errorf("ACT_OUT: %w", err)
}
return flight.OffBlocksReported{At: at}, nil
case "CNL":
return flight.CancellationReported{Reason: t.mapReason(d.DlyRsnCd)}, nil
// ...
default:
return nil, fmt.Errorf("%w: STATUS_CD %q", ErrUnmappable, d.StatusCd)
}
}
Applying it belongs in your application service, not in the ACL — load from your own store, apply, save:
func (s Service) ApplyLegUpdate(ctx context.Context, id flight.LegID, cmd flight.Command) error {
f, err := s.legs.Find(ctx, id) // s.legs is your database, not OpsHub
if err != nil {
return err
}
if err := cmd.ApplyTo(f); err != nil {
return err
}
return s.legs.Save(ctx, f)
}
Three things fall out of this that a snapshot-and-overwrite approach can't give you. Vendors send partial payloads, and a command carries only what changed, so you're never forced to invent values for the fields the message omitted. Movement messages arrive late and duplicated, and a replayed "OUT" hits RecordOffBlocks on a flight that is already off blocks and comes back as ErrIllegalTransition — a rejected duplicate rather than a rewritten history. And when one payload spans two aggregates — leg movement plus a crew change — the translator emits one command each, so the vendor's message shape doesn't get to define your transaction boundaries. Each aggregate stays its own unit of consistency, which is the whole reason you drew the boundary.
That is the real job of the layer. Not moving fields between structs, but deciding what an outside system is allowed to assert about a model you own.
Top comments (0)