Ports and Adapters Architecture for Functional Programmers (with Clojure)

the small codebases of microservices provide great refuge for the salaried functional programmer.

Ports and Adapters Architecture for Functional Programmers (with Clojure)
The Adapter Rodeo

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.

;; Driven adapter for Sentry satistfies the "interface"
;; or port that `process-string` expects. In this case the
;; signature for logging-fn is:
;; (Throwable) => nil
;;
;; `capture-exception-adapter` function is adapting
;; the "interface" provided by the sentry-clj lib.
(defn capture-exception-adapter
  [exception]
  (-> exception
      (Throwable->map)
      (sentry/send-event)))

;; Core business logic expects the `logging-fn`
;; interface/port, presumably for side-effects,
;; of the function signature (Throwable) => nil
(defn process-string
  [logging-fn input]
  (try
    (apply str (reverse input))
    (catch Exception e
      (logging-fn e)
      (throw e))))

;; Our business logic Driving adapter. Here it
;; composes our business logic with the driven
;; adapter, but that doesn't necessarily need to be
;; the case. The important thing is to decouple
;; the business logic from the input(s).
(defn process-string-adapter
  [input]
  (process-string capture-exception-adapter input))

;; API handler expects an "interface" or port
;; `biz-logic-fn` that takes a string and
;; returns a string.
(defn ->handler
  [biz-logic-fn]
  (fn handler-fn
    [request]
    (let [input (get-in request [:params :input])]
      {:status 200
       :headers {"Content-Type" "application/json"}
       :body (str "{\"result\": \"" (biz-logic-fn input) "\"}")})))

;; Here at the system boundary with either inject or close
;; over our adapters
(let [handler (->handler process-string-adapter)]
  (defroutes app-routes
    (GET "/process/:input" request (handler request))
    (route/not-found "Not Found")))

(def app
  (wrap-defaults app-routes site-defaults))
A toy Ports and Adapters webserver

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.

(defmulti log-exception class)

(defmethod log-exception Throwable
  [exception]
  (-> exception
      Throwable->map
      sentry/send-event))

(defmulti process-string class)

(defmethod process-string String
  [input]
  (try
    (apply str (reverse input))
    (catch Exception e
      (log-exception e)
      (throw e))))

(defn handler
  [request]
  (let [input (get-in request [:params :input])]
    {:status 200
     :headers {"Content-Type" "application/json"}
     :body (str "{\"result\": \"" (process-string input) "\"}")}))

(defroutes app-routes
  (GET "/process/:input" request (handlerrequest))
  (route/not-found "Not Found"))
Contrived examples are great until people point out it's unrealistic

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.

{:email "jane.doe@example.com"
 :user-id 1
 :oauth-user false
 :accounting-provider "quickbooks"
 :etc "..."}
Hopefully a less contrived example

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.

(ns best.ebitda.app.gross-profit)

(defmulti gross-profit-for-user
  "A port for getting the gross-profit for a user model.
  Takes a `user-profile` and returns a gross-profit bigdec
  for that user."
  (fn [user-profile]
    (keyword (:provider user-profile))))

(defmethod gross-profit-for-user :default
  [user-profile] 0M)
  
;; in another namespace
(defmethod gross-profit/gross-profit-for-user :quickbooks
  [user-profile]
  (let [{:keys [user-id]} user-profile]
    (some->> user-profile
             :user-id
             (get-quickbooks-account-id)
             (get-quickbooks-gross-profit user-id))))
No wincing here

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.

Thanks for reading. Subscribe for the next one or follow me on twitter @janetacarr , or don't ¯\_(ツ)_/¯ . You can also join the discussion about this post on twitter, hackernews, or reddit if you think I'm wrong.

Subscribe to Janet A. Carr

Sign up now to get access to the library of members-only issues.
Jamie Larson
Subscribe