What advice/rules I may give to junior developers about the Ruby on Rails app design?
This is a question I’ve been asked during the interview for a job offer I’d like to apply for. It is a highly subjective list of certain ‘rules to follow’ when writing code and making decisions during the app development.
Do not fear using pure Ruby objects & design patterns
Ruby on Rails MVC (Model 2) architecture is a great way of structuring the app — but what is often forgotten is that mapping Model, View & Controller parts of your app do not need to be 1:1 mapping to objects. In large applications with complex logic you will end up with controllers making too many things and fat models with low cohesion because of many ad-hoc methods added to perform a certain business use case.
- Try to follow single responsibility principle. Create objects that are highly specialized and do one thing only.
- Try to keep Rails controller responsible for handling requests only and delegate the actual logic to other objects —Service Object and Query Object patterns are very useful here.
- For behavior splitted into multiple objects try to use Facade pattern to centralize object orchestration.
- Be aware of common Rails design patterns — Value Objects, Service Objects, Query Objects, Presenters, Repositories, Form Objects. Use them to increase cohesion of your solution and achieve low coupling.
- If your domain model and database model differs (like domain-wise you have one thing but it is represented as multiple AR models, or database field layout is wildly different than domain sense of the object) use heavier Entity/Aggregate objects with Repositories.
Favor explicitness over implicitness
Ruby on Rails, thanks to convention over configuration idea & a magical metaprogramming sauce is excellent for creating prototypes and smaller app s— but with bigger apps you may quickly start to pay the cost of this implicitness. Design your code in an explicit manner.
- Avoid implicit features like accepts_nested_attributes_for or deep relationship chains in your AR models.
- Avoid using after_create/update/destroy/commit callbacks — they are powerful mechanisms that are a little too powerful and are sources of subtle bugs. Also, they make reasoning about the code very hard.
- Keep your side effects together — do not use ActionMailer through the ActiveModel method, instead call it in a service object instead. Reading the ‘main’ function of the business use case (usually query/service object) should reveal all side effects immediately.
- Follow the law of Demeter — keep your object chaining minimal.
Split your application horizontally
Complex applications are usually complex not because they have one very complicated business use case — they are complex because there are a lot of business use cases stuffed together. Be sure to split them in a meaningful way.
- Use modules to horizontally split your app — avoid splitting vertically (like Application::Something, Domain::Something, Infrastructure::Something).
- Follow the business language (Ubiquitous Language) when naming things. If in doubt, ask the business expert how they call the thing you want to name.
- Namespace horizontal boundaries using Ruby modules — in e-commerce you’d propably have Order module, Shipping module, Customer module and so on.
- Calling a module from another module should be forbidden if they are distinct. Calling a submodule from the parent module is ok (like calling Order::Confirmation from Order).
Design for real non-functional requirements
A huge application usually has non-trivial performance and availability profiles — and you need to have knowledge of them to make a proper implementation.
- Always ask what load is expected — how many users do we expect to use this particular feature, how often, how critical it is. Make decisions about implementation based on this data.
- When in doubt, measure. Use tools like New Relic or similar, perf on Linux, ruby profilers to understand the performance profile of your app.
- Do not optimize prematurely. Usually more performant code has a trade-off in readability and maintainability — try to avoid it if not needed.
- Be careful with external integrations. Use infrastructure-level patterns like circuit breaker or bulkheads to handle failures robustly.
Test your code exhaustively
(Unit) Testing is the foundation of a professional software developer’s work — be sure to treat it as a requirement, not a nice-to-have. Not testing your code, especially in a language like Ruby, is like being a surgeon and not washing your hands before surgery. Don’t do that.
- If you need to use mocks, think twice. Use dependency injection if you need to inject an external dependency to your tested object.
- Unit is not necessarily one object only — it can be multiple objects.
- Be sure to write tests for every case — if you have a conditional, be sure tests run both paths in your code. Test coverage tools can help a lot here.
- When in doubt about architecture, start with functional (request, controller) tests first. You may leave tests on this level if there are no other clients using the same objects. Move to lower level if you happen to reuse an object — it deserves its own test then.
Favor composition over inheritance
Inheritance is a powerful object mechanism which is not needed most of the time. Try to use lighter patterns like composition which allows you to decrease coupling between two objects.
- Rails-specific corollary to it is that STI tables are considered a code smell and should be avoided. Use them only for performance reasons, if any.
- Make use of dependency injection heavily to allow composition to shine. It’ll help with the previous point as well.
Respect the control flow
People are not great when it comes to tracing multiple code paths at once — make it easier for people reading your code by keeping the linear control flow.
- Avoid deep conditionals. Linux kernel developers do not need more than three levels of indentation and they are writing extremely complex software with many corner cases.
- Push conditionals to the highest possible layer of the application. Most decisions you are doing are based on HTTP request parameters — you can create proper objects on the highest layer and avoid doing these decisions again. For more details this may be a good read — replace ‘lambdas’/’functions’ with ‘objects’ in your head.
- Think in layers — do not call application-level object like service object from domain-level object (like Entity/Value Object). You should have at least three layers in head — Application (which is allowed to orchestrate between horizontal boundaries — that’s how you call stuff from different modules), Domain (where the business logic lives, split by the horizontal boundaries) and Infrastructure (external API integrations, adapters for external services like APNS, database). The order matters — you only go left to right with your dependencies. This will ensure your flow is linear.
- Avoid communicating between objects at the application layer — they are considered to be self-contained and should not be a ‘building block’ — this is what domain layer is for. If you need to call two services or two queries, use Facade pattern (often called Command in this particular context).
This is only a subjective choice of the most important rules that are worthwhile to follow — I think having a battle-tested framework in head about what is right and what is wrong is very useful while making decisions about an implementation of your software. These helped me a lot while working with big, complicated legacy applications. Since they are only a selection and are subjective, it is of course all right to argue with them. There is a lot more to this topic — for your app there may be a need to design cross-cutting concerns better, or there may be a better architectural choice than MVC — like CQRS, or CQRS/ES, or Hexagonal Architecture… But it is worth another post.