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: def __init__(self, warehouse, accounting, delivery): self.warehouse = warehouse self.accounting = accounting self.delivery = delivery
def place(self, order, sku, qty, cents): # the 2009 warehouse: CSV and the code OUT self.warehouse.send(f"{order};{sku};{qty};OUT") # accounting: XML, the total with a point total = euros(cents) self.accounting.send( f'<invoice no="{order}" total="{total}"/>') # delivery: JSON self.delivery.send( json.dumps({"order": order, "sku": sku}, separators=(",", ":")))orders = Orders(Endpoint("warehouse"), Endpoint("accounting"), Endpoint("delivery"))orders.place(1042, "HP-01", 1, 5900)warehouse ← 1042;HP-01;1;OUTaccounting ← <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
@dataclass(frozen=True)class Stock: sku: str available: int
# the boundary: only here is it known what A, B and -1 meanclass WarehouseAcl: def __init__(self): self.rejected = []
def read(self, lines): stock = [] for line in lines: sku, qty, status = line.split(";") qty = int(qty) if status == "A" and qty >= 0: stock.append(Stock(sku, qty)) elif status == "A": self.rejected.append(f"{sku}: unknown quantity") elif status == "B": stock.append(Stock(sku, 0)) else: self.rejected.append(f"{sku}: unknown status {status}") return stockStock is a dataclass: the domain gets an object with names, not a line from the file.This morning's file:
# the file the old warehouse uploads over FTP every hourfile = ["HP-01;3;A", "CH-07;0;A", "KB-12;5;B", "MS-03;-1;A", "SP-02;2;R"]acl = WarehouseAcl()for s in acl.read(file): print(f"{s.sku}: {s.available}")for r in acl.rejected: print("✗", r)HP-01: 3CH-07: 0KB-12: 0✗ MS-03: unknown quantity✗ SP-02: unknown status RKB-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 translator guesses instead of translating: every status that is not B becomes "available", and every quantity, even -1, goes into the domain. The unknown must be rejected out loud, as WarehouseAcl above does: the known cases listed, and a rejection for everything else.
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.
@dataclass(frozen=True)class OrderPlaced: id: int sku: str qty: int cents: int
class Hub: def __init__(self): self.adapters = {}
def connect(self, system, adapter): self.adapters[system] = adapter
def publish(self, event): for system, adapter in self.adapters.items(): system.send(adapter(event))
def size(self): return len(self.adapters)class Orders: def __init__(self, warehouse, accounting, delivery): self.warehouse = warehouse self.accounting = accounting self.delivery = delivery
def place(self, order, sku, qty, cents): # the 2009 warehouse: CSV and the code OUT self.warehouse.send(f"{order};{sku};{qty};OUT") # accounting: XML, the total with a point total = euros(cents) self.accounting.send( f'<invoice no="{order}" total="{total}"/>') # delivery: JSON self.delivery.send( json.dumps({"order": order, "sku": sku}, separators=(",", ":")))Step by step, in words
- 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.
- 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.
- The warehouse changes its format. Every link to it changes: 5 places in 5 systems, released together.
- A hub: each system connects once, with an adapter between its own format and one canonical form. 6 systems, 6 adapters.
- Orders publish OrderPlaced once and know no CSV or XML. The hub gives each system its own translation.
- The warehouse changes its format again: only its adapter changes. It keeps the old model out of the domain: an anti-corruption layer.
- 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 function: Python wants no interface, as long as it is called with an OrderPlaced and returns text. Orders publish once, and the adapters sit next to each system:
hub = Hub()hub.connect(Endpoint("warehouse"), lambda e: f"{e.id};{e.sku};{e.qty};OUT")hub.connect(Endpoint("accounting"), lambda e: f'<invoice no="{e.id}" total="{euros(e.cents)}"/>')hub.connect(Endpoint("delivery"), lambda e: json.dumps({"order": e.id, "sku": e.sku}, separators=(",", ":")))# orders publish once, in the canonical formhub.publish(OrderPlaced(1042, "HP-01", 1, 5900))print(f"adapters: {hub.size()}")warehouse ← 1042;HP-01;1;OUTaccounting ← <invoice no="1042" total="59.00"/>delivery ← {"order":1042,"sku":"HP-01"}adapters: 3The 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
for n in [3, 6, 10]: links = n * (n - 1) // 2 print(f"{n} systems: {links} links or {n} adapters")The links are the pairs of systems: each of n with each of the other n - 1, divided by 2, because the link A–B is the same as B–A. They grow almost like n², while the adapters grow like n. This is the worst case: in a real shop not every system talks to every other. But even 8 links with 8 different translations are a lot.
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.
return 1042: HP-01 × 1 on 2026-10-15return 1043: CH-07 × 2 on 2026-10-16✗ not a return: INV;HP-01;3More 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
Related lessons
Section titled "Related lessons"- Builds on: Events and queues · API design: contract, versions, retried requests · When the neighbour is slow: timeout, retry, circuit breaker · A transaction across services (saga)
- Continues in: Changing a live system (strangler fig, expand–contract) · The enterprise landscape and capabilities · Your first architecture task
- Tasks on this topic: The architect at work