How to protect naming conventions when working with microservices or third-party APIs

Subscribe to receive new articles. No spam. Quality content.

Naming things it's a hard task. Once you named something in the system, you want to follow this naming convention. Especially it's important if term comes from domain experts. But it's not that easy to keep naming right when you have to deal with third-party APIs or microservices.

We all care about keeping ubiquitous language nice and clean where:

domain model should form a common language given by domain experts for describing system requirements, that works equally well for the business users or sponsors and for the software developers.

It's important. In a perfect world every API we want to consume would follow the same rules as we do. But unfortunately very often we're dealing with legacy systems or third-party APIs which were built a while ago and they just not aware of our naming convention.

If it's a third-party API we can not change it. But even if we own that legacy system it's not easy to change it. Usually, it has many consumers and scope of renaming service plus all consumers is just too big to satisfy one our requirement for the naming.

To be more practical, your system might have the following set of attributes for Order morel:

:id, :title, :amount, :items

Whereas service that returns order can use these:

"OrderNumber", "OrderTitle", "TotalAmount", "OrderItems"

It's just an example, but I've seen systems where internal implementation of service leaked to API layer, so names of attributes looked really weird.

We want to protect our application and follow naming conventions we defined with domain experts, right?

In "Domain-Driven Design: Tackling Complexity in the Heart of Software" Eric Evans suggests to create "Anti-Corruption Layer" for such cases (he also mentioned Facade Pattern and Adapter pattern in that book).

New systems almost always have to be integrated with legacy or other systems, which have their own models. Translation layers can be simple, even elegant, when bridging well-designed BOUNDED CONTEXTS with cooperative teams. But when the other side of the boundary starts to leak through, the translation layer may take on a more defensive tone.

In this article, I wanted to share the approach I take to tackle this problem. My solution is not too complex but good enough to keep domain model clean.

I follow Clean Architecture principles, so I have Repositories and Entities. Also, I introduced the idea of data sources. I inject data source (using dry-container) depending on the environment. For the test environment, it's an InMemory data source that returns predefined ready-to-use data, for any other environment it injects API data source.

By convention data source should return either Hash or Array back to Repository. Repository wraps data into entity or array of entities.

Flow looks like this:

Anti-corruption layer

One important part here is "Mapping". I'm not sure if I can call it full-featured Anti-Corruption layer, but it solves the problem of response from the legacy system. It allows data source to return clean data back to the repository.

I was struggling with mapping issues on couple different projects, so I decided to extract that logic into a gem. That's how response_mapper was created.

It's really basic helper:

response = JSON.parse(response_from_api)
# Response from legacy we want to translate: 
# { "order_number" => 10, "order_items" => [{ order_item_id: 1, item_title: "Book" }] }

# Define mapping from legacy naming to our:
mapping = { 
  order_number: :id, 
  order_item_id: :id,  
  order_items: :items, 
  item_title: :title 
}

ResponseMapper.map(data: response, mapping: mapping)
# Nice and clean. Ready to be wrapped into Order object:
# { id: 10, items: [{ id: 1, title: "Book" }] 

It's easy to define mapping and pass it with response to ResponseMapper and it will do the job.

With nice and clean response you can wrap this result into object.

I use Dry-struct, so one possible example looks like this:

require 'response_mapper'
require 'dry-struct'

module Types
  include Dry::Types.module
end

class Order < Dry::Struct::Value
  attribute :id, Types::Strict::Int
  attribute :amount, Types::Strict::Int
end

response = { "order_number" => 1, "total_amount" => 200 }
mapping = { order_number: :id, total_amount: :amount }

order_attributes = ResponseMapper.map(data: response, mapping: mapping)

order = Order.new(order_attributes)
order.id # => 1
order.amount # => 200

It's not that hard to implement this logic and keep it in the application, but I don't like to duplicate code across different apps, so this helper works for me.

To send requests to API I have method with the following interface:

send_request(http_method, endpoint, params = {}, mapping = {})

Mapping is the last optional param. There is also a ResponseProcessor class which is responsible for error handling, parsing, and mapping, so it uses ResponseMapper and mapping param when passed.

Also, ResponseMapper symbolizes keys and performs "deep" mapping (even for nested structures).

I'm sure that many developers struggle with keeping naming consistent, especially when working with third-party APIs or microservices.

That is my way of dealing with such problem. I would like to learn from you. How do you deal with it? Do you create a full-featured anti-corruption layer? How you translate from one context to another?

Thanks for reading!

Subscribe to receive new articles. No spam. Quality content.

Comments