Fl4m3Ph03n1x

Fl4m3Ph03n1x

Hexagonal architecture in elixir

Background

For the longest time now I have been playing with the idea of doing an application that follows the hexagonal architecture:

After reading several books on the matter, this looks like something that everyone should do. It is hard for me to find a compelling reason to not use it given that you are writing any code with a decent amount of complexity (don’t use it for an Hello World app, obviously).

However, ironically, I have never found, in all my life as a programmer, any project using it. In fact, none of my colleagues I have (or ever had) even knew about it.

What now?

So, now I have decided with my free time, to create a pet project where I can implement this architecture.

This project should be simple: It is a command line app, that makes HTTP requests to an external website.

There is no database, authentication, no nothing. Just invoking the app:

# makes a hello world search in google and IO.puts the result
./my_app --greet="hello world" 

So, out of the box, I know I need an adapter for an HTTP client (lets say, HTTPoison) and a JSON decoder (lets say Jason) because I want my app to be able to change between decoders.

Questions

And this is where I freeze. There is so much stuff in my head, I can’t even start.

How should I implement the port? Via an interface (module with callbacks) like Jose Valim in hix Mox article?

What should that interface be like? Have GET and POST methods or have a “greet” method?

What about my adapters?

It really feels like that although I have read extensively about the topic, I am incapable of processing the information into something useful.

A code sample would really help.

Has any of you ever did something similar to this in Elixir?

Most Liked

tomekowal

tomekowal

I had a period when I studied Hexagonal, onion and other architectures and London vs Detroit schools. I find them all different ways to achieve the same thing: split business logic from implementation details.

There are two main benefits that I see:

  • your business code usually changes more often than implementation details, so it is a less mental burden to change it when it doesn’t touch DB, HTTP params and what-not
  • if you decide to change the database (a rather rare occurrence, but sometimes scaling requires this), stuff is less tangled, and you don’t break the business logic

What they don’t tell you is how are you going to pay for it.
Doing everything via the interface is another layer of abstraction. It is also easy to miss the rule and use the direct call in the code. Elixir doesn’t have interfaces. The closest thing is behaviours. They have quite a lot of boilerplate because it is a separate module usually in a separate file.

Plus, you need another layer of configuration for specifying which implementation to use (config.exs is a bad fit for that).

That’s why I like an approach described by Rafał Studnicki in this talk: https://www.youtube.com/watch?v=XGeK9q6yjsg He splits every use case into three sets of files: Model - pure business logic, IO - external dependencies like DB, and service - which coordinates. The rules are:

Models can’t use anything in IO or Service to not pollute business logic.
IO can use Models (mainly, if you read something from DB, you translate it to Model instead of returning raw schemas)
Services call IO and Model functions and can be the place to choose proper implementation (if there is one, direct calls are sufficient).

For projects I worked on, it is the lightest approach with the least amount of trade-offs.

There are, of course, trade-offs:

  • typically, the web layer can extract information from Ecto.Schema and use it (have you wondered how Phoenix.Form decides on which form it should use PUT and POST?), if you use this approach, you need to distinguish yourself
  • you define schemas for your DB and often define an almost identical struct for your Model

Any architectural patterns can pay off in more significant projects where you often jump between implementing different parts of business logic.

As for TDD with or without mocks. The SQL.Sandbox abstraction makes running tests asynchronously and in isolation so straightforward that not using real DB in tests seems wrong. There is no downside to doing it the simple way. I only use mocks for calling external services.

Apemb

Apemb

I worked for the better part of a year on a Phoenix Application that ended up being something like a GraphQL - DDD - CQRS - Hexagonal like architecture.

It started a small innocent Phoenix HTML server but well thing got complex very fast and we had to structure it better than with contexts, plus we ended up needing a separate front-end app.

I quit last month to work elsewhere, but it was a good architecture. I now work on a node.js mess, and I regret it every day.

I do not know what exactly where I can help, but here is where the architecture ended up when I left. (the app is named Kairos)

(I did leave out some very specific and legacy stuff for clarity)

/kairos_domain  <-- where the business logic lives
	/aggregates   <-- the aggregate modules + structs per DDD teachings
	/services     <-- other domain relatated logic
/kairos_command <-- the "write" part of the application
	/commands     <-- the commands dispatched from the GraphQL mutations mostly
	/ports        <-- the interfaces for the commands to interact with dependencies
/kairos_query   <-- the "read" part of the application
	/queries      <-- the queries dispatched from the GraphQL query resolvers 
	/models       <-- read structs
/kairos_infra   <-- the infrastructure
	/repositories <-- the repositories, getting data from the database, and adapting to domain aggregates
	/tables       <-- ecto structs
	/adapters     <-- the adapter modules to go from aggregates to ecto struct and the otherway around
	/dataloaders  <-- the dataloaders to read ecto structs with caching for better request with absinthe
/kairos_web     <-- the application part
	endpoint.ex   <-- phoenix endpoint
	router.ex     <-- phoenix router
	schema.ex     <-- absinthe graphql schema
	/schema       <-- absinthe graphql types
  /adapters     <-- adapters between mutations and commands, or between query models and graphql objects

Port were implemented this way :

defmodule KairosCommand.Ports.ShiftAggregateRepository do
  defmodule Behaviour do
    alias KairosDomain.ShiftAggregate

    @type uuid :: String.t()

    @callback get(id :: uuid) ::
                {:ok, ShiftAggregate.t()}
                | {:error, {:resource_not_found, [name: :shift_aggregate, id: String.t()]}}
                | {:error, term}

    @callback save(ShiftAggregate.t()) ::
                {:ok, ShiftAggregate.t()}
                | {:error, {:validation_error, [KairosDomain.ValidationError.t()]}}
                | {:error, {:resource_not_found, [name: :shift_aggregate, id: String.t()]}}
                | {:error, term}

    @callback delete(ShiftAggregate.t()) ::
                {:ok, :deleted}
                | {:error, {:resource_not_found, [name: :shift_aggregate, id: String.t()]}}
                | {:error, term}
  end

  @behaviour Behaviour

  @impl_module Application.get_env(
                 :kairos,
                 :shift_aggregate_repository,
                 KairosInfra.ShiftAggregateRepository
               )

  @impl Behaviour
  defdelegate get(id), to: @impl_module

  @impl Behaviour
  defdelegate save(shift_aggregate), to: @impl_module

  @impl Behaviour
  defdelegate delete(shift_aggregate), to: @impl_module
end

For the tests, in the test config you can specify a mock module (using mox for example, we used a fork of mox named erzats for the job) to use only unit tests. It simlifies immensly your life when your database model is quite complexe as was ours. You can than return whatever you want from the repositories, and not having to insert tens of ecto structs in the right order.

We did nice things with the command module, structs that represented the command, and a protocol implementation for the command handler. But it is a bit out of scope.

Feel free to ask any questions you want. I do not know to help you on your discovery, but would love to help :slight_smile:

Apemb

Apemb

The fact is we did not indent from the start to use Commands and Queries, but as we used GraphQL, it kinda appeared naturally.
GraphQL mutations are natural commands. It added a lot of clarity for us to separate mutation/command from queries. Because for the query part we had to use the dataloader lib, which has a very specific way of working.

This is the protocol used to dispatch the commands :

defprotocol KairosCommand do
  @moduledoc """
  KairosCommand is the modules in which the usecase / commands that define your applications
  actions are.

  All Commands should implement this protocol to ease the call and the formatting of the parameters
  """

  alias KairosCommand.Context

  @type result :: term
  @type reason :: term

  @doc "modify the builder data with the opts (keyword list of the params)"
  @spec run(KairosCommand.t(), Context.t()) ::
          {:ok, result}
          | {:error, :forbidden}
          | {:error, :unauthorized}
          | {:error, {:validation_error, [KairosDomain.ValidationError.t()]}}
          | {:error, {:impossible_action, reason}}
          | {:error, {:argument_missing, missing_argument :: atom}}
          | {:error, {:resource_not_found, [name: atom, id: String.t()]}}
          | {:error, term}
  def run(command_data, command_context)
end


And here is the command definition (one of the most simple command we have) and the protocol implementation for the command handler.

defmodule KairosCommand.DeleteOneShift do
  alias KairosCommand.DeleteOneShift

  @type uuid() :: String.t()

  @type t() :: %DeleteOneShift{id: uuid() | nil}

  defstruct [
    :id
  ]
end

defimpl KairosCommand, for: KairosCommand.DeleteOneShift do
  alias KairosCommand.Policy
  alias KairosCommand.Context
  alias KairosCommand.DeleteOneShift
  alias KairosCommand.Ports.ShiftAggregateRepository

  @spec run(DeleteOneShift.t(), Context.t()) ::
          {:ok, :deleted}
          | {:error, :forbidden}
          | {:error, :unauthorized}
          | {:error, {:argument_missing, :id}}
          | {:error, {:resource_not_found, [name: atom, id: String.t()]}}
          | {:error, term}
  def run(%DeleteOneShift{id: shift_id}, ctx) do
    data = %{shift_id: shift_id}

    data
    |> Chain.new()
    |> Chain.next(&verify_is_authorized(&1, ctx))
    |> Chain.next(&load_shift_aggregate/1)
    |> Chain.next(&ShiftAggregateRepository.delete(&1.shift_aggregate))
    |> Chain.run()
  end
  
  ... (private functions are missing for clarity)
end

The mutation resolver creates the command and the context (with user permissions mainly) and calls KairosCommand.run(command, context)

Having one way to call commands and a standard response helped us mutualise and standardise the mutation resolver logic, and thus clarify the web/http part of the application. Quite a bit of plumbing in the end, but it happened over the course of 6 months, if we had to recreate that plumbing from scratch that would suck.
(Error handling, especially the form errors were automatically filled from the domain logic, translated and filled in the react form. Quite satisfying. Quite a bit of work for something Rails or Phoenix give for free… But Ecto.Changesets caused quite a bit of headaches so happy it ended up only in the infra, and business rules validations are free from it)

Last Post!

Apemb

Apemb

Yes our ports return only KairosDomain plain structs. Structs that are some quite similar, some loosely and some quite different from the schema.

All the reste of the functions inside the command modules and domain modules do as if saving those struct was easy, use the correct port, and all is well.

The structs that end with Table are Ecto Structs, those with Entities are Domain Struct. (Our naming is like, Entites are readonly struct, Aggregates are the state-modifier structs)

# From KairosInfra.RecurringShiftEntityAdapter module

  alias KairosDomain.RecurringShiftEntity
  alias KairosInfra.RecurringShiftTable

  def from_recurring_shift_table(%RecurringShiftTable{} = recurring_shift_table) do
    %RecurringShiftEntity{
      id: recurring_shift_table.id,
      last_shift_generation_date: recurring_shift_table.last_shift_generation_date,
      timezone: recurring_shift_table.shift_service_request.warehouse.timezone,
      recurrence_pattern: adapt_to_recurrence_pattern(recurring_shift_table),
      recurring_template: adapt_to_recurring_template(recurring_shift_table)
    }
  end
# from KairosInfra.RecurringShiftEntityRepository

  def get(id) do
    result =
      RecurringShiftTable
      |> where(id: ^id)
      |> preload(
        shift_service_request: [warehouse: []],
        recurring_provider_assignments: []
      )
      |> Kairos.Repo.one()

    case result do
      nil ->
        {:error, {:resource_not_found, [name: :recurring_shift_entity, id: id]}}

      recurring_shift_table ->
        {:ok, RecurringShiftEntityAdapter.from_recurring_shift_table(recurring_shift_table)}
    end
  end

Those are one example with a loosely similar domain struct from ecto struct. Yes there is an adapter, yes the repository is not a plain Ecto function, but we gain a better error message, and writing the whole thing is like 5 min testing included. The longest part is creating the base data for the test factories. The rest is copy paste.

Why we did that on that particular struct, is more about consistency. We needed Ecto separation for one, we decided that it would be the way for all. (But as we refactored stuffed as we needed, I guess some stuff will never migrate as they never will be modified again…)

Is that needed for every project, no indeed, I do not think so either. In the startup I am working with right now, we have three different backends, and I see a moderate benefit in changing the architecture for one part of one of the three. What I am hopping is extracting that part into its separate app and going hexagonal on that one. And that isn’t very high on my to-do list :wink:

Hexagonal and clean archi are a nice clean way of organising code, extensible and easily maintainable, but expensive to migrate and in my opinion, useful only on a long and big project, or for complex business critical applications.

Where Next?

Popular in Questions Top

RisingFromAshes
I’ve read in another post that it may be possible with a router helper - but I couldn’t find an appropriate one, and tbh, I’m still just ...
New
Brian
What is the proper way to load a module from a file in to IEX? In the python world, doing something like this pretty standard: from ....
New
ovidiubadita
Hey all, I discovered Elixir and I love it. I always wanted to learn a functional programming and I intended to go for Haskell, but afte...
New
openscript
Hello! Sorry for this astonishing simple question, but I’m really stuck. I try to set up the intellij-elixir plugin, but I don’t know ho...
New
stefanluptak
Hello everybody, usually, I use a 29" ultra-wide monitor for VSCode which can easily accomodate explorer (files panel) + file with code ...
New
vrod
I am using the Starship cross-shell prompt – it seems pretty nice, but I get some errors: [WARN] - (starship::utils): Executing command ...
New
senggen
Erlang/OTP 25 [erts-13.2.2] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] 15:22:35.803 [error] gen_event {lager_file_backend...
New

Other popular topics Top

jononomo
I am trying to figure out how Mix knows whether the environment is test, dev, or prod – where is this set? Thanks.
New
joeerl
Hello again - after a longish gap I’ve decided I really must dig into Elixir and see what’s been happening here - so I have a few questio...
New
saif
Hello everyone, Long time lurker first time poster here. I’ve recently begun working on Elixir full-time again! :raised_hands: It’s been...
New
SoCreat
i’m a new one to elixir which editor can i use vs code? or atom? Thanks! :smiley:
New
JorisKok
I have a server on AWS, and was running a load test using artillery. When looking at the Phoenix dashboard I see the Ports going to 100% ...
New
Harrisonl
We have an ECS cluster with 4 services, where each task joins a single cluster, via discovery ECS discovery service. Currently when I de...
New

We're in Beta

About us Mission Statement