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
end
The 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
end
The 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
end
To 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)
end
But 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
end
The 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
end
The 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)
end
Problem: 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
end
We 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
end
We 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.OmsProduct
must 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)
end
The 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
end
We 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.ProductImplementation
The 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])
end
Benefits:
- 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
end
The 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
end
Register the DiRegistration.Oms.ProductImplementation
module:
# apps/di_registration/config/config.exs
import Config
config :oms, :product_implementation, DiRegistration.Oms.ProductImplementation
Make 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
end
Import 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/