DEV Community

Cover image for Tuning Rails application structure
Sergey Tsvetkov
Sergey Tsvetkov

Posted on • Updated on

Tuning Rails application structure

Overview

This post is quite big because I wanted to cover basic setup of the app in one go, so here is an overview:

  1. First, we are going to talk about our needs and goals
  2. Second, we will configure reasonable defaults
  3. Third, we install some missing libraries
  4. And last, we configure nice directory structure

Consider subscribing to my Dev.to blog to to stay in touch! πŸ˜‰ And feel free to ask questions in the comments section below.

You can also find me here:

See you!

What do we want?

What do you think is an actual reason for Ruby-on-Rails to become one of the most successful frameworks out there?

For a long period of time I was under impression that is is all about it's agility and freedom. Dynamically typed Ruby together with meta-programming magic allowed us to make incredible things with very little effort. And rubygems.org back than contained answers to all of the questions you could possibly have. Go crazy all alone and yet stay productive, nothing will stop you!

With time principles behind development of Rails apps have changed though. More enterprise level software, established businesses, large scale solutions, high load. More stability, more types, stricter rules. Less gems because every gem is a dependency to keep track of and support. Less end-to-end solutions. More custom development. More testing. And so on.

Yet framework itself is still feels quite good and lightweight, productive and pleasurable to work with. So, what is the root cause of this feeling?

As far as I see it, the main point here is that Rails simply helps you to solve real day-to-day tasks fast and easy. That's it. As easy as it sounds. Handle and parse user's request. Route it. Go to the database. Create or update records there. Validate them. Respond. All in a standard way known and agreed on by many people involved into the community. You don't have to explain how it works every time someone joins. You can pick up a project you left for a while on the shelf and everything is still clear.

Of course, it is not ideal. Do I think that ActiveRecord is the best choice in any situation you may find yourself in? Nope. But is works quite good for many cases. If you have only models and controllers out of the box, it means logic will stick to one place or another, right? Is that good? Well, depends on a size of your application. And, what's even more important, on a size of your team. Should we trust web sockets handling to Rails? Up until recently Ruby was not particularly good in handling long connections. That's correct. But if your scale is not that big, well, it could still work pretty well. And once you reached the boundaries you can always switch to AnyCable and use Go to handle such troubling connections if you wanted to. As a result a lot of small projects could have their access to real time features out of the box without even knowing about HTTP upgrade and other relevant stuff.

In other words, my feeling is that we hear a lot of critics from large corporate Ruby-on-Rails users on how hard it is to scale it's basic principles behind certain size after 10 years of work. But the truth is that we should focus more on those who can only be dreaming to have such a scale and 10 years of development behind them and who's task at the moment is to work efficiently and move forward fast to survive and strive.

It happened that for the last few years quite frequently I participate in 2 types of projects. Launching of something or re-launching of something. When you work in a small teams with significant constraints in your resources this ability to solve typical problems fast and in a standard way which Rails grants you is just a "must have". With time I collected some tips and tricks on tuning Rails setup from the get go which, first of all, helps you to bootstrap even faster and, secondly, allows you to go trough "middle size" stages of the project and "grow up" without too much pain.

Reasonable defaults

Let's start from an obvious thing: standard set of parameters for rails new configured once and more or less forever. The thing is that due to the nature of our projects (mobile apps with some parts of the system available via web as well) we usually drop the whole "view" level of the framework, using mainly API with JSON. Database is PostgreSQL. Testing with rspec. We do not use any code preloading gems such as spring. We send e-mails using separated service written in Node.JS with templates done in React (a topic for another conversation probably). We don't need WYSIWYG-editor or ability to receive and process incoming emails. All of that are parts you can opt in or out in Rails. Here is how it looks like:

➜ rails new --database=postgresql --api \
  --skip-spring \
  --skip-test \
  --skip-active-storage \
  --skip-action-text \
  --skip-jbuilder \
  --skip-action-mailer \
  --skip-action-mailbox \
  my_new_project
Enter fullscreen mode Exit fullscreen mode

Quite hard to remember, isn't it? And easy to forget about something specific when you create a new project. And then it takes some time to carve it out. Luckily, Rails has a feature which allows you to configure it once using configuration file and forget about it, using selected keys as a default. And if happens that you need something from the list of disabled stuff, well, you can always override the default and enable it through CLI arguments.

The file is called .railsrc and is it assumed that it lives in your home directory. Here is how it is documented: click.

In our case .railsrc contains this:

➜ cat ~/.railsrc
--skip-spring
--skip-test
--api
--skip-active-storage
--skip-action-text
--skip-jbuilder
--skip-action-mailer
--skip-action-mailbox
--database=postgresql
Enter fullscreen mode Exit fullscreen mode

Thanks to it creation of a new project boils down to the simple command rails new <PROJECT NAME>. Default arguments will be taken from the file, so you don't have to remember about them anymore:

➜ rails new my-awesome-project
➜ cd my-awesome-project && ls
Gemfile      README.md    app          config       db           log          tmp
Gemfile.lock Rakefile     bin          config.ru    lib          public       vendor
Enter fullscreen mode Exit fullscreen mode

Latest Rails

Our next step is much less obvious, but it turns out to be quite useful from practical point of view with just a few downsides. Let's go to our Gemfile and change fixed version of Rails there to the main branch of Rails:

gem "rails", github: "rails/rails", branch: "main"
Enter fullscreen mode Exit fullscreen mode

I almost can hear you screaming: "What's the hell? Are you out of your mind? Why one would do that?". Well, look... One of the biggest challenges in any Rails project is an upgrade of Rails itself. Even if backward compatibility of Rails itself is there for you still every time you upgrade some gems won't make it though the process. Plus you never have time to actually look into it. So, this process takes time. Sometimes it takes years. In many cases it never actually happens.

Frequently Rails upgrade means also Ruby upgrade which automatically means infrastructure upgrade. Nowadays you most likely have to change at least Dockerfile. There is a probability that this part is done by other people and they have their priorities. So, the complexity of simple Rails upgrade goes through the roof.

It was not that rare for me to see some projects stuck with ~> 3.0, ~> 4.0, ~> 5.0 and so on, depending on when the project was started initially. Development teams just had not enough courage to just go for an upgrade whatever it means. And with time this issue grew up to be a real deal problem. And, of course, quite frequent outcome in such case is to drop it all, abandon this old dirty outdated "monolith" and go for shiny new "micro-services" written in Go or Node. Seen it before? Me too. Sometimes it ends up good. But it takes resources, time and money. So, to avoid the trouble just use the latest available version all the time! Spread the effort thin.

What advantages does it give to you:

  1. Main branch of Rails is quite stable thing. Bugs are being fixed. New issues happen sometimes, but critical stuff is not that frequent and most likely you won't even notice anything up until certain scale. But, of course, you should trust it completely. One day everything can go off rails πŸ˜„ What does it mean? Well, you just know: without good test coverage you won't survive. So, tests are must have. All in. Skin in the game. Specs are your life insurance. With good test coverage something still can go wrong. For example, you may experience some performance issues and memory leaks. So, monitoring, green-blue deployments and canary roll-outs are welcomed into your life as well. Configure them early and enjoy your life every since!

  2. There are a lot of changes in Rails which impact it's performance. It means, that most of the time you have the fastest possible version of the framework in production. It is true for Ruby as well. Every next release gives 20-30% to speed. If you adopt early use of Ruby / Rails builds you don't need to compromise on performance anymore. As a bonus you have all of the latest features. For example, for a long time encrypted database fields support in ActiveRecord was available in master but not in any released version of the framework before Rails 7.

  3. The reality is such that not every gem works with the latest version. Some of them are "stuck" on some specific major release and just won't install. Well, on the one hand it limits your choice to only libraries with very good community support. On another hand, if there is this one little thing you really need but it is not available for the latest Rails, why won't you clone it, fix it and make a PR to give back to the community, huh? To busy for that?

Well, let's say I convinced you and version of Rails has been changed to main. If the risk is too high then at least track every single Rails release and make it your priority to do an upgrade early on. That will save you a lot of time and pain in the future, believe me.

Useful gems

What's next? There are some libraries in the Gemfile we almost 100% are going to use but their are commented out by default. Examples are redis, bcrypt, rack-cors. Let's put them in!

Once we are done with default gems, should we look into something we usually use? That's jwt because we need session tokens for our API. Next comes our one and only sidekiq. For a long period of time it was the best in town solution for background jobs. Now we could also consider solid_queue or good_job. In development and testing groups we need rspec-rails, factory_bot_rails and ffaker. Dealing with money? Start doing it properly from the beginning! Do not forget to install money-rails. Once everything is added to the Gemfile do not forget to trigger bundle install.

Next you should generate required folders and configs for rspec using bin/rails generate rspec:install, as it is said in it's README. In addition it is useful to do bundle binstub rspec-core to generate bin/rspec. I'm not going to dive into rspec configuration itself here, because it works quite well out of the box and you'll learn what you need to know as you go.

Now let's take care of factories by creating a directory for them using mkdir rspec/factories.

What about sidekiq? If you decided to stick to it then go to config/application.rb and configure ActiveJob adapter there: config.active_job.queue_adapter = :sidekiq. Another option would be to use sidekiq directly and just forget about ActiveJob which would save you a few dives in the documentation and Google probably.

Taking into account we already opened application configuration, let's change our default time zone to UTC. Based on our experience, it simplifies your life a lot, because you don't have to be worried about location of your physical infrastructure and what time is it there. So config.time_zone = "UTC".

Modules and directories

Up until now we were doing more or less classical changes in our project. It's time to go a bit sideways. Here is a trick. By default, loader in Rails is configured in such a way that it loads all files from from any directory inside of the app folder, assuming that all of them will define classes from the main module. Easiest example here is a models folder. There you can find all database models, defined in the app, right? It's useful that way. When you need a User model you just drop a file models/user.rb into the project and use it without any module prefix: User.new. Loaders knows how to find it.

This approach is less cool when you would like to have some modules in your code. Which is not such a terrible idea in the project growing fast. Let's say you would like to have a class which defines JSON presentation for your user. It sounds reasonable to put it into users/presenters/show.rb. Like this:

module Users
  module Presenters
    class Show
      def initialize(user)
        @user = user
      end

      def as_json
        {
          id: @user.id,
          email: @user.email
        }
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Well, Zeitwerk won't support it:

➜ bin/rails c
irb(main):001:0> Users::Presenters::Show
Traceback (most recent call last):
        1: from (irb):1
NameError (uninitialized constant Users)
Enter fullscreen mode Exit fullscreen mode

Because users folder belongs to app directory Rails will treat that the same way it treats models folder from our example above. You should not do Models::User there, right? But it is not very helpful in case of our presenter because Users::Presenters::Show is not mapped to users/presenters/show.rb. Layout assumed by Rails offers you to go for something like presenters/users/show_presenter.rb and, respectively, Users::ShowPresenter. Same as you do for, let's say, controllers. But this approach has some downsides. First, module is replaced by the suffix. Second, parts of the system connected to each other are located far away from file system point of view. As a result, you'll frequently find yourself changing multiple directories related to the same "domain" here and there.

Can we fix that without breaking the whole convention? Let's see first who and where adds folders from app directory into the loader as if they were root? After some digging you'll find this file:

paths = Rails::Paths::Root.new(@root)

paths.add "app", eager_load: true, glob: "{*,*/concerns}", exclude: ["assets", javascript_path]
paths.add "app/assets",          glob: "*"
paths.add "app/controllers",     eager_load: true
paths.add "app/channels",        eager_load: true, glob: "**/*_channel.rb"
paths.add "app/helpers",         eager_load: true
paths.add "app/models",          eager_load: true
paths.add "app/mailers",         eager_load: true
paths.add "app/views"

paths.add "lib",                 load_path: true
paths.add "lib/assets",          glob: "*"
paths.add "lib/tasks",           glob: "**/*.rake"

paths.add "config"
paths.add "config/environments", glob: -"#{Rails.env}.rb"
paths.add "config/initializers", glob: "**/*.rb"
paths.add "config/locales",      glob: "**/*.{rb,yml}"
paths.add "config/routes.rb"
paths.add "config/routes",       glob: "**/*.rb"

paths.add "db"
paths.add "db/migrate"
paths.add "db/seeds.rb"

paths.add "vendor",              load_path: true
paths.add "vendor/assets",       glob: "*"``
Enter fullscreen mode Exit fullscreen mode

As you can see standard folders are being added into the paths here. In particular, we are interested in glob: "{*,*/concerns}". That's exactly the line where it says that everything in the app folder itself should be loaded (that's * part) and also files from concerns too (that's */concerns, such as models/concerns or controllers/concerns).

We found where it is defined, but it turns out that this configuration is used much-much later on. So, we can actually change it when our application is loaded. For example, we could do it in config/application.rb by adding following line:

# makes zeitwerk do not autoload subfolders of app folder by default
config.paths["app"].glob = nil
Enter fullscreen mode Exit fullscreen mode

Here is how config/application.rb looks like after the change:

require_relative "boot"

require "rails"
require "active_model/railtie"
require "active_job/railtie"
require "active_record/railtie"
require "action_controller/railtie"
require "action_view/railtie"
require "action_cable/engine"

Bundler.require(*Rails.groups)

module MyAwesomeProject
  class Application < Rails::Application
    config.load_defaults 6.1
    config.time_zone = "UTC"
    config.api_only = true
    config.active_job.queue_adapter = :sidekiq

    # makes zeitwerk do not autoload subfolders of app folder by default
    config.paths["app"].glob = nil
  end
end

Enter fullscreen mode Exit fullscreen mode

Let's try it out:

➜  my-awesome-project git:(master) βœ— bin/rails c
irb(main):001:0> Users::Presenters::Show
=> Users::Presenters::Show
irb(main):002:0>
Enter fullscreen mode Exit fullscreen mode

File is being automatically loaded by Zeitwerk! But what about all the standard paths we had? Well, because they are explicitly configured in the file we discussed above no damage is done:

➜ bin/rails c
irb(main):001:0> User
=> User (call 'User.connection' to establish a connection)
Enter fullscreen mode Exit fullscreen mode

Another nice side effect I usually exploit is that standard basic classes now can be also moved to the app folder:

➜ mv app/models/application_record.rb app
➜ mv app/controllers/application_controller.rb app
➜ mv app/jobs/application_job.rb app
Enter fullscreen mode Exit fullscreen mode

Other stuff from the same category, like application_presenter.rb, can go there as well. It is not required and you can leave them where they were, but it sounds pretty reasonable to me, that basic classes can be found in the app directory with no additional effort spent to find them.

Conclusion

Well, that's basically it for now. I think it is enough for the beginning πŸ˜„ In the next series we are going to talk about standard tasks you may find in almost any application which have no nice solution provided by Rails: incoming request payload and params validation, session management, phone verification and so on.

Top comments (0)