The Lego Way of Structuring Rails Code

Two years ago I had an interesting challenge: explain to a junior developer why he should prefer interactor pattern instead of Rails Active Record Callbacks.

Arguing against callbacks is easy - thanks to the community there is a lot of experience shared about their drawbacks: callbacks pain, 7 Patterns to Refactor Fat ActiveRecord Models.

The second part is not that easy - explain to a developer why do I believe that interactor is a better alternative than, e.g. the commonly referred-to service object?

The main difference between the two is that service object has many responsibilities, e.g. CreditCardSevice can respond to #create, #make_default, #charge and #delete, whereas interactor complies with the Single Responsibility Principle. Both of them are simple to explain. Service object wins in grouping related actions in one place, but interactor is more constraining making it hard-to-abuse.

We could argue for hours which of them is better. So how do we reach consensus in comparing design patterns? What we usually do is reading the description, looking at the code samples, writing some code to discover benefits and limitations and picking the one which is a better fit for the case. It’s a valid method and I would like to improve it by asking a funny question - what if code was physical?

We could see the patterns and properties it has. We could construct some samples and improve them during discussion. Let’s imagine that our code is physical, like Lego bricks and explore the code composition analogies we can make.

Code as Lego

Composing from big pieces

With big classes, like fat models or controllers, we see several big bricks.

Big bricks

We put smaller bricks on them and then the requirements change. We cannot decompose the big bricks, so we try to mold them or even better - not touch them at all. Just add more stuff around and use some glue if needed.

This is especially painful with Active Record Callbacks. Once a project gets bigger we add more and more callbacks on e.g. User class. We get into situations when some callbacks should not be called, like sending an email when a user password is updated except on creation. Then we start writing conditional callbacks. The problem gets bigger once the project grows - we have more code and tests. Test duration increases because callbacks are run each time User is created. When we are testing a purchase, we do not want the user creation callbacks to run. Sadly, there is no simple escape from callback hell situation once your code-base is full of them.

Big bricks help us to achieve the initial result fast, but they are harmful when a project matures. The same applies on a higher level - it’s faster to keep all the code in a single project at first, but later big applications become hard to maintain: startup time increases, a single test run takes longer than 2 seconds, developers need decent hardware to be able to run the code on their machines, test suite becomes too big to be run on a developer machine, deployments slow down because we still want to run all the tests and need to reload all the application servers.

The sad downside of it is that slow test runs discourage developers from using TDD which can later lead to degraded system design. This can start a snowball effect of reducing code quality, introducing more bugs which take longer to fix, resulting in decreased delivery capacity.

Divide and conquer - identify the easiest points to split and start your path to micro-services. Make sure that you need micro-services and are ready for them.

Too much connections

Try to imagine User class of more than 1000 lines that has many validations, relationships and public methods which are not related, such as:

  • send_confirmation_email
  • upload_item
  • update_settings
  • ban
  • approve_holiday

and so on.

If it were a Lego brick, it could look like this:

too many connections

It’s custom, strange, hard to maintain and extend. Our code solves a specific problem, so it has to be custom, right? We can achieve it via composition of many classes with unified interfaces:

standard bricks

We prefer classes that have well defined responsibilities:

  • models are responsible for ORM
  • business operations are handled by classes with a single public method
  • value objects have many public methods but don’t perform operations

Such simple rules help developers make code-organizing decisions and prevent monster classes that nobody wants to maintain.

Artful solutions

Sometimes we don’t find a good fit for our problem and invent a custom masterpiece.

Custom solutions

It’s tiny, does exactly what we need and looks amazing. But again requirements change: another developer comes in, looks at the “masterpiece” and starts asking questions. How do I change parts? What shapes can I use? Why on earth is it composed of custom parts?

This happens when we invent custom patterns for solving hard problems like data over-fetching, N+1 queries or cache invalidation. Hard problems need thought-through solutions that rely on simple and intuitive principles. Otherwise complexity can overwhelm you.

Real-life example: we want to implement cache invalidation. Members can trade their clothes and fashion accessories via the Vinted platform, so we have an order that involves a seller, a buyer and a conversation about the traded items. The buyer purchases the items, then we need to:

  • mark the order as sold and invalidate its cache
  • update each item - mark as sold and invalidate its cache
  • mark all other orders that involve the sold items as invalid and update their cache
  • notify the other buyers that an item is no longer available in conversations and invalidate the conversations’ cache

If we cache an item’s information in order and conversation caches, then we’ll have trouble coordinating the updates, especially if we are using callbacks.

This situation can be solved by creating some precise artful solution and putting in correct callback conditions, but that solution would not be easy to maintain and extend.

We should make the solution simpler:

  • coordinate the sale in a single place instead of using callbacks
  • avoid caching associations’ data - append it in response phase instead of cache phase

Artful solutions look nice, but standard solutions are easy to share and maintain by a group of people. The same principle applies to libraries - they should be easy to understand, configure and plug in.

Too small bricks

If we split the code into small pieces, how small should we go?

Small bricks

The smaller the bricks, the more flexibility we have. But our fingers can hardly handle tiny things. Try to make parts that make sense as a business unit on one hand, and are small enough to be easily testable on the other.

Making the code DRY leads to extracting classes that sometimes have almost no logic - they contain a single test and mostly delegate work to other objects. This sounds like an imaginary situation, but we’ve been there - requirements change, functionality gets extracted to other classes or branching is removed.

Replace all the class invocations with delegated code. Even though we lose the injection point for a possible change of requirements, we don’t keep the code that we don’t need. It’s hard to delete the code, but it is always persisted in the source control system history.

Once you have many small bricks, it’s easy to get lost. Organize the bricks in directories by epic or feature, e.g. onboarding, payments, messaging.

Organization

Imagine we are developing checkout. Let’s start from creating the folder for our epic - checkout.

▾ domain/
   ▸ checkout/

What are the operations that we support? Let’s create a file for each operation or value object.

▾ domain/
   ▾ checkout/
      ≡ add_shipping_address.rb
      ≡ add_to_cart.rb
      ≡ checkout_summary.rb
      ≡ clear_cart.rb
      ≡ confirm_cart.rb
      ≡ payment_outcome.rb
      ≡ process_payment.rb
      ≡ register_credit_card.rb
      ≡ remove_from_cart.rb

We can see there are two more concepts missing: cart and payment.

▾ domain/
   ▾ cart/
      ≡ add_to_cart.rb
      ≡ clear_cart.rb
      ≡ confirm_cart.rb
      ≡ remove_from_cart.rb
   ▾ checkout/
      ≡ add_shipping_address.rb
      ≡ checkout_summary.rb
      ≡ register_credit_card.rb
   ▾ payment/
      ≡ payment_outcome.rb
      ≡ process_payment.rb

Namespaced classes should have shorter names.

▾ domain/
   ▾ cart/
      ≡ add_item.rb
      ≡ clear.rb
      ≡ confirm.rb
      ≡ remove_item.rb
   ▾ checkout/
      ≡ add_shipping_address.rb
      ≡ register_credit_card.rb
      ≡ summary.rb
   ▾ payment/
      ≡ outcome.rb
      ≡ process.rb

Appending s to a namespace helps mitigate name clashes. We think of a namespace not as a plural form, but as an ownership - cart's.

▾ domain/
   ▾ carts/
      ≡ add_item.rb
      ≡ clear.rb
      ≡ confirm.rb
      ≡ remove_item.rb
   ▾ checkouts/
      ≡ add_shipping_address.rb
      ≡ register_credit_card.rb
      ≡ summary.rb
   ▾ payments/
      ≡ outcome.rb
      ≡ process.rb

We got a clear picture of concepts and actions that the application supports by looking at the file tree - no need to dig into code trying to build a mental model. Uncle Bob explains the reasoning behind it in the Ruby Midwest 2011 - Keynote: Architecture the Lost Years talk. Watch it - it has changed the way I reason about code organization.

Basket-related functionality can be easily found by searching for Baskets:: - much better than scanning models or controllers. Each class has a single responsibility and a single reason to change.

What is the right size? It’s personal - some people like files with several hundreds of lines, some are willing to make it fit into a screen. Talk to your colleagues and agree on what you’re all comfortable with.

Last words

It’s not about which Lego shapes (or patterns) you choose, it’s about how easy they are to connect and reason about. We all strive for long term productivity and developer happiness. A single brick is dumb - it’s simple, easy to understand and change. A system of connected bricks forms a unique masterpiece - software which does or doesn’t do what you want. When it doesn’t - replace complex structures with simpler ones. In the future, you’ll thank yourself for that.

It’s expensive to experiment discovering the right shapes. Look at the community’s best practices, write a toy project or try out new things on a small service. Look at other languages - people have built large systems with Java, C, C++, etc. and have a lot of practical experience to share. Be a child - discover what’s present behind your comfort zone, understand the ideas and steal them.

Happy coding! smile