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
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
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 ![]()
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
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 ![]()
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.
Popular in Questions
Other popular topics
Categories:
Sub Categories:
Forums
Popular Tags
- #ecto
- #liveview
- #troubleshooting
- #learning-elixir
- #deployment
- #library
- #erlang
- #testing
- #genserver
- #mix
- #absinthe
- #remote-other
- #otp
- #plug
- #how-to-question
- #macros
- #postgres
- #channels
- #elixirconf
- #exunit
- #discussion
- #code-sync
- #javascript
- #podcasts
- #onsite
- #dialyzer
- #docker
- #authentication
- #umbrella
- #full-time-contract
- #podcasts-by-brainlid
- #ecto-query
- #elixir-ls
- #phoenix_html
- #iex
- #blog-post
- #graphql
- #genstage
- #ai
- #websockets
- #supervisor
- #elixirconf-us
- #advent-of-code
- #distillery
- #processes
- #api
- #forms
- #metaprogramming
- #security
- #hex









