Hello, my name is Slava and I have an odd habit to write about refactoring Ruby on Rails code from time to time.
Today, I’m going to show how to refactor an existing Rails application, that becomes convoluted and hard to maintain, and for some reasons(like a quick start for new developers) you want to keep it as close as possible to the common “Rails Way”.
Why did it happen to you?
At first, let’s figure out what leads to the situation when you cannot understand your codebase. Here some examples of what can go wrong:
- Too strict following MVC — You put all your code either in model or controller or (please don’t) view. After some time some of them become really big and this is definitely a bad sign.
- The Magic of Rails — At first you understand when and why all your callbacks happen, helpers come from and default scopes look like but only at first, believe me.
- Convoluted code — There is not enough understanding and conventions in Rails in where to put certain pieces of code and what depends on what. This causes problems when changing code — you can’t say where it used and what can it break
Your application may have none of these problems, but a lot of other projects do.
How can you fix it?
There is no silver bullet but there are modern frameworks and libraries that were built with ideas that help you to write code without these(and not only these) problems:
- Phoenix(that’s not Ruby but were made by people who are used to Rails and its problems)
- And plenty other frameworks from other languages such as Laravel
But we do remember — we have reasons to keep it close to classic Rails architecture. So here what we can do:
- Add two new abstractions — Repository and Operation (don’t be afraid — it is quite easy to understand), but remove complexity from other abstractions
- Make some rules describing where to put all pieces of code (to Model, Controller, Repository or Operation)
- (Bonus) Get rid of Rails helpers and all business logic in views
What the hell are these Repositories and Operations?
“Thin controllers, fat models” — that’s the most common advice that you will hear when it goes about refactoring MVC-based code and Rails in particular. And this approach is better than nothing — at least you have some rule about where to put your code.
But this advice doesn’t work in real life with real applications that tend to grow and have more and more features.
Let’s imagine we have a Dog class. At first, it was responsible to validate the presence of ears and a tail. Then you added a “bark” and “being cute” methods and that was ok. At the same time Dog is responsible for finding dogs with some breed, some size, and some color. Then you add “bring me newspapers” method and then “send a push notification to my human if I’m digging in the yard” method and so on and so forth. You end up with a monster model with tons of lines and no one understanding what is up to.
We need to do something with it!
There is a nice bunch of principles named SOLID. In theory (and actually in practice) they help you make your code reusable, maintainable, scalable, testable and more. You may not follow all of them but the first one “Single Responsibility Principle” is my favorite.
It says that each class(model, structure etc.) of your application should have as fewer responsibilities as possible(only one in the perfect case). When it’s not like this it’s become harder to change, test and even use your class.
That’s how we get to the idea of something new that will help us — Repositories for example.
Repository is a PORO(Plain Old Ruby Object) which used as a connection between business logic and database. That’s the place where you put all your code which is responsible for querying(such as scopes) and managing entities(like creating and updating). The main rule here is that all the queries and changes that happen in the repository should affect only the model it is related to.
You can say that you can use scopes and some methods to do the same. And in fact, you will be right. But we don’t want to have one class(model in this case) responsible for all possible tasks. These “God” models tend to become harder and harder to maintain — everything interacts with them, and they interact with everything. At the same time, repositories create a clear boundary between objects and their persistence.
Furthermore, if you see that some parts of your repository can be divided into smaller repositories like
OldDogsRepository — do it! The “Single Responsibility” rule works here as well — each class should have as fewer responsibilities as possible. But consider putting them in one folder using Modules as namespaces like
Hint: You can create an
ApplicationRepository with core logic and inherit all your repositories from it.
Hint 2: Another great thing about repositories is that you can make your tests way faster. Just mock repository methods in classes where they are called and you don’t even need the database connection for testing these classes! Here is an example. And interactions with a database sometimes is taking a significant part of your testing time. But don’t forget to write integration tests to ensure that everything actually works together!
Now when we know where to put DB-related code we need to decide where to put main business logic. That’s where Operations come to help.
Operation is also a plain Ruby class which contain a code related to one business operation:
As you can see it’s small and clear. It contains steps which are easy to follow. It also creates a booth(also with an operation) for each dog and you will not forget it if you will always call an operation to add a dog.
At this point you might disagree — callbacks are done for that! But callbacks are implicit and if you see the code for the first time or it is big enough so you cannot remember everything, they can make you spend some hours debugging(especially if you have a lot of callbacks with complicated logic).
On the other side, operations code are explicit, rather small(depends on the logic anyway) and quite clear. You still can spend some time understanding what is going on, but it will take less time.
Hint: When you will be deciding what piece of code you want to put in its own operation, consider thinking about one operation for one API call. Even if you don’t have an API now, most probably at some point you will need it(for mobile apps or when you decide to try SPA with modern JS framework). But don’t afraid to add operations that are not called directly from the API.
Hint 2: As we did with
ApplicationRepository, you can create an
ApplicationOperation with core logic and inherit all your operations from it. You can put
prepare_params method there for example. Also, you can take a look at Trailblazer::Operation and Hanami::Action as base classes for you operations.
Hint 3: As you maybe mentioned I added
booths_repo. This is done for easy mocking this classes in specs. Again — same example.
After introducing Operations, Controllers become responsible only for calling operations that they need and deciding what to render depending on results and happened errors.
It looks easy and clear. You can handle exceptions inside the method itself or use
rescue_from method in the controller.
Hint: If you want to divide your business logic exceptions and code problems exceptions, you can create your custom exceptions or try something like dry-monads where you can control flow returning
After moving most of the code from your models to repositories and operations they will contain only validations, associations, and methods that actually are related to the model.
What we have after refactoring?
- You don’t have “thin controller, fat model” — you have thin controller, thin model, thin operations and well-sized repository
- You will be able to understand all business logic related to some action looked through just a couple lines of code
- You will know where to find code that you want to change
- It’s easier to write “better” code when you have rather small files and certain relations between them
- Your architecture is still very close to the classic Rails and new developers with only Rails background will understand it very quickly
As you’ve already mentioned — I like lists. So here is another one:
- Add comments — it does not hurt and you will have a general understanding of what is going on in this place even several months later. Also, if you do it properly and will use some tool like YARD, you will have documentation for your code without any additional efforts.
- Avoid nested attributes for associations of the model — it’s hard to maintain creating and updating them even with classic Rails. If you need to create some associated model, just prepare params as you need and call directly operations that will create them. Same with updating and deleting them.
- Don’t afraid to break the rules — do it if you see that something can be done better with some new abstractions or not following the rules that you generally have. Just remember that you have(or probably will have) other developers in your team and your changes should be clear for them as well.
Bonus if you use Rails Views
There is nothing worse than a lot of Ruby code in the views. So prepare everything before it goes to the view.
But don’t use Rails helpers for it! The bad thing about helpers is that they are global. If you create one helper with a method
can_create? you will not be able to have a method with the same name in other helpers (of course you can add it, but only one of them will be used in views)
That’s why I suggest you to avoid using helpers for not global code. Because controllers are responsible for rendering views and we already removed most of the code from them, we can put these methods in there and use
helper_method method of Rails controller.
helper_method makes controller methods available in view and, most important for us, scopes it to the views that we render from this controller. So you can use it like this:
can_create? is available in views that you will render in this controller, but not in the other views and you always will know where to find methods called from the view
You still can use Rails helpers in cases when methods you want to define are global like
I hope this approach I showed you today will encourage you in thinking about how you can improve your Rails code. You don’t need to follow all the rules I described in this article but some of them might be helpful for you.
If you want to take a look at the more structured version of rules I’ve shown, you can check this Cheatsheet.
Thank you for your attention!