Consuming an external API looks harmless at first. You run a requests.get, get a dictionary back, and use it directly throughout the code. The problem starts when that same JSON structure ends up scattered across ten files, and the API renames a field or switches price from float to string. Fixing it becomes a treasure hunt.
The anti-corruption layer (ACL) addresses this problem. Borrowed from Domain-Driven Design, it acts as a translator between an external system and your business logic. One contact point, one place to update when the API changes.
The problem: diffuse coupling
Consider a service that fetches car information from a third-party API:
def display_car(url: str) -> None:
response = requests.get(url)
data = response.json()
print(f"{data['brand']} {data['model']} - {data['color_hex']}")
def price_with_tax(url: str) -> float:
response = requests.get(url)
data = response.json()
return float(data['price']) * 1.20
The dictionary returned by the API leaks everywhere. If the provider renames color_hex to paint_code, every function accessing it must be updated. Worse, if price switches from "15000.00" to 15000, the float() conversions scattered around the code become so many time bombs.
This coupling is invisible until the day it hurts. It is a particularly insidious form of connascence: the shape of an external payload becomes an implicit contract shared by every caller.
The solution: a dedicated domain object
The ACL introduces an intermediate type that represents what your application understands, not what the API returns:
from dataclasses import dataclass
import requests
@dataclass(frozen=True)
class Car:
brand: str
model: str
color: str
price: float
def fetch_car(url: str) -> Car:
response = requests.get(url, timeout=5)
response.raise_for_status()
data = response.json()
return Car(
brand=data["brand"],
model=data["model"],
color=data["color_hex"],
price=float(data["price"]),
)
The rest of the application only handles Car instances. The transformations (renaming color_hex to color, converting price to float) are concentrated in fetch_car. The frozen=True prevents accidental mutation of an object meant to reflect external data. For more on the subtleties of immutable dataclasses, see field(default_factory) in depth.
What the ACL really brings
The obvious benefit is resilience to external changes. If the API renames a field, you touch a single function. But the ACL brings three less visible things.
A clean domain vocabulary. An API may return usr_first_nm, dob_iso, acct_st. Your application should not live with those names. The ACL translates them into first_name, birth_date, account_status, which makes the calling code readable without external context.
A clear boundary between I/O and logic. All the network parts (timeout, HTTP errors, JSON parsing) stay in the layer. Business logic knows nothing about requests or httpx. That makes testing easier: replace fetch_car with a stub returning a Car, no HTTP mock required.
End-to-end typing. The raw data dictionary has no type. Once converted into a Car, mypy or Pyright can check every access. Field errors become lint errors, not runtime errors.
Handling missing or invalid fields
The original note used data.get("brand"), which returns None if the key is missing. That is false safety: None propagates through the code and blows up later, far from the actual cause.
The ACL is the ideal place to validate:
@dataclass(frozen=True)
class Car:
brand: str
model: str
color: str
price: float
def fetch_car(url: str) -> Car:
response = requests.get(url, timeout=5)
response.raise_for_status()
data = response.json()
try:
return Car(
brand=data["brand"],
model=data["model"],
color=data["color_hex"],
price=float(data["price"]),
)
except (KeyError, ValueError, TypeError) as exc:
raise ValueError(f"Invalid API response: {exc}") from exc
The error is raised the moment it is detectable, with an explicit message. The rest of the code receives either a valid Car, or a clear exception.
For more complex contracts, Pydantic fills the same role with declarative validation:
from pydantic import BaseModel, Field
class Car(BaseModel):
brand: str
model: str
color: str = Field(alias="color_hex")
price: float
def fetch_car(url: str) -> Car:
response = requests.get(url, timeout=5)
response.raise_for_status()
return Car.model_validate(response.json())
Pydantic handles the renaming via alias, the type conversions, and raises a detailed ValidationError if the response does not match the expected schema.
When not to add an ACL
The ACL has a cost: one extra type to maintain, one conversion function to write. For a script that calls an API once and prints the result, it is over-engineering.
The pattern becomes relevant as soon as:
- the same external data is consumed in several places
- the API vocabulary differs from your domain vocabulary
- the API is unstable or out of your control
- you want to be able to swap the provider without rewriting business code
Conversely, for a one-off call to your own internal API whose contract you control, the ACL adds little.
Conclusion
The anti-corruption layer is not a complex pattern. It is a discipline: refuse to let external structures leak into business code. The dict returned by an API stays at the boundary, and everything flowing inside the application is a typed, validated object, named after your own vocabulary.
The day the API changes, you will know exactly which file to open.
