Getting Ruby on Rails on track with Railjet
TL;DR: Design-patterns can help keep your Rails code manageable. At Nedap we’ve developed Railjet to help us stick to specific design patterns.
Almost 8 years ago, I started working on a new application in a language (Ruby) and a framework (Rails) that were new to me. In the beginning, the code looked awesome. The different kinds of logic were spread over not too many files, the files were not too large, and if you looked really well you could see pink unicorns dancing on rainbows in-between the lines of code.
But unfortunately, it did not take long before cracks became apparent…
Rails; The good
For small applications, with little business logic, Rails in its vanilla form works very well. Spreading the code over models, views, and controllers yields a readable application to almost everybody. In this design pattern the models manage the data and contain the logic and rules of the application, the views describe how to represent (parts of) the data, and the controllers accept input and tie models and views together.
Rails; The bad and the ugly
However, as applications grow larger, they contain more business logic, and more code to represent it. This causes either of three problems in Rails:
- the controllers get bloated.
- the models get bloated.
- both of the above.
Bloated controllers or models mean there is a lot of code in one place. In my experience this has three main disadvantages:
First and foremost, it makes understanding and maintaining the code harder (and thus more error prone).
Second, with ActiveRecord-models being used in many places in the code, the validation of those models has to be conditional (e.g., a rental car that is currently rented needs a contact person, while if it is the garage it should always have a responsible mechanic). This conditional validation is a major contributor to the ‘bloating’ of the models, but is also complexity in itself.
Finally, the same holds for performance optimization. Queries can be optimized for one situation, but less so for the next. Either you add all the optimized, similar-but-slightly-different queries in your model or controller, leading to very bloated models. Or you don’t optimize at all…
Rails does offer an option to deflate the controller and the model using concerns, however those contain the same code, tugged away in a different file.
Railjet; why?
This is not the first time people have written complex code, nor the first time people saw code get bloated over time. So logically, smart people have come with solutions to this problem already. While working at Nedap Krzysztof Zalewski was inspired by several of those to create Railjet. Specifically, three sets of principles were most influential in the development:
- the SOLID principles
- Clean Code
- Clean Architecture
This article is not the place to explain those principles (nor am I the person), but luckily plenty has been written about all three of them. For example, see this Thoughtbot blog for an excellent explanation on SOLID, this gist for a list of core principles behind Clean Code, and this blog-post (by Robert C. Martin, who also coined the term clean code) for the ideas behind clean architecture.
Railjet; what?
All of these principles can be implemented by a combination of good conduct and spreading code over POROs (Plain Old Ruby Objects) in addition to the Model-View-Controller in Rails. Those POROs are placed at proper places in your Ruby on Rails repository. Several developers have described how to implement different design patterns in Rails (e.g. here, or here or even maintain gems to help implement them (e.g., Draper). Railjet also is such a gem: it offers a set of conventions on what-should-go-where and some boilerplate code and helpers to make it easier for you.
To my knowledge, Railjet offers one of the most extensive sets of design patterns in a single gem. On the app
-directory of your Rails repository you gain (up to) six directories:
- use_cases: This is where the actual business logic goes. One of the core principles of a use case is the single responsibility principle. Use cases are therefore typically small classes, with a
call
method, and possibly a few private methods. Another effect of this core principle is that a use case will often call other use cases, so that its single responsibility stays clear. - repositories: The boundary-with-the-outside-world layer; this is where you gather necessary data (from your DB, files, external API’s) and write-out the results. We often have a repository for each model, returning (collections of) ActiveRecord-models. However, you could have the repositories return plain data objects, making the whole framework agnostic.
- forms: Validate user input. Every attribute can be validated based on (conditional) presence, and datatype. But also, more complex validations (e.g. integers being positive), or completely custom constructed validations can be used.
- policies: Similar to forms, but for decisions, or validations in the business logic. Policies, in contrast to forms, can directly retrieve data from repositories. And to makes things even better, policies can be combined by composition.
- presenters: Used to present data generated by the business-logic in versatile way. Typically, presenters implement a
to_json
method to return data to a javascript front-end framework, such as React. But you could also have ato_xml
when used for an API, orto_csv
when writing to a file. - auth: Used to verify if a user is allowed to perform an action. Normally, the controller would be the place to check if a user is allowed to perform an action, but with complex authorization schemes this means you need a lot of information besides the user.
In addtion to to those six design patterns, there is the context
. The context contains data that remains the same during a request, for example the rights of the current user. The context is generated in the controller and passed along when initializing a use case or policy. The context also contains the repository objects, as those could vary depending on the current user.
Railjet; How?
So how would the flow through a Rails application look like? Here is some pseudocode updating a phone number and sending a confirmation message (happy flow only):
- Rails
client
controller received edit call with two parameters:id
andphonenumber
string. - Controller generates
Client
context for the specific client based on theid
. - Controller generates
ClientPhone
form object, which validates that thephonenumber
is valid phonenumber. - Controller calls
phonenumber/update
use case, and passes the form along. - That use case calls:
-save
on theclient
repository, passing thephonenumber
.
- Checks theAllowPhoneNumberUpdate
auth, which checks if the user is allowed to change thephonenumber
of theclient
.
- Calls themessage/send
use case with as arguments theclient
, and a message text. - The
message/send
use case
- Creates theShouldSendMessage
policy, which checks if the currentclient
is allowed to receive messages.
- Callssend
on themessage
repository, which dispatches a message to an external text message service.
Railjet is available as a Ruby-gem. The codebase and more information on how to set it up can be found on github.
Railjet; Why (how) not to use it?
Railjet should not be a straitjacket. You like the use case pattern, but think the rest is completely ridiculous? Then you can use just the use cases! Or maybe you want to use the repositories in one place, but not the next? No problem, go ahead. Railjet makes it easy to stick to the design pattern, it’s not enforcing them.
Railjet also isn’t a magic wand. If your application contains an enormous amount of domain logic and complexity, it won’t make that magically go away. Spreading it out over the different patterns, will make it easier to work with and find your way around it. But the complexity is still there, and a (tiny) bit of complexity has even been added in the form of the extra design patterns. The latter also means you should not use Railjet in simple applications. MVC is good enough there, and there is no need to add the complexity of extra design patterns.
It’s perfect! Or is it?
Don’t be silly. It only makes it easier to stick to a set of principles. And just like any theoretical principle: they often break down once the first bullet gets fired line of code gets written. One thing where we often deviate from the principles is the data that is passed along between use cases.
Ideally, the data would be wrapped in framework agnostic data structures. But as the amount of data grows, and describes more separate (but related) records, managing it becomes more complicated. A lot of those “data management tasks” (e.g.: record relations) have been greatly simplified by ActiveRecord. So we often find ourself deviating from the principle, and passing along AR-objects.
The use of Context
is another design pattern that makes life, especially debugging-life, difficult sometimes. Use cases need a Context when initialized, which in turn (may) need several objects (e.g.: user
, client
) for initialization. When debugging on the Rails-console, setting this up can become tedious, especially if several different types of context are used in the application. Railjet does offer help in that area by means of a small console helper.
Another design pattern which could replace the Context
is DependencyInjection. This would limit the information you need to provide to what is actually needed by the code you're trying to execute. Currently there is no convention or helper-methods to do this in Railjet, but it might be something we add in the future.
Altogether, Railjet helps us stick to design patterns in day to day life. Since the different components are independent and optional, you can refactor existing code in small steps. So, no reason not to start using design patterns yourself today!
This article was a joint effort by Stephan Roolvink and Djurre de Jong.