Anti-Corruption Layer - DDD in Ruby on Rails
Your delivery application is expanding to China. Google Maps barely works there, so you integrate Baidu Maps, the dominant maps provider on the Chinese market. The geocoding endpoint responds with:
{
"status": 0,
"result": {
"location": { "lng": 116.307852, "lat": 40.056885 },
"precise": 1,
"confidence": 80,
"comprehension": 100,
"level": "门址"
}
}
Nobody on your team suggests passing these values around the application. What does "level": "门址" mean? Is confidence: 80 good or bad? The documentation is in Chinese, the status codes are numeric, and the field values are unreadable. So the response gets wrapped immediately. Someone digs through the docs, translates each field, and gives every value a name the team understands. Only clean, meaningful objects leave the integration code.
Then the same team integrates Google Maps and writes location_type == "ROOFTOP" inside a business rule without blinking.
This article is about that difference. Domain-Driven Design calls the missing piece an anti-corruption layer, and the Baidu integration shows that every developer already knows how to build one. The hard part is noticing when it's needed.
What is an Anti-Corruption Layer?
Whenever two domains integrate, two models meet. Earlier in this series, bounded contexts drew boundaries between models inside your own application. With an external provider, the boundary is drawn for you, but the models still meet, and one of them will influence the other.
You are the downstream side of this relationship. Baidu won't rename a field because it confuses your team, and Google won't adjust its enums to match your business vocabulary. Without a deliberate boundary, the influence flows one way: the provider's names, types, and quirks seep into your code and become part of your ubiquitous language.
"Create an isolating layer to provide clients with functionality in terms of their own domain model."
— Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software
An anti-corruption layer is exactly that. It speaks the external system's language on one side and your domain's language on the other. It translates names, but more importantly it translates meaning: foreign statuses become your statuses, foreign scales become your scales, foreign concepts either map to your concepts or stop at the boundary.
Geocoding with Baidu Maps
The delivery domain needs an answer to one question: where is this address, and how precisely do we know it? That vocabulary belongs to us, so the domain objects come first.
# app/modules/geolocation/geocoding_result.rb
module Geolocation
GeocodingResult = Data.define(:coordinates, :precision)
end
Coordinates is a value object, covered earlier in this series. It holds a latitude and a longitude in WGS-84, the standard used by GPS and by the rest of our system.
# app/modules/geolocation/coordinates.rb
module Geolocation
Coordinates = Data.define(:latitude, :longitude) do
def self.from_bd09(latitude:, longitude:)
new(**CoordinateSystems.bd09_to_wgs84(latitude:, longitude:))
end
end
end
Why from_bd09? Because Baidu returns coordinates in BD-09, its own coordinate system, offset from WGS-84. The field names say lng and lat, same as Google's, but a coordinate taken straight from a Baidu response and compared with a GPS position will be off by hundreds of meters. The conversion algorithms are public and easily found. The point is that the conversion happens here, in one place, and nowhere else.
The geocoder talks to Baidu in Baidu's language and returns only domain objects:
# app/modules/geolocation/baidu/geocoder.rb
module Geolocation
module Baidu
class Geocoder
GEOCODING_URL = "https://api.map.baidu.com/geocoding/v3/"
PRECISION_LEVELS = {
"门址" => :street_address,
"道路" => :road,
"商务大厦" => :building,
"城市" => :city
}.freeze
def geocode(address)
payload = fetch_geocoding(address)
raise GeocodingError, "Baidu status #{payload['status']}" unless payload["status"].zero?
result = payload["result"]
GeocodingResult.new(
coordinates: Coordinates.from_bd09(
latitude: result.dig("location", "lat"),
longitude: result.dig("location", "lng")
),
precision: PRECISION_LEVELS.fetch(result["level"], :approximate)
)
end
private
def fetch_geocoding(address)
uri = URI(GEOCODING_URL)
uri.query = URI.encode_www_form(
address: address,
output: "json",
ak: Rails.application.credentials.baidu_maps_key
)
JSON.parse(Net::HTTP.get(uri))
end
end
end
end
The PRECISION_LEVELS hash is the translation made visible. On the left, Baidu's vocabulary. On the right, ours. A courier doesn't care that Baidu matched a 门址 (a door address). They care that the precision is :street_address, which our domain defines as good enough to deliver a parcel without calling the customer.
Nobody needs convincing to write this code. The foreign language forces the translation.
When the External Model Speaks English
Now the same application geocodes a London address through Google Maps:
{
"status": "OK",
"results": [
{
"formatted_address": "10 Downing St, London SW1A 2AA, UK",
"geometry": {
"location": { "lat": 51.5033635, "lng": -0.1276248 },
"location_type": "ROOFTOP"
},
"place_id": "ChIJRxzRQcQEdkgRGVaKyzmkgvg"
}
]
}
Every field reads naturally. formatted_address is obviously an address, ROOFTOP sounds precise, place_id looks useful, better keep it. And so it all flows straight into the application. The parcels table gains place_id and location_type columns, and a few months later a business rule reads:
class Parcel < ApplicationRecord
def deliverable?
%w[ROOFTOP RANGE_INTERPOLATED].include?(location_type)
end
end
This is the corruption the pattern's name warns about. The model didn't break, it got quietly replaced:
- Business rules quote Google's enum values, so domain logic now depends on a vendor's API reference.
- The ubiquitous language is polluted. A domain expert would not recognize a rooftop parcel.
- A change in Google's API ripples through models, validations, and tests instead of stopping at the boundary.
- Adding a second provider means teaching the whole codebase a second foreign language, because the first one was never contained.
The only difference between this integration and the Baidu one is that Google's model is readable. Readability switched off the translation instinct. Corruption has nothing to do with character sets. It is about whose model your code speaks.
Anti-Corruption Layer in Ruby on Rails
The fix is to treat Google exactly the way we treated Baidu. The anti-corruption layer lives inside the bounded context that owns the integration, following the module structure from the Bounded Context article:
app/modules/geolocation/
├── geolocation.rb
├── coordinates.rb
├── geocoding_result.rb
├── baidu/
│ └── geocoder.rb
└── google/
└── geocoder.rb
The Google geocoder translates into the same domain vocabulary:
# app/modules/geolocation/google/geocoder.rb
module Geolocation
module Google
class Geocoder
PRECISION_LEVELS = {
"ROOFTOP" => :street_address,
"RANGE_INTERPOLATED" => :road,
"GEOMETRIC_CENTER" => :building,
"APPROXIMATE" => :city
}.freeze
def geocode(address)
payload = fetch_geocoding(address)
raise GeocodingError, "Google status #{payload['status']}" unless payload["status"] == "OK"
result = payload["results"].first
GeocodingResult.new(
coordinates: Coordinates.new(
latitude: result.dig("geometry", "location", "lat"),
longitude: result.dig("geometry", "location", "lng")
),
precision: PRECISION_LEVELS.fetch(
result.dig("geometry", "location_type"), :approximate
)
)
end
# fetch_geocoding omitted, analogous to the Baidu version
end
end
end
Notice that both translators make judgment calls. Is Baidu's 商务大厦 the same precision as Google's GEOMETRIC_CENTER? Is RANGE_INTERPOLATED good enough to deliver without a phone call? These are domain decisions, and the anti-corruption layer is where they get made, once, explicitly, instead of being scattered across the codebase as string comparisons.
The rest of the application picks a provider and speaks its own language:
# app/modules/geolocation/geolocation.rb
module Geolocation
def self.geocoder_for(country_code)
country_code == "CN" ? Baidu::Geocoder.new : Google::Geocoder.new
end
end
result = Geolocation.geocoder_for(parcel.country_code).geocode(parcel.address)
parcel.deliverable = result.precision == :street_address
The beauty of this approach is that the rest of the application cannot tell the providers apart. Parcel knows nothing about rooftops or door addresses. Domain tests build a GeocodingResult in plain Ruby rather than stubbing vendor payloads. And when a third market requires a third maps provider, the work is one new translator, not a codebase-wide migration.
Translating Errors at the Boundary
Precision levels aren't the only foreign vocabulary a provider exposes. A timeout, a dropped connection, or a numeric Baidu status code belongs to the provider's model just as much as 门址 does. You've already seen the layer handle one of these: the status check raises GeocodingError rather than letting a raw Baidu code escape.
Transport failures deserve the same treatment. A Timeout::Error or a JSON::ParserError that leaks out corrupts the failure paths the way ROOFTOP corrupts the happy path, and a controller that rescues Net::OpenTimeout now depends on how the geocoder talks to Baidu. So the boundary translates failures into the domain's own vocabulary, and GeocodingError is the domain's word for "we couldn't geocode this address":
# app/modules/geolocation/geolocation.rb
module Geolocation
GeocodingError = Class.new(StandardError)
end
# app/modules/geolocation/baidu/geocoder.rb
def geocode(address)
payload = fetch_geocoding(address)
raise GeocodingError, "Baidu status #{payload['status']}" unless payload["status"].zero?
# ... build GeocodingResult as before
rescue Timeout::Error, JSON::ParserError => e
raise GeocodingError, "Baidu geocoding failed: #{e.message}"
end
Now every failure that crosses the boundary is a GeocodingError, whether it began as a Chinese status code or a dropped connection. The caller decides what to do when geocoding fails without knowing which provider failed, or how.
Where to Place the Anti-Corruption Layer
So far the layer sits at the edge of the Geolocation context, and that is its natural home. When we integrate with a supplier, the relationship is one-sided. The supplier sets the terms of the conversation and evolves on its own schedule, so the layer is defensive and belongs to the downstream side, inside the context whose model it protects. Everything under baidu/ and google/ is that defensive edge, and only domain objects cross it.
In the Blue Book, Evans describes the internals of this edge as a combination of facades, adapters, and translators. The facade simplifies access to the supplier but still speaks the supplier's language, so conceptually it belongs to the supplier's context, even though we write the code. The adapters and translators turn its answers into our model. Our integration is small enough that fetch_geocoding plays the facade while PRECISION_LEVELS and Coordinates.from_bd09 do the translating. A heavier integration separates these into distinct objects, and the layer gains structure without gaining domain logic.
The layer can also live outside the context. When more than one context consumes the same supplier (the delivery application and a fleet analytics application both geocode addresses), repeating the translators in each of them duplicates work and maintenance. The translation can be promoted to a small standalone service that talks to the map providers and exposes one internal protocol. There is a consequence: such a service becomes a bounded context of its own, with a published language, and each consumer still translates that language into its model at its edge. The translation gets thinner, but the boundary stays. What must not change is ownership. The layer defends the downstream models, so the downstream side defines its language.
Is an Anti-Corruption Layer Always Worth It?
No. An anti-corruption layer is another layer to write and maintain, and sometimes the translation adds nothing. When you build a thin dashboard over a single vendor's API, the vendor's model effectively is your domain, and conforming to it is the honest choice. DDD calls this the conformist relationship, and choosing it consciously is a legitimate decision.
The rule of thumb follows the business rules. The more your domain logic depends on external data, the more the translation pays off. A value displayed once on a screen can stay foreign. A value that decides whether a parcel ships should speak your language.
Summary
Treat every external model as if it responded in a language you cannot read, because in the way that matters, it does. The instinct that makes you translate a Baidu response is the anti-corruption layer working as intended. Apply the same instinct when the API speaks fluent English, and your domain model stays yours.