Hexagonal Architecture

View Source

< Query System | Index | Property-Based Testing >

Hexagonal architecture (ports and adapters) separates domain logic from infrastructure by defining ports — interfaces through which components communicate. Skuld's Port system supports incremental adoption: define DoubleDown contracts, then convert components to effectful implementations at your own pace.

The four scenarios

#CallerImplementationMechanism
1Plain ElixirPlain ElixirDoubleDown.ContractFacade — config-based dispatch
2Plain ElixirEffectfulSkuld.Adapter — wraps effectful impl with stack
3EffectfulPlain ElixirPort.with_handler + :direct resolver
4EffectfulEffectfulPort.with_handler + effectful module (auto-detected)

Setting up a port

Define a contract with defcallback:

defmodule MyApp.Orders do
  use Skuld.Effects.Port.EffectfulFacade

  defcallback place_order(cart :: Cart.t()) :: {:ok, Order.t()} | {:error, term()}
  defcallback get_order(id :: String.t()) :: {:ok, Order.t()} | {:error, term()}
end

This generates effectful callers (returning computation()) and __key__ helpers for test stubs — all in one module.

Consumer side (effectful caller)

Write domain logic using the effectful facade:

defcomp checkout(cart) do
  {:ok, order} <- MyApp.Orders.place_order(cart)
  order
end

Wire the implementation at runtime:

checkout(cart)
|> Port.with_handler(%{MyApp.Orders => MyApp.Orders.Ecto})
|> Throw.with_handler()
|> Comp.run!()

Provider side (adapter)

To implement the contract in an effectful style:

defmodule MyApp.Effectful.OrderService do
  @behaviour MyApp.Orders

  defcomp place_order(cart) do
    inventory <- MyApp.Inventory.check(cart)
    {:ok, order} <- MyApp.OrderRepo.insert(cart, inventory)
    {:ok, order}
  end
end

And bridge it to plain callers with Skuld.Adapter:

defmodule MyApp.OrdersAdapter do
  use Skuld.Adapter,
    contract: MyApp.Orders,
    impl: MyApp.Effectful.OrderService,
    stack: fn comp ->
      comp
      |> Port.with_handler(%{MyApp.Inventory => MyApp.InventoryService})
      |> Port.with_handler(%{MyApp.OrderRepo => MyApp.OrderRepo.Ecto})
      |> Throw.with_handler()
    end
end

Testing

Test stubs via the facade's __key__ helpers:

responses = %{
  MyApp.Orders.__key__(:place_order, cart) => {:ok, %Order{id: "123"}}
}

checkout(cart)
|> Port.with_test_handler(responses)
|> Throw.with_handler()
|> Comp.run!()

Incremental adoption

You don't need to convert everything at once. A contract can have a plain Ecto implementation on one side and effectful code on the other. New components can be effectful from day one; existing modules can be adapted gradually through Skuld.Adapter.


< Query System | Index | Property-Based Testing >