The first Go service next to a PHP monolith is written quickly. The problem arrives two weeks later, and it looks like this: the new service has a struct with a field cust_stat, because the old table has that column, and a field flag2, because nobody knows what it means any more but the report needs it.
At that point the new service is no longer a new service. It is a second piece of legacy, in a different language, and the model everybody wanted to escape has made the jump too.
The layer that prevents this is called an anti-corruption layer. This article describes where it sits, what it translates, what it handles along the way, and when it is allowed to disappear.
The old model comes along if you let it
The mechanism is quiet and very reliable. The new service needs customer data, so it reads it from the existing table. The easiest thing is to adopt the column names, because then no conversion is needed. Two weeks later five places in the new code depend on those field names, and the old model is set in concrete.
The result is worse than the starting position. Before, there was one old model in one place. Now there is the same old model in two places, in two languages, and every change to it needs both.
The layer against that is not an invention, it is a decision about where translation happens. It costs half a day the first time, and it is the difference between a service that becomes independent later and one that never does.
What the layer is, and what it is not
An anti-corruption layer is a thin body of code on the new side that translates everything coming in from the old world and everything going out. Behind it, nobody knows the old vocabulary.
Three misunderstandings are worth clearing up.
It is not a service of its own. An extra process in the middle doubles the failure points and the operational work. The layer is a package inside the new service, not an application.
It is not an adapter around every call. Mirroring every function of the old world means rebuilding the old interface and gaining nothing. The layer models what the new service needs, not what the old world offers.
It is not meant to be permanent. It is a bridge for the time in which both models exist. Without an end date it becomes part of the inventory nobody touches.
Which part gets pulled out first is decided beforehand: Which service to pull out of the monolith first.
Where the layer sits
That question decides the benefit, and it often gets answered wrongly: the layer belongs on the new side, not in the middle and certainly not in the monolith.
In the monolith it would be one more place in the legacy system for somebody to maintain, and it would suffer along with every rebuild there. In the middle it would be another process. On the new side it is part of the service, ships with it, is tested with it and gets removed with it.
In practice that looks like this: the new service has one package that is the only place knowing the old vocabulary. Everything else in the service works with its own.
internal/
├── billing/ Domain logic. Knows only its own vocabulary.
│ └── customer.go type Customer struct { ID, Plan, ActiveSince ... }
└── legacy/ The layer. The only place with old vocabulary.
├── reader.go Reads from the old database
└── translate.go Turns cust_stat into a plan stateComing from PHP, you trip over different things than expected: Learning Go as a PHP developer.
Three kinds of difference that get translated
What the layer does falls into three categories with very different levels of effort. The third one is what costs projects.
Naming. cust_stat becomes Plan, create_dt becomes CreatedAt. That is mechanical, boring and half the value: on its own it makes the new code read like new code.
Structure. The old world has a wide table with forty columns, twelve of which are only filled for one special case. The new side has an object with a clear shape. The layer throws away what the service does not need, and that is its most important property: it does not pass everything through.
Meaning. This is where it gets expensive. status = 7 means "cancelled, but still active for the current month", and only one person knows that. Rules like that are written down nowhere, and they are the real value of the layer: they get recorded once, with a comment, instead of being guessed again in five places.
// Plan state from the old cust_stat column.
//
// The numbers come from the 2011 billing system. 7 and 8 both mean
// "cancelled": 7 is cancellation at month end (so the contract still runs),
// 8 is immediate. Collapsing the two cuts customers off too early. Confirmed
// with the business on 2026-10-08.
func planState(custStat int) (billing.State, error) {
switch custStat {
case 1, 2:
return billing.Active, nil
case 7:
return billing.CancelledAtMonthEnd, nil
case 8:
return billing.CancelledImmediately, nil
case 9:
return billing.Suspended, nil
default:
// Deliberately an error rather than a default value: an unknown
// state is a data finding, not a normal case.
return 0, fmt.Errorf("unknown cust_stat %d", custStat)
}
}The default branch is the most important line in the example. An unknown value becomes an error rather than silently becoming Active. Every legacy system holds values nobody expects, and the layer is where they surface.
The read path, and why it gets its own struct
The temptation is to read the database row straight into the domain object. That puts the old structure back into the new code, only invisibly.
Two structs here are not extra work, they are the whole idea:
// Inside the layer: mirrors the old table, names as they are there.
type customerRow struct {
CustNo int
CustStat int
CreateDt sql.NullTime
Flag2 sql.NullString // Meaning unclear, see below
}
// What the service gets. Does not know cust_stat.
func (r *Reader) Customer(ctx context.Context, id int) (billing.Customer, error) {
var row customerRow
err := r.db.QueryRowContext(ctx,
`SELECT cust_no, cust_stat, create_dt, flag2 FROM customer WHERE cust_no = ?`, id).
Scan(&row.CustNo, &row.CustStat, &row.CreateDt, &row.Flag2)
if err != nil {
return billing.Customer{}, fmt.Errorf("read customer %d: %w", id, err)
}
state, err := planState(row.CustStat)
if err != nil {
return billing.Customer{}, fmt.Errorf("customer %d: %w", id, err)
}
return billing.Customer{
ID: row.CustNo,
State: state,
CreatedAt: row.CreateDt.Time,
}, nil
}About Flag2: a field whose meaning nobody knows gets read in the layer and left there, with a comment. It does not travel inwards. If its meaning turns up later, exactly one file changes.
The write path and the errors
Towards the legacy system the layer translates back, and while doing so it handles two things that would otherwise be scattered across the whole service.
Timeouts. The call into the legacy system gets a deadline. Without one, the new service inherits the response times of the old one, and a slow monolith makes the fast service slow. The deadline belongs in the layer, because it is a property of the connection and not of the domain logic.
Translating errors. A database timeout is not a database error to the service, it is "legacy system unreachable". Inside, that gets handled without the domain logic knowing what a database is.
var ErrLegacyUnreachable = errors.New("legacy system unreachable")
func (r *Reader) withDeadline(ctx context.Context) (context.Context, context.CancelFunc) {
// 800 ms: the monolith's measured 99th percentile is 610 ms. Not a round
// number from the gut, but the measurement plus headroom.
return context.WithTimeout(ctx, 800*time.Millisecond)
}On the PHP side the call stays deliberately thin. The monolith knows the new service through exactly one class, and that class returns the old behaviour on failure:
public function planState(int $customerId): string
{
try {
return $this->billingService->state($customerId);
} catch (ServiceUnreachable) {
// Fall back to the old path while it still exists.
// Disappears with the old code, not before.
return $this->legacyState($customerId);
}
}When the layer disappears again
An anti-corruption layer is a bridge. Bridges come down when the two banks have grown together, and here that means when the old data is no longer the source.
The route there is the usual one: the service gets its own data, both get written for a while, then the read path switches, then the old one falls away. Only in that last step does the layer disappear, and it disappears completely, translation functions included.
What does not disappear are the comments. The rule that 7 and 8 are two different cancellations is domain knowledge and belongs in the domain logic then, not in the bin. That is the real return on the whole exercise: knowledge that used to sit in an undocumented column ends up as code with a reason attached.
While the layer exists, it carries a date and a name. Without both it becomes, in two years, the part of the system nobody touches, because nobody knows which translations are still needed.
How Go services get wired in next to a PHP system at all is covered in its own article; how I set such a project up is on its own page.
This article belongs to a series about systems that already exist. The retrospective orders every article in it by situation.

