Hexagonal Architecture + Rails
Last night, Fito and I watched Alistair Cockburn's Hexagonal Architecture talk from the Tech Excellence Conference. We really enjoyed it, and despite thinking I already understood the pattern, I learned a ton. In fact, before you continue reading, you should go watch it. Go ahead! I'll wait...
Awesome!
So, when I first started learning the Ports & Adapters pattern, I found it confusing. Eventually, I thought I'd made sense of it, but I was over complicating it. And, for some reason, it never clicked with me that the term "adapter" was a direct reference to the Gang of Four Adapter Pattern. So, seeing the pattern represented so simply in code was a real face-palm moment for me. And, it was the key to unlocking my understanding of the pattern.
For those of you who skipped the video, why did you do that? Go watch it!
The Ports & Adapters Pattern
Ok, now that everyone has seen the video, here's a quick review of the Ports & Adapters Pattern:
Your application code — your business or domain logic, whatever you call it — sits inside the hexagon and provides "ports" with which the outside world can connect. In a statically typed language, these ports are interfaces. In Ruby, they're just the public API of the classes in your application code (inbound), or the method calls your application expects of a service (outbound).
On the left side of the diagram are "inbound" or "driving" ports. This is how the outside world interacts with your application. It is your Application Programming Interface (API). It is up to your application code to define this interface.
On the right side of the diagram are "outbound" or "driven" ports. This is how your application interacts with services available outside your application. This is your Service Programming Interface (SPI). And, it is also up to your application code to define this interface as well, preferably in the ubiquitous language of the domain.
That's the "ports" half of the pattern. The adapters are classes that sit between the outside world and your API and SPI. For example, you may choose to use a web framework as a means of adapting HTTP requests to your API. Or, you may choose to use the Repository Pattern to provide a domain-centric SPI between you and your data store(s).
So, for example, rather than calling Task.create
on a ActiveRecord model called Task
, you could call a persistTask
method on a TaskRepository
class that then translates that into the ORM syntax. This keeps your application blissfully ignorant of the technology used for persisting data.
Why would you want to do that? Well, it allows you to swap in different technologies for testing. Or, for long-lived applications, it allows you to swap out older technologies for newer, superior technologies over time without causing you to alter your business logic every time.
Here's an example: your logging mechanism may write files to disk. But, maybe, with the advent of tools like Kafka, or the requirement to run in Kubernetes, it might make sense to change out that mechanism. If your application is tightly coupled to your logging mechanism, you'll have to modify the application code to introduce the new technology.
Or, maybe you just switched your email vendor from Sendgrid to Mailchimp. If your application code is calling Sendgrid directly, you'll need to go edit it. But, if you introduce an adapter between your application code and the Sendgrid API, then all you need to do is write a new adapter for Mailchimp.
Ports & Adapters + Rails
Rails is super opinionated. And, at first glance, it may not seem compatible with Hexagonal Architecture. But, if you look more closely, it becomes clear that Rails controllers could be considered HTTP adapters for your application's API. They receive HTTP requests, make calls to your application, then format the result as an HTTP response. That's an adapter. (Though it does require you to forgo the traditional "call the model from the controller" approach to writing Rails applications.)
ActiveRecord is a bit of a different story. You could think of it as an adapter for your database. But, it's not. An adapter is not in control of the interface on either side. It simply implements the interfaces that the application and the service provide via their SPIs and APIs, respectively. But, ActiveRecord doesn't implement your application's SPI. Rather, it provides its own interface in the shape of create
, find
, update
, delete
, etc.
So, if your application is making ActiveRecord calls directly, you've coupled yourself to that interface. Now, if ActiveRecord's syntax ever changes (which it has), or if you decide to use a new technology that isn't supported by Rails, you'll have to modify your application code. And, as you know if you've read Fito's Extension without Modification article, we prefer not to modify existing code if at all possible.
If I were to redraw the diagram above with Rails in mind, it might look like this:
Note that tests don't require an adapter. They can drive your API directly. Also note that different services may be configured in different environments. Those could be mocks or even vendor supplied sandbox environments.
Final Thoughts
Ok. Let's wrap up...
Ports & Adapters is a simple pattern for decoupling business logic from I/O. It requires the "application" (business / domain logic) to provide both an inbound ("driving") API and an outbound ("driven") SPI. Plus, it is well suited to what we call Inside Out Development, where you TDD the use cases in the application first, then select (or build) a framework to help your application interact with the outside world.
It is completely possible for Rails to act as that framework. But, not if you're tied to doing things The Rails Way™. For example, using helpers like form_for
couples your view to your model, completely bypassing your application logic. Doing so means that you're tying your view to ActiveRecord. That's the same thing as calling Task.create
from inside your application code. All requests for data MUST go through the data store adapter (repository) in order to remain decoupled.
For many Rails developers, this is a step too far. They're bought into the concept that you don't need to separate your business logic from your I/O. And, as their applications grow, and as their requirements change over time, they will gradually stop thinking of their Rails project as the fast, shiny Rails app that it once was, and start thinking of it as legacy code. They'll complain about the legacy code they have to deal with. And, they'll completely forgive Rails for leading them there.
Having written both traditional Rails apps and systems that use the Ports & Adapters Pattern, I can tell you first hand that the water is better in the hexagon!
No spam, no sharing to third party. Only you and me.
Member discussion