Hexatetrahedral Rails
Software is a creative endeavor and a craft. And like any creative endeavor and any craft, it is subject to fashions. About a decade ago, one of those fashions was Hexagonal Rails largely inspired by the DDD book, but also by the original Hexagonal Architecture work by Dr. Cockburn.
Some of these applications are now up for their Rails upgrade and an “oil change,” and it’s interesting to see them in the wild and how they get perceived through the lens of the years that have gone by since then. I call them “hexatetrahedral Rails applications” - in jest, of course - because they often end up presenting complexities that go beyond the intended benefits, sometimes becoming what I’d describe as complications or even complicationments.
And while I appreciate the good intentions behind this approach, I’ve found myself questioning whether the benefits outweigh the costs in most cases. So I felt that - at the very least - I want to suss out why it is valuable, but also - figure out how I define/detect those apps in the wild, and how to understand their raison d’être well. Not to be snarky - but to look for the nuggets of wisdom in there which can be useful for us, today.
Why the vitriol?
I use this term from time to time because I’ve observed teams struggling with the maintenance and evolution of applications architected this way. While most “adult” Rails codebases (anywhere from 7 to 18 years of age) tend to be messy, that mess usually is within the confines of the knowable and well-discovered Rails builtins - ActiveRecord, ERB, controllers, helpers, the works.
Hexatetrahedral apps, in contrast, present a different paradigm which is usually (not always) supplemented by a large amount of non-default tooling. That tooling also needs updating. It needs to be understood. It is often coming from a single person who might have either abandoned it, or decided to redo it nearly from scratch. The burden of maintaining those “divergent” dependencies is much higher than “just updating the Rails app.”
And all of that also comes with a lot of dogma, usually:
No, you shall not do a
User.where
directly, you have to do aUsersRepository.find_all_with_email(...)
.
That setup often adds indirections that don’t necessarily improve the expressiveness of the codebase, while making changes more complex - because, for every change, the engineer needs to decide whether they will be dropping this “repository” or “domain design” approach and just hit the ActiveRecord subclasses directly - or keep up the approach of going through “the published pathways.”
The Original Premise
It is actually very instructive to try and understand the hexagonal architecture proposition – the original one. Alastair Cockburn, the author of the architecture, actually recently did a video presentation that is worth a watch – if you have touched a hexatetrahedral Rails application recently you will realize that this is not what the architecture implies.
The original premise is actually quite sound, and shares similarities with other well-regarded layering propositions such as “functional core, imperative shell” and is based on composition of modules over small interfaces. But to have the luxuries of that composition it is hardly necessary to subscribe to the entire bill of materials thought customary for a “Hexagonal Rails” setup.
Why It Kind Of Fails
And the arguments for doing a “hexagonal architecture” inside Rails quickly get thin. “Decouple from Rails”? Your app is a Rails app. Why would you want to “decouple” from Rails if the main delivery vehicle of your app is… the web and Rails? Ok, I did do one app that had even a modicum of success and that had CLI and web frontends, whereby in both the “meat” of the application was being exercised fully - that was tracksperanto. But other use cases are quite thin in the wild. A GUI version of your app? If you already have a Ruby runtime in place and you already need most of the dependencies - you can run a script that wraps a Rails Executor around most key mutating actions and you are good to go.
And that’s even mostly ignoring the fact that the prevalent majority of native GUI toolkit bindings for Ruby are abysmally bad and not getting better, except for calling Java UI libraries from jRuby. CLI for a point-of-sales terminal or something similar? You can let it talk to your application over what’s called a “driving adapter” in the terminology of Dr. Cockburn, but you don’t need to “decouple from Rails.” Most of the conversations I’ve had with people about this kind of boiled down to a circular argument, namely: “we need to decouple from Rails to be less coupled to Rails.” Why the said decoupling was so essential - I rarely could decipher.
Now, it is a very valid desire to have this decoupling if your deliverable is, in itself, a library-like thing that you want to make embeddable in other applications. But if that’s the goal - there are less rigid architectures which allow you the same. Make it a duo of two files (module + test) that can be pasted into an application. Make it a gem. Make it a Rails engine, if you know you would always be delivering into Rails apps.
The idea of that architecture is actually very sane and appropriate: it tells you to create thin interfaces between important components, and then use those interfaces for polymorphism. And it works great when components are swappable, and swappable for a reason. The tax calculation engines depending on the country is a good one, but other repository scenarios are also very valid provided you need multiple repositories. Do you really need multiple UserRepository
providers? Will you really be storing your user data for the same person@domain.com
both in one repository and the other? Do you have a sensible strategy for deciding which repository wins?..
Another challenge I’ve encountered is that proponents of the architecture sometimes prescribe it without fully addressing questions about its practical benefits - specifically for Rails. Let’s just check out a couple of such recommendations.
Hexagonal Rails: Escape the Framework Trap boasts that the architecture will help you should the 3 following disasters suddenly hit:
- You need to switch payment providers (hello, 3-month rewrite).
- You want to test business logic without hitting the database.
- Your new CTO mandates GraphQL (Rails views become tech debt).
If you need to switch payment providers, you will switch payment providers. Why this has to be a 3-month rewrite is beyond me. Moreover - payment providers these days have OAuth or other flows that will hit your app from the outside using OAuth callbacks, or webhooks, or similar - it is not the question of calling a method. Testing business logic without hitting the database - if your database fits in memory and your disk can be just as fast as memory why would you do that? And if your new CTO mandates GraphQL - it is not your usage of ActiveRecord that is going to be the problem, or your “coupling to Rails.” Leaving the utility of GraphQL for most apps out of the picture - should you end up in a need to reprofile to GraphQL as delivery mechanism, your view layer is going to be thrown away and redone regardless. And, if anything, there will be not less ActiveRecord “touching” - but way more, since doing GraphQL prefetching effectively needs to operate much closer to the data storage APIs than a “list or single item” RESTful solution.
Unlocking the Power of Hexagonal Architecture in Rails Development suggests that
While it is possible to implement Hexagonal Architecture within Rails, it requires a departure from conventional Rails practices. Many developers may resist this change, preferring the simplicity of tightly coupled code. However, as applications grow and evolve, this tight coupling can lead to legacy code challenges.
Uhm… I guess? Why I need to use Repositories is not explained in this statement, neither is the question answered as to why tight coupling to Rails primitives is that bad, nor why I should pick this particular flavor of architecture to help my… legacy code challenges.
For most things that I’ve seen, having most things be swappable was utter overkill. Yes, having thin interfaces is great, and it is something ActiveRecord makes quite difficult if ActiveRecord is your interface. But you are not obliged to do it! Those indirections that occlude ActiveRecord are not going to make your Rails app infinitely better serviceable, easier to understand and easier to change. They are just what they are - indirections.
And a lot of interfaces mandated by the original premise of the hexagonal architecture are, sadly, a requirement of typechecking (and, by extension, of Sorbet if you use it - because duck-typed interfaces, while being bread-and-butter and joy of the Ruby language, are thoroughly rejected by the Sorbet design).
Other Hallmarks of a Hexatetrahedral Rails app
There are a few distinguishing traits I came to expect to see in a “hexatetrahedral” Rails app. Some of them may be present, or just a few - but the more are present, the more I tend to regard it as being under the broad umbrella.
- DataMapper or rom_rb instead of ActiveRecord
- Lots of dry-rb usage
- RSpec instead of Minitest
- FactoryBot instead of fixtures
- Trailblazer and ROAR for views
- Repositories instead of “bare” ActiveRecord access
- RSpec test cases make liberal use of mocking (frequently forgetting to
and_call_original
too) - Sometimes - use of a “null database” of a mocked database, for the sake of “making tests run fast”
- Excessive use of stateful service objects (the
.new
then.run
)
On one hand, I love those apps - the “hexatetrahedral” applications were trying to plot an alternative path, far away from the 37signals and Shopify-led “vanilla” Rails.
On the other hand, I do think that most of them are Frankenstein’s monsters, because they impose a rather high number of additional concepts, dependencies, and indirections.
And much of this tooling has evolved differently than expected - some has become obsolete, while other pieces have struggled with maintenance. SSDs and free database servers - along with SQLite - have made “null database” and “mock database” obsolete except for very large, data warehouse analytical queries. Trailblazer and ROAR do exist, but they failed to gain traction. FactoryBot, while useful, is known to be one of (if not the) prime suspects when tests are slow. Dry-rb have seen some maintainers rotate in and out, and ROM has its own set of quirks and had its own amount of churn throughout the years. RSpec, while fairly ergonomic, does not provide all the facilities for integrating with modern Rails versions - as Rails helpers are not directly compatible with RSpec and need another layer of adaptation (the rspec-rails
gem).
However, behind the facade of “trying interesting approaches and libraries” and “trying to make an app well architected,” I see those apps were trying to do something different - and valuable! They are trying to narrow the APIs Rails exposes.
API Narrowing
I would argue that the “hexagonal architecture” in Rails - when done properly - is not a case (attempt? pretense?) of domain modeling, but rather - a longing for API narrowing. Let me explain.
When we were doing format_parser we had an interesting conundrum. We needed to read files, sure - but we also needed to read HTTP resources. We knew that, at some point, we would need to use external libraries - which would want to get something roughly file-like, and read from it. We also knew that it was a tricky affair because a badly written parser can happily stall into an infinite loop trying to read 1 byte from the same location in a file. If it were to do the same with HTTP, it would generate millions of HTTP requests, and be none the wiser. And we didn’t want to have a file-reading routing stall the same way, honestly - because we would have to heuristically apply multiple parsers to the same file.
So we would not only need to design a “file-like” (or, rather, “IO-like”) object to use for HTTP resources instead of files, we would also need to “wrap” actual files in something that would allow us to track reads and seeks.
The Ruby IO object has many instance methods related to reading and seeking. We will count peek() under those. If we want to have some measure of control over those - we would need to wrap (for files) or reimplement (for HTTP) all of them.
The solution was to actually mandate something similar to the (still non-standard!) SizedReaderSeeker
in Go. We would have an object that would deliberately work as a stand-in for a sized resource and function roughly as an IO. If we have a parser that needs more methods to be supported - we wrap that object again with something that allows the parser to have methods it wants.
Ruby’s “human orientation” is great - there are multiple names for the same thing, and it is very inviting. But it is also a challenge because when you need to be at the “toll booth” - you have to be at all of those spots! And more - core-extending modules like ActiveSupport may add even more spots.
What’s important is that our “base” methods cover the basics, and that the “extra” methods a particular parser could use be expressible in those terms.
For example: an io.rewind
is actually io.seek(0, IO::SEEK_SET)
, which is actually io.seek(0)
. An io.pos = n
is the same as io.seek(n)
. Our own parsers we would write using only this small subset of the methods. If we had to use gems containing parsers, we would verify their use of arguments is able to deal with our “narrow interface” correctly.
The result was the IOConstraint which collapsed the Ruby IO object into just a handful of methods. Any method a well-written parser would want to use should be expressible using those few base methods.
This allowed us, for instance, to define an entire stack of IO helper routines. For example, we could trace whether a parser is not trying to do too many tiny-tiny reads. We could implement a page cache for HTTP requests, which reduced the number of requests for most file formats to just 2. We could intentionally raise if a parser would misbehave - for example, if it were to attempt tens of thousands of reads. All of that - because the “toll booth” for accessing our readable resources was closely guarded.
If we look at ActiveRecord, the API it provides is… immense. In a pretty tiny Rails app, ActiveRecord provides 608 class methods and 270 instance methods (that is on ApplicationRecord, without any associations or attributes). Every association on every ActiveRecord, obviously, exposes the same 608 class methods and then some for the association helper methods. It’s a huge, huge API. And it is very hard to create a good bulletproof abstraction on top of, because not only do those methods have intricate behaviors on their own - but they also have sequencing constraints and dependencies. For example:
user.log_entries.delete_all
user.log_entries.create!(message: "User account reset")
user.log_entries #=> [], will be empty until reloaded
If you have a lot of teams and a lot of domains / modules, you will be looking for a way to reduce the amount of spots you need to put your “toll booths” at to reduce the carnage. And the problem is that when you return an ActiveRecord from one of your “properly controlled” methods - surprise, it then again exposes the ocean of methods from the basic ActiveRecord menu. Of which there are legion.
I think this was actually the main desire of the hexagonal architecture (but also of Packwerk, and other similar efforts) - and while we have made progress since the early 2010s on structuring our Rails apps better, we haven’t yet cracked this puzzle.
Should You Do A Hexagonal Rails App Today?
My humble suggestion is: don’t do it. As I have outlined above, I believe there to be one - and only one - reason to use that architecture, and that is reducing the API surface of ActiveRecord. Such reduction can be useful if you have a large and growing number of teams which are going to collaborate on the codebase, and you know that those teams will prefer to work in separate, completely isolated modules.
In essence, I believe that as an attempt at better modeling of the business use cases the Hexagonal Rails architecture has failed. It was a reasonable attempt at creating a small API over what is essentially a “free for all” API of immense size. But even the Rails community has moved on since then. Packwerk was another attempt at providing this isolation, for instance.
And, honestly - you can still apply some learnings from that architecture today. But you won’t need RSpec, you won’t need FactoryBot, and you won’t need Repositories. You may end up with something that looks very much like Repositories, though. You also won’t need dry_rb, you won’t need rom, and you won’t need ROAR or Trailblazer.
What you will need instead is discipline. The biggest challenge I’ve seen - and heard peers in the industry tell me about - is not setting up such an architecture but upholding it. Any team having an app architected this way will find themselves at a junction, fairly frequently.
- There is an API of … methods available. It is widely documented, it has articles written about it, and has gone through a number of reviews and fixes. If you take 7 methods from that API and hold them just-so you can ship the feature that is due yesterday
- There is an API of 12 methods, carefully curated by a contracting architect in 2017. There clearly is no method for doing the feature that is due yesterday. We need to add the 13th method, but every deliberation on “how” to write it moves us to… option 1. Because those 12 methods are not necessarily able to compose between themselves - they are meant for external communication.
Oh - and by the way - the feature was actually adding audit logging to one of the things that the module exposes. But it is a naked struct, so…
Most people, when faced with that dilemma, will just drop the whole “this is the blessed way to call these models” concept on the floor, whip out a where()
and be done with their day. You can maintain this “narrow API”, but you should be there every time when that option 2 emerges - and watch out when you suddenly notice your UserRepository
growing as many methods as ActiveRecord::Base
.
There is another, sneakier aspect. For people working the app, learning your particular flavor of the architecture is going to be costly - and completely non-portable to any other app or job. Since there is no “codified” architecture they are going to encounter in a different app, taking the first option of the two is simply safer for them in terms of not having to learn something they will likely need only once.
What, Then?
If you do think discipline is possible, you can try the following approach. Let every “domain” be a Ruby module. A module defines a namespace for models, like so:
module UserManagement
class User < ApplicationRecord
end
class Membership < ApplicationRecord
end
class Organization < ApplicationRecord
end
end
And then… by the same token, you can add ActiveJobs
into the same module. And controllers, should you want to. It’s almost a Rails Engine, but not quite because you do not hook into the complex (and perilous) Rails initialization cycle, do not install migrations, etc. You just add a signal that a particular model lives inside of a particular domain and when you need to do something with that domain – the module is your entry point. Not for calling methods and functions, but for understanding the domain.
So, next time you see a “hexatetrahedral Rails application” in the wild - show it some grace, but be aware that it is of a fashion that has not become timeless. Maybe next decade?