Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save khanha2/d364bb234252b27847353d9d84380438 to your computer and use it in GitHub Desktop.
Save khanha2/d364bb234252b27847353d9d84380438 to your computer and use it in GitHub Desktop.
Apply Anti-Corruption for Domain-Driven Design in Elixir

Apply Anti-Corruption for Domain-Driven Design in Elixir

Abstract

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.

Overview

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.

Approach 1: Modules duplication

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.

Approach 2: Dependency injection

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.

Apply dependency injection for commuicating between multiple applications

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

Reference

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/

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment