Recently, the topic of building loosely coupled systems with Rails has gotten a lot of attention. More and more people are experimenting with different approaches trying to make their Rails applications more maintainable. The hexagonal architecture is one such approach. And in this article I am going to show how it can be used with Rails.
Why Do We Care About Architecture?
A good architecture enables the following properties:
- High-level abstract modules don’t depend upon low-level details.
- Modules interact with each other via well-defined interfaces.
Though they may sound a little bit too abstract, they have a profound impact on the development of an application. In particular:
- Modules can be developed and deployed independently.
- The application becomes easy to test because the logic that needs to be tested depends upon neither the UI of the application nor the database. It also makes tests extremely fast.
- Implementing another frontend (e.g., REST API) for the application becomes trivial.
The hexagonal architecture views the application as the hexagon in the middle. Every side of the hexagon represents some sort of a dialog the application needs to have with the outside world (e.g., talking to the database or interacting with the user).
The application talks to all these external “devices” via ports. A port can be viewed as a protocol that the application defines. In a statically-typed object-oriented language with interfaces (e.g., Java) each port would comprise one or more interfaces. In Ruby, since there are no interfaces, ports are not really represented in the code.
Each external “device” has an adapter converting the API of the application into some signals understandable by that external “device”. Whereas a port is a collection of interfaces/protocols, an adapter implements all those interfaces. There are typically multiple adapters for any port. For instance, the database port may have AR-based and in-memory-based implementations.
What are ports, adapters, and external “devices” in a Rails application?
- The Web UI and the database are external “devices”.
- Controllers, all sorts of http clients, message queue clients are adapters.
- The protocols of these http clients and message queue clients are ports.
Technique 1: Use Case Services
Since Rails controllers are adapters and are not a part of your application, they cannot contain any business logic. A use case service is a component that is responsible for the coordination logic we tend to put into our controllers. And being a coordinator, a use case service should not do any computation or state management. There is no one-to-one mapping between use case services and controllers. One controller can support multiple different use cases and vice versa.
Technique 2: Passive Controllers
Another useful technique is the passive controller. A passive controller does not make any decisions about whether the use case it invoked succeeded or failed. Instead, the controller plays the role of a listener that gets notified by the invoked use case service. The following diagram shows an interaction between a user case service and a controller.
Technique 3: Repositories
The database is an external “device” and, therefore, the application must communicate with it via an adapter. Unfortunately, the active record pattern makes it difficult. The approach many developers outside of the Rails community use is to create special objects managing all interactions with the database — repositories. The fact that repositories are not widely used in the Rails community does not mean that we cannot use them. One way is to use object-wrapping libraries like EDR.
Now, let’s take a look at an example of a use case implemented using all the mentioned techniques.
class OrdersController < ApplicationController
#... def create
create_order = CreateOrder.new(OrderRepository, self)
create_order.create current_user, params[:order]
end def order_creation_succeeded order
end def order_creation_failed order
@order = order render 'new'
end # Models
end class Order
end # Adapters module OrderRepository
extend self def save order
DATABASE.put(:order, id, order)
end # Use Case Service
def initialize order_repository, listener
@order_repository = order_repository
@listener = listener
end def create user, params
order = Order.new(params.merge(user: user)) if order.valid?
What exactly is our application? What are the ports? What are the adapters?
- External “devices”: the Web UI and the database.
- Ports: The listener role that OrderController plays, and the protocol of OrderRepository.
- Adapters: OrderController and OrderRepository.
- Application: The User, Order models and the CreateOrder service.
The hexagonal architecture helps separate the application from the delivery mechanism.
- The application knows nothing about Rails. Instead, it notifies listeners about the results of a use case execution.
- The application knows nothing about persistence. It uses repositories to talk to the database.
But most importantly, we have a clear use case boundary (the inner hexagon). That is where we can specify all the functionality supported by the application regardless of technology. That is where we test all the nitty gritty of the business logic without loading Rails or the database.
The company I work for just started a blog about building domain centric applications with Rails. If you are into this kind of stuff, please check it out.
Follow @victorsavkin on twitter to learn more about programming.
Originally published at victorsavkin.com.