Skip to content
wannacode

Integrating old and foreign systems

In short: An anti-corruption layer translates the model of an old or foreign system into your domain, and a bus with a canonical format cuts the links between n systems to one adapter per system.

Problem

A warehouse from 2009 and accounting in XML

The shop has an old warehouse: a program from 2009 that nobody dares to touch. It speaks CSV, with ; and codes: A is available, B is blocked, and a quantity of -1 means "counting right now". Accounting wants XML with the total written with a point. Delivery wants JSON.

This is how orders tell all three:

class Orders(Endpoint warehouse, Endpoint accounting,
Endpoint delivery)
{
public void Place(int id, string sku, int qty,
long cents)
{
// the 2009 warehouse: CSV and the code OUT
warehouse.Send($"{id};{sku};{qty};OUT");
// accounting: XML, the total with a point
var total = Euros(cents);
accounting.Send(
$"""<invoice no="{id}" total="{total}"/>""");
// delivery: JSON
var json = $$"""{"order":{{id}},"sku":"{{sku}}"}""";
delivery.Send(json);
}
}
var orders = new Orders(new Endpoint("warehouse"),
new Endpoint("accounting"), new Endpoint("delivery"));
orders.Place(1042, "HP-01", 1, 5900);
Output
warehouse ← 1042;HP-01;1;OUT
accounting ← <invoice no="1042" total="59.00"/>
delivery ← {"order":1042,"sku":"HP-01"}

It works. But orders now know three foreign formats, and the warehouse codes have spread into the checkout too: somewhere it says if (status == "B"). The catalogue also talks to the warehouse, the sellers to accounting. When the warehouse adds a new status, nobody knows how many places to look in.

The idea

A translator at the boundary

A foreign system has its own model: its own codes, its own units, its own odd values. If that model gets into the domain, the domain starts thinking in the language of the 2009 warehouse and changes every time the warehouse does.

So a translator stands at the boundary. It reads the foreign format and lets in only the concepts of the domain: Stock(sku, available). Only the translator knows what A, B and -1 mean. This is called an anti-corruption layer.

Inside, it is an adapter, but with a bigger job: an adapter changes the shape of one interface, while an anti-corruption layer keeps a whole model from leaking in. It decides what each foreign value means and what to do with the unknown. The rule: the unknown is rejected out loud, never guessed.

See it

The translator in code

record Stock(string Sku, int Available);
// the boundary: only here is it known what A, B and -1 mean
class WarehouseAcl
{
public List<string> Rejected { get; } = [];
public List<Stock> Read(IEnumerable<string> lines)
{
var stock = new List<Stock>();
foreach (var line in lines)
{
var f = line.Split(';');
var sku = f[0];
var qty = int.Parse(f[1]);
switch (f[2])
{
case "A" when qty >= 0:
stock.Add(new Stock(sku, qty));
break;
case "A":
Rejected.Add($"{sku}: unknown quantity");
break;
case "B":
stock.Add(new Stock(sku, 0));
break;
default:
Rejected.Add($"{sku}: unknown status {f[2]}");
break;
}
}
return stock;
}
}
case "A" when qty >= 0 is a case with a condition: "A" with a negative quantity goes on to the next case.

This morning's file:

// the file the old warehouse uploads over FTP every hour
string[] file = ["HP-01;3;A", "CH-07;0;A", "KB-12;5;B", "MS-03;-1;A", "SP-02;2;R"];
var acl = new WarehouseAcl();
foreach (var s in acl.Read(file))
Console.WriteLine($"{s.Sku}: {s.Available}");
foreach (var r in acl.Rejected)
Console.WriteLine($"✗ {r}");
Output
HP-01: 3
CH-07: 0
KB-12: 0
✗ MS-03: unknown quantity
✗ SP-02: unknown status R

KB-12 is blocked, so for the domain 0 are available. MS-03 is being counted: the translator does not know how many there are and does not make up a number. SP-02 has the status R, which nobody has described. It is rejected, and someone will ask the owner of the warehouse what it means.

Try it

Find the bug

A colleague writes a shorter translator: "everything that is not blocked is for sale". A month later the warehouse starts sending the status R (reserved for an outside seller), and the shop sells the same speaker to two customers. Also, MS-03 appears on the site with -1 in stock. Which line is the bug on?

Pick the line with the bug.

The idea

The web of links

Six systems: orders, catalogue, warehouse, accounting, delivery and outside sellers. If each talks to each, the links are n(n-1)/2 = 15, each with its own translation. A change of the warehouse format touches 5 of them, in 5 systems that have to be released together.

The other way is a hub with a canonical form: one shared form of the message (OrderPlaced) that everyone understands. Each system connects once, with an adapter between its own format and the canonical one. Six systems, six adapters. A change of the warehouse format touches only its adapter: that adapter is the warehouse's anti-corruption layer.

record OrderPlaced(int Id, string Sku, int Qty,
long Cents);
delegate string Adapter(OrderPlaced e);
class Hub
{
readonly Dictionary<Endpoint, Adapter> adapters = [];
public void Connect(Endpoint system, Adapter adapter) =>
adapters[system] = adapter;
public void Publish(OrderPlaced e)
{
foreach (var (system, adapter) in adapters)
system.Send(adapter(e));
}
public int Size => adapters.Count;
}
Step by step, in words
  1. Orders send to three systems, each in its own language: CSV to the 2009 warehouse, XML to accounting, JSON to delivery. Orders know all three formats.
  2. The others talk like this too: the catalogue to the warehouse, the sellers to accounting. 6 systems that all talk to each other are 15 links, each with its own translation.
  3. The warehouse changes its format. Every link to it changes: 5 places in 5 systems, released together.
  4. A hub: each system connects once, with an adapter between its own format and one canonical form. 6 systems, 6 adapters.
  5. Orders publish OrderPlaced once and know no CSV or XML. The hub gives each system its own translation.
  6. The warehouse changes its format again: only its adapter changes. It keeps the old model out of the domain: an anti-corruption layer.
  7. Point-to-point: 15 links, 5 changes per format. Hub: 6 adapters, 1 change. The price: the hub is one more system that must not fail.

See it

The hub in code

The adapter is a delegate: any lambda that takes an OrderPlaced and returns text will do. Orders publish once, and the adapters sit next to each system:

var hub = new Hub();
hub.Connect(new Endpoint("warehouse"),
e => $"{e.Id};{e.Sku};{e.Qty};OUT");
hub.Connect(new Endpoint("accounting"),
e => $"""<invoice no="{e.Id}" total="{Euros(e.Cents)}"/>""");
hub.Connect(new Endpoint("delivery"),
e => $$"""{"order":{{e.Id}},"sku":"{{e.Sku}}"}""");
// orders publish once, in the canonical form
hub.Publish(new OrderPlaced(1042, "HP-01", 1, 5900));
Console.WriteLine($"adapters: {hub.Size}");
Output
warehouse ← 1042;HP-01;1;OUT
accounting ← <invoice no="1042" total="59.00"/>
delivery ← {"order":1042,"sku":"HP-01"}
adapters: 3

The messages are the same as at the start. The difference is who knows the format: orders no longer know CSV and XML, and a new system needs one more connect, with no change to orders. Euros turns cents into text with a point: 5900 becomes 59.00.

Try it

Predict the output

foreach (var n in new[] { 3, 6, 10 })
{
var links = n * (n - 1) / 2;
Console.WriteLine($"{n} systems: {links} links or {n} adapters");
}
Answer

The idea

Synchronous or asynchronous, and the contract

Not every link is the same:

Synchronous (request and response) Asynchronous (queue, file)
When the answer is needed now: "is it in stock?" before the sale it can wait: the invoices by the morning, the warehouse file every hour
If the other side is slow you wait too, hence timeout and retry the message waits in the queue
If the other side is down you refuse or have a fallback answer it catches up when it is back

The canonical form is a contract: everyone depends on it. It changes like an API contract: a new field is added without breaking the old adapters, and removing a field is a new version. If the contract gets fields for the warehouse and fields only for accounting, it belongs to everyone and to no one.

The idea

When the hub hurts

  • With two or three systems. A hub is one more system: it is deployed, monitored, and it fails. With few links, one direct link with a translator is cheaper.
  • When logic gets into it. A hub that decides discounts or routes by business rules becomes a place no team owns and every team waits for. The hub carries and translates; the rules stay in the systems.
  • When the canonical form wants everything. A model that describes the order for every system at once takes months to agree on, and then nobody dares to touch it. Several small messages (OrderPlaced, StockChanged), each with a clear owner, work better.
  • An anti-corruption layer is not always needed. If the foreign model is good and stable (for example the contract of the payment provider), or if it is a module of your own team, the translator is just one more layer to maintain.
More precisely, if you are curious

Real hubs and brokers: Apache Kafka, RabbitMQ, the cloud queues (Amazon SQS, Azure Service Bus); for integration: Apache Camel, Spring Integration, MuleSoft, Kafka Connect. The older name is ESB (enterprise service bus). Its bad reputation comes from the buses that ended up holding all the business logic.

The patterns have names. The book "Enterprise Integration Patterns" (Gregor Hohpe and Bobby Woolf, 2003) describes the message translator, the canonical data model, the router and dozens more. The anti-corruption layer comes from "Domain-Driven Design" by Eric Evans (2003).

Old systems in practice: the translator also keeps the original file, so a line can be replayed once someone finds out what R means. A file over FTP is read only once it has been uploaded completely (for example, signalled by an empty .done file next to it). The FTP servers themselves, the brokers and the network between them are infrastructure and are taught in the DevOps course ZeroToOps.

Synchronous through a hub is done too (an API gateway in front of several systems), but then the hub is part of every request: if it is slow, everything is slow.

Exercise

Returned goods

Solve it in your editor.

The old warehouse uploads a second file: the returned goods. A line is RET;order;sku;count;date, and the date is dd.MM.yyyy. Sometimes the file also has lines from the stocktaking (INV;…), which are not returns. Write a translator that gives the domain returns with the date in ISO format (2026-10-15) and rejects out loud every line that is not a return.

The input is RET;1042;HP-01;1;15.10.2026, RET;1043;CH-07;2;16.10.2026 and INV;HP-01;3.

Output
return 1042: HP-01 × 1 on 2026-10-15
return 1043: CH-07 × 2 on 2026-10-16
✗ not a return: INV;HP-01;3

More tasks

  • Outside sellers. Connect the sellers to the hub too: they want the line ORDER 1042 HP-01. How many lines in orders change?
  • Accounting answers. Accounting sends back XML <paid no="1042" cents="5900"/>. Write the translator inwards: the domain wants a payment in euros.

Test

Check yourself

1. What does an anti-corruption layer do?
2. The old warehouse sends a line with a status the translator does not know. What is right?
3. Eight systems all talk to each other. How many links is that, and how many adapters does a hub with a canonical form need?
4. The checkout must know whether an item is in stock before it accepts the order. Accounting wants the invoices by the morning. How do you connect them?
5. When is a hub not needed?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·