This article introduces some Anti-Corruption approaches to support communication between domains (business group) in the Domain-Driven Design pattern while keeping business logic between them isolated.
Assume we apply DDD (Domain-Driven Design) for an e-commerce project with the following structure:
apps
|-- api
|-- core
| |-- lib
| | |-- domains
| | | |-- oms
| | | | |-- actions
| | | | | |-- create_order_action.ex
| | | | |-- models
| | | |-- pms
| | | | |-- functions
| | | | | |-- list_product_function.ex
| | | | |-- models
| | | | | |-- product.ex
The Pms.Product schema is defined by:
defmodule Pms.Product do
schema "products" do
field :sku, :string
field :name, :string
field :type, :string
end
endThe Pms.ListProductFunction module is defined by:
defmodule Pms.ListProductFunction do
@spec perform(skus :: list(String.t())) :: list(Pms.Product.t())
def perform(skus) do
Pms.Product
|> where([product], product.sku in ^skus)
|> Repo.all()
end
endThe Oms.CreateOrderAction action is defined by:
# apps/core/lib/domains/oms/actions/create_order_action.ex
defmodule Oms.CreateOrderAction do
def perform(params) do
with {:ok, %{items: items} = raw_order} <- cast_params(params),
products <- list_products(items),
:ok <- check_products(items, products) do
end
end
@item_schema %{
product_sku: [type: :string, required: true],
product_name: [type: :string, required: true],
quantity: [type: :integer, required: true, number: [greater_than: 0]]
}
@order_schema %{
order_code: [type: :string, required: true],
items: [type: {:array, @item_schema}, required: true]
}
defp cast_params(params) do
# Casting parameters to a raw order with the order schema
end
defp list_products(items) do
skus = Enum.map(items, & &1.product_sku)
# List products from PMS
end
defp check_products(items, products) do
# Check product SKUs in items exist or not
end
defp insert_order(raw_order, products) do
# Insert the order
end
endTo list products by the list_products/1 function, in a natural way, we use the Pms.Product schema to query PMS products:
# apps/core/lib/domains/oms/actions/create_order_action.ex
defp list_products(items) do
skus = Enum.map(items, & &1.product_sku)
# List products from PMS
# Note: this call returns list of Pms.Product object
Pms.ListProductFunction.perform(skus)
endBut this method violates this DDD rule: domains should be isolated from each other so that each domain can focus on its particular business group. In this case, an OMS action call a PMS module directly, make the OMS depends on PMS.
To solve this problem, we must use Anti-Corruption method to help domains can communicate while keeping their business logic isolated.
Idea: In case domains use the same resource (for example: database, cache, etc.), and a domain must reuse codes from another domain (function, schema), we can duplicate code for the domain to reuse with limited scope (for example: fields, queries, etc.).
By using this approach, the project structure is:
apps
|-- api
|-- core
| |-- lib
| | |-- domains
| | | |-- oms
| | | | |-- actions
| | | | | |-- create_order_action.ex
| | | | |-- functions
| | | | | |-- list_product_function.ex
| | | | |-- models
| | | | | |-- oms_product.ex
| | | |-- pms
| | | | |-- functions
| | | | | |-- list_product_function.ex
| | | | |-- models
| | | | | |-- product.ex
The Oms.OmsProduct schema is defined by:
# apps/core/lib/domains/oms/models/oms_product.ex
defmodule Oms.OmsProduct do
schema "products" do
field :sku, :string
field :name, :string
# Note: OMS doen't use product type
end
endThe Oms.ListProductFunction module is defined by:
# apps/core/lib/domains/oms/functions/list_product_function.ex
defmodule Oms.ListProductFunction do
@spec perform(skus :: list(String.t())) :: list(Oms.OmsProduct.t())
def perform(skus) do
Oms.Product
|> where([product], product.sku in ^skus)
|> Repo.all()
end
endThe list_products/1 function is rewriten by:
# apps/core/lib/domains/oms/actions/create_order_action.ex
defp list_products(items) do
skus = Enum.map(items, & &1.product_sku)
# List products from PMS
# Note: this call returns list of Oms.OmsProduct object
Oms.ListProductFunction.perform(skus)
endProblem: In case the Pms.ListProductFunction module is updated with new conditions, that mean we must update Oms.ListProductFunction module (and sometimes the Oms.OmsProduct schema). For example:
Assume we list only products with their type is active in PMS, the Pms.ListProductFunction modudle is rewriten by this code:
# apps/core/lib/domains/pms/functions/list_product_function.ex
defmodule Pms.ListProductFunction do
@spec perform(skus :: list(String.t())) :: list(Pms.Product.t())
def perform(skus) do
Pms.Product
|> where(
[product],
product.sku in ^skus and product.type == ^"active"
)
|> Repo.all()
end
endWe also update both Oms.OmsProduct schema and Oms.ListProductFunction module:
# apps/core/lib/domains/oms/models/oms_product.ex
defmodule Oms.OmsProduct do
schema "products" do
field :sku, :string
field :name, :string
# Note: OMS doen't use product type
field :type, :string
end
end# apps/core/lib/domains/oms/functions/list_product_function.ex
defmodule Oms.ListProductFunction do
@spec perform(skus :: list(String.t())) :: list(Oms.OmsProduct.t())
def perform(skus) do
Oms.Product
|> where(
[product],
product.sku in ^skus and product.type == ^"active"
)
|> Repo.all()
end
endWe can see problems:
- The listing products function is rewrite 2 times, make us spend more time to maintain and develop new features by rechecking duplicated code.
Oms.OmsProductmust include unused fields to support querying products.
Idea: We build a new layer on top of domain modules to support communcating between domains.
By using this approach, the project structure is:
apps
|-- api
|-- core
| |-- config
| | |-- config.exs
| |-- lib
| | |-- di
| | | |-- oms
| | | | |-- product_implementation.ex
| | |-- domains
| | | |-- oms
| | | | |-- actions
| | | | | |-- create_order_action.ex
| | | | |-- behaviours
| | | | | |-- product_behaviour.ex
| | | |-- pms
| | | | |-- functions
| | | | | |-- list_product_function.ex
| | | | |-- models
| | | | | |-- product.ex
The Oms.ProductBehaviour is defined by the code:
# apps/core/lib/domains/oms/behaviours/product_behaviour.ex
defmodule Oms.ProductBehaviour do
@type product_type :: %{
sku: String.t(),
name: String.t()
}
@callback list_products(skus :: list(String.t())) :: list(product_type)
endThe Di.Oms.ProductImplementation is defined by the code:
# apps/core/lib/di/oms/product_implementation.ex
defmodule Di.Oms.ProductImplementation do
@behaviour Oms.ProductBehaviour
@impl true
def list_products(skus) do
products = Pms.ListProductFunction.perform(skus)
Enum.map(products, & %{sku: &1.sku, name: &1.name})
end
endWe register the Di.Oms.ProductImplementation module into the config.exs file for the OMS module:
# apps/core/config/config.exs
import Config
config :oms, :product_implementation, Di.Oms.ProductImplementationThe list_products/1 function is rewriten by:
# apps/core/lib/domains/oms/actions/create_order_action.ex
@product_impl Application.compile_env(:oms, :product_implementation)
defp list_products(items) do
skus = Enum.map(items, & &1.product_sku)
# List products from PMS
apply(@product_impl, :list_products, [skus])
endBenefits:
- The OMS module doesn't care the implementation in the PMS module for listing products.
- The listing products function is not rewritten when updating with new conditions.
The apps/core/lib/di/ directory defines the Anti-Corruption Layer (ACL): a set of modules support communicating between domains.
Suppose we have a project which has multiple application with this structure:
apps
|-- api
|-- oms
| |-- lib
| | |-- actions
| | | |-- create_order_action.ex
| | |-- models
|-- pms
| |-- lib
| | |-- functions
| | | |-- list_product_function.ex
| | |-- models
| | | |-- product.ex
|-- worker
The relationship between applications is defined is this table:
| application | layer |
|---|---|
| api | application |
| worker | application |
| oms | business logic |
| pms | business logic |
To support communicating between applications in the business logic layer, we define a new application that is called DI registration.
apps
|-- api
| |-- config
| | |-- config.exs
|-- di_registration
| |-- config
| | |-- config.exs
| |-- lib
| | |-- oms
| | | |-- product_implementation.ex
| |-- mix.exs
|-- oms
| |-- lib
| | |-- actions
| | | |-- create_order_action.ex
| | |-- behaviours
| | | |-- product_behaviour.ex
| | |-- models
|-- pms
| |-- lib
| | |-- functions
| | | |-- list_product_function.ex
| | |-- models
| | | |-- product.ex
|-- worker
| |-- config
| | |-- config.exs
| |-- mix.exs
Make the di_registration depends on OMS and PMS.
# apps/di_registration/mix.exs
defmodule DiRegistration.MixProject do
defp deps do
[
{:oms, in_umbrella: true},
{:pms, in_umbrella: true}
]
end
endThe DiRegistration.Oms.ProductImplementation is defined by the code:
# apps/di_registration/lib/oms/product_implementation.ex
defmodule DiRegistration.Oms.ProductImplementation do
@behaviour Oms.ProductBehaviour
@impl true
def list_products(skus) do
products = Pms.ListProductFunction.perform(skus)
Enum.map(products, & %{sku: &1.sku, name: &1.name})
end
endRegister the DiRegistration.Oms.ProductImplementation module:
# apps/di_registration/config/config.exs
import Config
config :oms, :product_implementation, DiRegistration.Oms.ProductImplementationMake API and Worker applications depend on di_registration application:
# apps/api/mix.exs
defmodule Api.MixProject do
defp deps do
[
{:di_registration, in_umbrella: true}
]
end
end# apps/worker/mix.exs
defmodule Worker.MixProject do
defp deps do
[
{:di_registration, in_umbrella: true}
]
end
endImport configuration from di_registration for API and Worker applications:
# apps/api/config/config.exs
import Config
import_config "../../di_registration/config/config.exs"# apps/worker/config/config.exs
import Config
import_config "../../di_registration/config/config.exs"The relationship between applications is redefined by this table:
| application | layer |
|---|---|
| api | application |
| worker | application |
| di_registration | integration |
| oms | business logic |
| pms | business logic |
https://learn.microsoft.com/en-us/azure/architecture/patterns/anti-corruption-layer https://gist.github.com/andrewhao/eb8b365066341b08240a7fae0b25f3bc https://www.thereformedprogrammer.net/evolving-modular-monoliths-3-passing-data-between-bounded-contexts/