Ports and Adapters Architecture for Functional Programmers (with Clojure)
the small codebases of microservices provide great refuge for the salaried functional programmer.
Back in the day
When I started in this industry, the architecture 'all the rage' was microservices. Clojure lends itself nicely to microservices. Some developers struggle in large Clojure codebases, so the small codebases of microservices provide great refuge for the salaried functional programmer. However, the many small codebases create engineering complexity through multiple teams and services. That complexity comes at a hefty cost for software engineering budgets, so microservices aren't exactly a great approach if you have three users.
I've done the startup thing before too. After leaving the microservices place, I got a Clojure gig at a place with a Service-Oriented Architecture (SOA). Great company, but developing with their codebase felt like nails on a chalkboard because they had a monorepo with stuff all over the place. Unsurprisingly, these are the kind of companies I suspect want to move away from functional programming languages once they're no longer a fun toy to play with.
When they started looking at other options, I learned about Hexagonal Architecture also known as Ports and Adapters Architecture. Ports and Adapters Architecture decouples system inputs, business logic, and system outputs by separating them into distinct domains. If that sounds like a three-tiered architecture to you, you're not too far off, except the 'tiers' would be further broken down by domain into 'sub-tiers'. Specifically, our sub-tiers can be thought of as the adapters in this metaphor. Adapters literally meaning adapter(ish) pattern. Adapters reify the interface of a port for the benefit of the of the system inputs or outputs. Traditionally, ports are interfaces. So in Clojure speak, they could be protocols.
P&A
If you see a protocol and wince, I don't blame you. Protocols can introduce rigidity to your design, and they're tough to design like any bag of function signatures. They remind functional programmers of being in the trenches of the brittle old Object-Oriented Design. But what if we could deconstruct a protocol? If it really is just a bag of function signatures, we'd be able to decouple our domains by using functions as arguments.
In the above example, our handler factory ->handler
closes over a function called biz-logic-fn
. biz-logic-fn
gives us the smallest example of a port: a function that takes a string input
and returns a string. The actual business logic may neither take a string, nor return a string, so we must convert our "interface" to suit our needs. Lucky for us, we have just the thing: our process-string-adapter
.
process-string-adapter
will make good on our promise to the handler-fn
. Since process-string-adapter
calls a business logic function, we can call it a driving adapter, as in, it's driving the process forward. Driving adapters wrap our business logic "interface"(functions) for the system inputs. The business logic here lives in our process-string
function which gets called by process-string-adapter
(I'm aware process-string
does in fact take a string and return a string, it's a contrived example, stay with me here).
process-string
has it's own port called logging-fn
. logging-fn
takes a map and returns nothing, presumably for side-effects. The adapter we supply for our logging-fn
port comes in the form of capture-exception-adapter
which wraps the "interface" provided by the sentry-clj library. We can call capture-exception-adapter
a driven adapter, as in, it's being driven by the process. Driven adapters wrap the "interfaces"(functions) provided by our external services and libraries.
Other Methods
Great, so we can deconstruct protocols into higher-order functions. Instead of passing a list of functions in lieu of protocols, a more realistic alternative to this might be multimethods. It's a rather trivial exercise to replace our functions with multimethods in the example above.
With multimethods we can dispatch on anything, but the better approach might be to dispatch on data. In fact, let's borrow a concept from Onion Architecture: the domain model. Consider a software-as-a-Service solution (SaaS) where a user makes a request to view her business's EBITDA . Her identity, our domain model, is built by the business logic using joins on the users table through an adapter, of course.
Now we can create a multimethod called gross-profit
that dispatches on the value :accounting-provider
. Dispatch allows the business logic to only care about gross profit for calculating EBITDA provided it has a valid identity. The business logic could conceivably pull gross profit data from any accounting integration added to the system. The multimethod definition gives us a port, and every method creates an adapter for each integration.
So far all of these examples live in a single process, but we've created a pathway to a strangler pattern where we can easily strip out our dependencies into separate processes if we so choose.