Abstract
Adding new functionality to a large legacy Rails application is often expensive, if possible. But what if instead of adding new behaviour to the Rails application we implemented it as a single-page application in JavaScript? In this article I will show a few useful techniques for doing that.
Adding Behaviour to Legacy Rails Applications
Suppose we have a book-selling application, and we need to implement a new workflow for our customers. For instance, customers should be able to buy books without needing to sign up. The workflow is quite complex, and, therefore, will require changing a dozen or so use cases and maybe creating a few new ones. That will affect multiple models, controllers, and lots of view partials. Changing the latter is especially risky because they are not very well tested.
It may take months to make all these changes and the chance that another workflow will be affected is pretty high.
Building a Single-Page Application Instead
Instead of making such massive changes to a Rails application, where everything is interconnected, and there are no boundaries, replace a part of it with a single-page application. At first, this single-page application is going to delegate most of its work to the backend. It is, however, a very good place for implementing new workflows and use cases. So over time, as new requirements arise, the single-page application will incorporate them, minimizing changes to the backend. Eventually, all the coordination logic will be moved to the single-page application, and the backend will be left with service providers and repositories.
Why build a single-page application instead of just rewriting the module in Ruby? Why pick JavaScript? Because it enables new types of user interactions. This means that you do not just rewrite a part of your application for the sake of better maintainability, you do it to produce some business value. In this case, a better user experience. And on top of that you are getting more maintainable code. Having said that, not everything requires an interactive user interface. Obviously, rebuilding such modules as single-page applications will not be the best idea.
Two Frontends
Since building a single-page application is not a one-day task, for a period of time you will have two frontends coexisting. Those who will need the new workflow will use the single-page application, and everyone else will use the old UI. Only after all the functionality has been migrated to the single-page application will you be able to turn off the old frontend. After some time, when everything has settled down, it can be deleted.
Refactoring the Backend
Making the backend serve two different frontends is done as follows.
Step 1. Moving Behaviour from Controllers to Services
As I have already mentioned, the single-page application is going to delegate most of its work to the backend at first. As a result, we have to reuse the behaviour implemented by the “legacy” controllers. The easiest way to do that is to extract this behaviour into service objects.
class OrdersController < ApplicationController
def create
order_attrs = sanitize_attributes params[:order]
order = Order.new(order_attrs.merge(user: current_user))
#...
if order.save
transaction = PaymentProcessor.create_payment order
flash[:notice] = "Transaction Id: #{transaction}"
redirect_to order_path(order)
else
@order = order
render 'new'
end
end
end
Factoring out all the behaviour into OrderService can be done as follows.
class OrdersController < ApplicationController
def create
order_attrs = sanitize_attributes params[:order]
OrderService.create self, current_user, order_attrs
end
def order_creation_succeeded order, transaction
flash[:notice] = "Transaction Id: #{transaction}"
redirect_to order_path(order)
end
def order_creation_failed order
@order = order
render 'new'
end
end
module OrderService
def self.create listener, user, order_attrs
Order.new(order_attrs.merge(user: current_user))
if order.save
transaction = PaymentProcessor.create_payment order
listener.order_creation_succeeded order, transaction
else
listener.order_creation_failed order
end
end
end
As you can see, the controller still has some responsibilities left. First, it has to prepare the data. Second, it plays the role of a listener that gets notified by the invoked use case service.
Step 2. Creating New Controllers
Now, having OrderService we can implement a controller that will serve our single-page application.
#new controller
class SPA::OrdersController < ApplicationController
def create
order_attrs = preprocess_attributs params[:order]
OrderService.create self, current_user, order_attrs
end
def order_creation_succeeded order, transaction
render status: 200, json: order, serializer: OrderSerializer
end
def order_creation_failed order
render status: 422, json: order, serializer: OrderSerializer
end
end
Since the single-page application may require tweaking the use case service a little bit, Steps 1 and 2 are often done concurrently.
The following diagram shows the relationship between the controllers and the service.
Step 3. Switching Between Old and New Frontends
For a period of time the single-page application will not have all the functionality implemented by the old fronted. Therefore, we need to be able to redirect customers to the right frontend depending on their workflow. Another thing that might be quite useful is being able to disable the single-page application completely, in case it starts misbehaving.
Wrapping Up
One way to deal with legacy Rails applications is to surround them with single-page applications that will incorporate new workflows and use cases. I showed how to do this gradually without breaking existing workflows.
I haven’t talked about the JavaScript side things due to a broad variety of frameworks that make giving generic advice hard.
Learn More
-
Read more about use case services and passive controllers in my article “Hexagonal Architecture for Rails Developers”.
-
Kent Beck talks about parallel implementations in his famous presentation on responsive design.