Well-crafted functional architecture: ports and adapters
At Salam.io we are developing a modern social platform containing a humongous amount of features.
Development of such product is quite hard and challenging - we need our software to be robust, scalable, fault-tolerant, performant and at the same time we want it to be easy to extend, test, maintain and support.
All these issues are inevitable upon growth of the app but they for sure can be simplified or even avoided by choosing, optimising the software architecture.
We also use functional programming languages - Clojure & Elixir - for the backend and frontend as much as possible. Therefore, we need to adjust the existing architecture approaches to powerful abilities and intricacies of modern functional programming.
That’s why we’re starting these series of articles - we think it’s crucial for everyone who’s crafting the functional systems to understand and apply software architectures rules and principles.
1 Why ports & adapters ?
Even if you’re developing a relatively small scale software you still need to design it first - and to design it properly. The earlier you start caring about your architecture the earlier you can benefit from it and the later a lot of issues caused by bad architecture would appear.
The main idea of ports & adapters architecture is that application that you’re building is a closed area. This means that all your business logic should be separated from technical details in this area. Often architecture is about the boundaries so are the ports & adapters.
In case you stick with ports & adapters from the very beginning then this approach should help you to keep your business logic separated and easily tested s well as technology agnostic - you can write a port & an adapter for any software/third-party service/library that you’re using so it can be easily extended or switched in favour of another one.
2 Hexagonal
Ports & adapters architecture also has another name: Hexagonal architecture. According to this terminology the inner part of your software - the place where you put your business logic - is hexagon while your adapters are placed surround it.
The hexagon should not contain any references to another frameworks, real world services, libraries, etc. - all these elements should be adapters. At the same time the architecture doesn’t prescribe you to design your hexagon in some certain way - you can use Layer architecture, Onion, DDD or any another suitable architecture inside or it may be a pure business logic without any sophistications - it’s up to you.
Why hexagon? Well, any geometric figure with boundaries could work, but the hexagon represents better the concept that you have ports at the edges of your application and adapters behind it. Likewise, it’s a symmetric figure and we’ll describe below why it’s important.
3 Ports
Every time you need to interact with something from beyond of your application logic you need to group these actions and describe them in a port. The port is the edge of hexagon and it should be an integral and essential part of your application.
Naming of the ports is quite important - you shouldn’t use any technology name in your port but focus on its mission instead. Some of examples:
- PushNotifications
- Search
- Persistence
- Authentication
The majority of programming languages usually contain interfaces / protocols feature allowing you to build a port. In Clojure, for example, you can use multimethods or protocols to achieve this goal. But for now let’s see how we can implement the realisation of port for Elixir using its’ capability to create behaviours:
defmodule Core.PushNotifications do @moduledoc """ Port for sending push notifications. """ @type message :: %{title: String.t(), body: String.t()} @type payload :: Keyword.t @type recipients :: [map] @adapter :core |> Application.fetch_env!(__MODULE__) |> Keyword.fetch!(:adapter) @callback send_notifications(message, recipients, payload) :: {:ok, [map]} | {:error, any} defdelegate send_notifications(message, recipients, payload), to: @adapter end
The example above is nothing more than an abstraction for using push
notifications from Core
. We declare the behaviour and one callback
that specifies what we send and what we can expect as the result. The
exact implementation - adapter - should be placed in your app
configuration like:
config :core, Core.PushNotifications, adapter: PushNotifications.APNS
If you want to call this port from your application you just need to use the delegated function:
defmodule Core do alias Core.PushNotifications def register_user(params) do # business logic ... result = PushNotifications.send_notifications(message, recipients, payload) # handle the result somehow end end
As you can see, from the Core
we know nothing about the implementation
details - we just send notifications to users and that’s it. In ideal
case we need to move any impure function, any side-effect to the edge
of the system - to adapters and call them only by using ports .
4 Driver Adapters
Adapters are components which are placed outside of your application - and your hexagon. They should represent the technology, service, library that you need to interact through the port.
We specify two types of adapters: Driver and Driven.
The first ones are something from the left side of the picture above. It could be a HTML page, API endpoint, CLI application, GUI or anything that drives your application. That also means that the driver adapter should use a driver port interface so your app receives technology agnostic request on its borders.
Let’s assume that we also have a web application that uses our Core.
If we want to register user then we need to call a
Core.register_user/1
function from inside of our controller. In that
case UserController
is our driver adapter and Core
is the called
application. Fortunately, in Elixir we have type specs that can play a
role of specification of driver port so you’ll always be able to see
what we need to send and what we should expect in response.
defmodule Web.UserController do use Web, :controller def create(conn, params) do result = Core.register_user(params) # will create user and send notifications # handle the result somehow end end
In the approach above you can see that we use Core.register_user/1
function as the driver port - because it’s spec describes the
interface - and Web.UserController.index/2
as the driver adapter.
5 Driven Adapters
A Driven adapter implements an interface given by driven port. That means that now driven adapter depends on our application, but not visa versa. The same as driver, this adapter should also be placed outside of our hexagon and represents a technology/library/real-world device.
Common examples are:
- Persistence adapters - SQL, NoSQL databases or even in-memory / file storage
- Cache adapters - Redis / Memcached / ETS or in-memory storage
- Email adapters - SMTP or third-party services
- Message queue adapters
- Third-party APIs
Let’s continue the push notifications solution we’ve started before.
Now, in order to implement the driver adapter, we need to use the port
Core.PushNotifications
and it’s callback send_notifications
. We will
adapt realisation of sending push notifications over APNS by the
specification that was given us by this port:
defmodule PushNotifications.APNS do @moduledoc "APNS adapter for push notifications" @behaviour Core.PushNotifications @impl true def send_notifications(message, recipients, payload) do {:ok, recipients |> Enum.map(fn r -> build_notification(message, r, payload) end) |> Pigeon.APNS.Notification.push()} end defp build_notification(message, recipient, payload) do Pigeon.APNS.Notification.new(message, recipient.device_token, payload) end end
Now our push notifications are almost completed. We can always change the implementation - for example, from APNS to Firebase - or use third-party library * without changing our core application* - so we can say that’s technology agnostic approach.
6 Testing
Of course the main benefit of ports and adapters architecture is
improved testability. Instead of manually mocking calls to the
real-world providers we just need to create a test adapter that we
satisfy testing conditions. In the perfect case every driven adapter
should have a test analogue as well as all behaviours of driver ports
should be tested. Let’s write a test adapter for the PushNotifications
port then:
defmodule PushNotifications.TestAdapter do @moduledoc "Test adapter for push notifications" @behaviour Core.PushNotifications @impl true def send_notifications(message, recipients, payload) do {:ok, [%{message: message, payload: payload, recipients: recipients}]} end end
As you can see we are not sending data to the outer world but use a pure
function instead. In case of any incoming input we will know its’ output
for sure. Now, when we unit-test the Core
module we just need to
select test adapter as the implementation of PushNotifications
interface. In Elixir ecosystem we have a great library called Mox
that
can be used for such case:
Mox.defmock(PushNotifications.TestMock, for: Core.PushNotifications) defmodule CoreTest do use Core.DataCase, async: true import Mox # Make sure mocks are verified when the test exits setup :verify_on_exit! test "register/1" do stub_with(PushNotifications.TestMock, PushNotifications.TestAdapter) assert {:ok, _} = Core.register_user(some_params) end end
In this example you can see that we’re not sending push notifications in the real world but using the local test mock instead. We are free to change the test adapter for any testing purposes if we want to.
From now you get your driver port’s behaviour tested. As the next step you can test exactly the adapter implementation without any outside logic attached - you just need to check that your implementation is working fine as it was predicted. As for the integration testing you’re free to choose between the real-world adapters or you may use some test adapters for this purpose - it’s up to you.
7 Pros vs Cons
Now we’ve covered the basics of ports and adapters architecture. Let’s summarise what we have:
7.1 Pros
- Testability
- Replaceability
- Technology-agnostic approach - you can delay technological solutions
- Isolating pure code from impure code
- Isolating side-effects
- Maintainability
7.2 Cons
- Sometimes it may be an overhead, especially for a small scale software
- You may not need it if you are pretty sure that the technology stack of your project will remain the same over the years
8 Conclusion
We applied ports & adapters architecture at Salam.io when it became clear that our software will be using a lot of services which could be replaced in the future. This approach has already given a lot of benefits and allowed us to make our software even more testable and flexible.
If you want to know more about this architecture you can take a look at the original article by Alistair Cockburn .
In the next article of this series we will show how you can apply ports & adapters architecture in Clojure by using its language tools and component libraries.
Stay tuned!