Get Your Rails Out Of My Ruby - HTTP & Controllers
On Friday September 22 (2017), I’ll be presenting a talk at connect.tech conference entitled “Get Your Rails out of My Ruby”. This talk is going to be looking at alternatives to writing Ruby code in a Rails application that don’t involve doing everything the “Rails way.” This post and future posts leading up to the talk will look at certain common areas where Rails is used; this post will be about controllers and your HTTP interface.
Rails Controllers - Benefits & Challenges
Rails has done so much for the Ruby community and is arguably the best collection of “getting started practices” that exists for web development. Other frameworks are catching up to it, but it has been innovating in its own way to keep pace. Rails handles creating a web interface gracefully and provides mechanisms to do it securely (versus rolling it all yourself from scratch). ActionController, specifically, has a few large benefits (not an exhaustive list):
- Clean interface to write routes and controllers quickly
- ActionController::Parameters for securely handling parameters, beyond a simple Hash
- Easy to use router
With these benefits, I’ve seen some challenges from relying on Rails for everything web-interface related:
- The default use of controllers leads to web & application code coupled together tightly
- Many ways to achieve objectives, some better and some worse than others. How do you know what’s better when getting started?
- Router is flexible and will let you do things you maybe shouldn’t be (1 controller with many endpoints)
- Larger learning curve than may be necessary due to generic functionality
When getting started with a new application or product, these tradeoffs might be entirely acceptable, and I would argue that they most likely are acceptable for a majority of use cases. However, these tradeoffs begin to appear less worth it as an application, business, or customer base increases in size. Specifically, the tight coupling of web and application code.
Web / Application Coupling
Your app is not a web app; your app is more than that. Your application is solving problems for businesses or consumers. Your application has a problem domain that isn’t just “the web.” When building your application, are you writing and communicating in your business’s context, or in the context of “the web?”
Conway’s law says that your application design will reflect the communication structure of your business. This is due to the needs of individuals in the organization to communicate effectively with each other. Let’s look at an example of how this might play out in application code:
Your banking software is designed to support operations of the tellers for your bank branches. When customers come into the bank, they submit “withdrawal slips” to the tellers, who process these slips and issue “money withdrawals” back to the customers. Your company has codified this business process for the software written for the tellers, which is accessed via a web interface on their computers. When designing the classes for this software, what are the “withdrawal slips” and “money withdrawals” called?
In the “Rails way” mindset, these might end up being simple “Controller Actions” which produce “Views” viewed by the tellers. The slips contents are passed around the controllers by “params”. The web application creeps into the code in this way, naming concepts by Rails concepts rather than business domains. The web / application domains are now coupled with each other.
Problems with Coupling
Being real, this tight naming coupling isn’t going to cripple your app; you may not even notice side effects for a while. However, slowly things might end up happening:
- Engineers become unable to communicate in the organization without mentally translating concepts
- Engineers doing refactors are struggling to discover the business intents, as the business concepts are not obvious
- Tighter coupling has caused bugs in one section to propagate down to another section of code, causing worse problems
- No mapping of language was ever created, people aren’t sure of the mapping anymore
All of these problems are going to escalate to the business level at some point, they aren’t simply engineering issues. Particularly decreased productivity and large refactors to reduce coupling.
In the above image, a traditional Rails Way design is shown. Rails routes the request to a controller, which utilizes service objects to implement business logic. These service objects are tied to the controller if any business logic is implemented in the controller, such as parameter defaults, safety checks, etc. It is possible to reduce coupling by making the controllers as dumb as possible, but I’m not sure that this plays out as expected in the real world.
In my view, the biggest issue with this design is that the application logic ends up tied in some way to a controller. This makes it not possible to invoke the business logic without reading the controller, understanding it, and duplicating that setup somewhere else. This introduces the above issues, such as lack of clear understanding, tight coupling, and more difficult refactors.
A different way?
What if our business logic was able to be separated from the web logic via object separation. This is fairly common in the Rails
world with the introduction of “service objects.” These objects are wrappers to encapsulate behavior. Instead of the “withdrawal slip”
being represented by the params
of a Request
, perhaps there is a WithdrawalSlip
object which has codified that business concept.
The controller will then create a WithdrawalSlip
and process it via a MoneyWithdrawalProcess
.
This small alternative immediately provides some benefits to the engineers and organization. The concepts are apparent in the code, and the engineering team is able to make sense of the names compared to how the business operates. The changes allows the codebase to get further, but there are still some problems. Every endpoint for the banking interface now has to create and manage respective objects, and construct results into responses for the tellers. The interface has become less Rails specific, but actual functionality is still heavily reliant on Rails. This means that engineers unfamiliar with the codebase (new hires) may end up doing something The Rails Way rather than the way that has been established by the team, simply because it’s possible and they are not familiar with the team’s way yet. This might lead to tension (“You only do it this way because you’re afraid of change!”) or code erosion over time, neither of which are good.
Separating business logic completely away from the web logic
What if the web logic was completely separated from the business logic? I don’t mean the service object extraction mentioned previously, but an actual fully operating application that doesn’t involve the web at all. This is possible to do, but involves separating the two concepts and only combining them at very small seams.
The actual development of the application logic is very specific to your use case, which makes it hard to offer suggestions on how to achieve it. However, a general goal should be to have the entire application “runnable via CLI.” This creates an artificial goal of not having code that is deeply integrated with being accessed via the web. Another artificial goal is that all tests for the application should not involve the web at all. There are no “controllers” nor “actions” at this point, just your business logic.
When this is achieved for the business logic, new development questions might be “how do we want to name and design this functionality?”, rather than “how can I build this functionality in Rails?” Abstractions and patterns could be established in the code that aren’t apparent otherwise, due to the artificial web limitations.
In the above example, a design is proposed that introduces a single seam between a controller (singular) and the application. This design introduces a very low level of coupling, and the code that is coupled is generic code implemented for all endpoints. In practice, I’ve found that this seam can be as low as three files: routing, request, response.
The most significant advantage to this design is that the application is completely able to be executed independent of a web request, maybe the business needs some CLI runners, or a fancy web socket based API. This design would make it possible to implement these interfaces in a low number of files, without changing the application at all.
Creating a seam with Rails
I’ve made a lot of suggestions about taking your code away from Rails, so it seems that I should advise on how to create a seam with Rails. Rails still solves a lot of problems that micro-frameworks like Sinatra just don’t solve. It’s also nice to be able to use something that Rails provides when you really do need it. For these reasons, I would suggest creating the seam with Rails rather than creating a seam to another framework. However, there is nothing so far that indicates you need to use Rails for web! Your entire business logic is represented without Rails web code present, and could be accessed via any type of interface: Rails, Sinatra, CLI, Websockets, etc.
One way to interface with Rails is by defining routes in the router, pointed at some controller structure that you desire. A front controller becomes a viable choice here, as the single controller/action creates a very small seam with the application. You could also still approach 1 action per endpoint, but your seam will be much larger and could be prone to leaking through or being difficult to maintain.
Let’s look at how you might approach the router:
# config/application.rb
# Setup your routes via a custom routes file that properly autoloads locally like routes.rb does
config.paths["config/routes.rb"].unshift(Rails.root.join("config", "custom_routes.rb"))
# config/routes.rb
# Integrate your routes via a context adapter
Rails.application.routes.draw do
RailsAdapters::Routes.define_routes(self)
end
# lib/rails_adapters/routes.rb
# Define your routes on the context provided
class RailsAdapters::Routes
def self.define_routes(context)
new(MyRouter.instance).call(context)
end
def intialize(router)
@router = router
end
def call(context)
@router.routes.each do |route|
# route.type => :get | :post | :put | :delete
# route.path => "api/money_withdrawls/:id"
options = { controller: "front", action: "execute", executor: route.executor }
context.send route.type, route.path, options
end
end
end
# app/controllers/front_controller.rb
class FrontController < ApplicationController
def execute # routing happened previously
request = RailsAdapters::Request.new(self) # request adapter
response = params[:executor].new(request).call # response adapter
render json: response, status: response.status # RailsAdapters::Response.new(self).call(response) rather?
end
end
The three steps I mentioned previously are visible here: routing, request, response.
So far, I’ve offered a way to define some routes in a separate place than the routes file. This may not seem great, as there is not a ton of code, but that is the point! There doesn’t need to be a ton of code to create this seam with Rails. With a small FrontController that can handle the clue between Rails request concepts and responses, we’re able to create lightweight adapters. If we wanted to move this code to something like Sinatra, we would rewrite these adapters only, 0 business logic changes.
One technique that can really help writing these adapters is the “context” concept above. By passing self
in as a parameter to
a method, every method on that context will be available in the consuming class. In this way, we could access context.params
,
context.render
, context.get
, etc. The code that is adapting can have full access to the Rails context, without necessarily
being tied to Rails.
Wrap it up…
If you approach your business code as a separate entity from your Rails (or framework in general) code, you will be able to write the code that you want to write, rather than the code you’re forced to write. Look for design patterns that suit your needs and make sense in your context. I utilize a “front controller” pattern, but there are other patterns for web interfaces that could work for you! If you’re tied to The Rails Way, you might miss these patterns and be forced into someone else’s design choices.