To write modular code, we decided to implement a service oriented architecture, which states that all the business logic (also known as the domain logic) of an application should be contained into isolated components called services. Around this logic tier, you will find a presentation tier defining the user interface and a data tier modelling data to be stored.
Services have the following characteristics:
they are as simple as possible, and only implement the business logic
they have very limited interactions between each others
they are organized and grouped to follow the business logic of our application.
As you know, Ruby on Rails is our favourite backend framework. As you should also know, Rails is a MVC framework in which by default, the business logic is into models and there is no services.
To implement a service oriented architecture into Rails apps, we decided to add layers in between the original ones (model, view, controller): the services of course, but also some params and some commands.
Here are all the layers you should find into our Rails apps, grouped by broader layers called tiers:
presentation tier :
controllers: to handle an HTTP request
channels: to handle websockets
params: to parse & validate the HTTP parameters
commands: to call services and format their results
serializers: to format payload of HTTP responses (same as HTML templates)
services: to perform the business logic
models: to represent database information
The relation between those elements is unidirectional: technical elements can call business logic, but not the opposite.
The relation between those layers is unidirectional: layers of the presentation tier can call the logic and data tier, and the logic tier can call the data tier. We should absolutely avoid going backwards in the flow (i.e. having a model class calling a service method, or a service calling a command).
Here is a simplified flow through the layers triggered by a single HTTP request:
Around those core layers, you will find some other layers that are part of the infrastructure:
jobs: for asynchronous processing
libraries (lib folder): for non-business related utilities
mailers: to send transactional emails
Doing so allows our applications to be very simple to extend and maintain.
Putting the business logic into services allows developers to directly find where the complex parts are implemented, and where to change them. This effectively reduces the time to code, and reduces the number of bugs.
Deciding to split the business logic from technical elements allows to have a reflection only in terms of business, and not to think about all the side effects, such as data persistence, allowing to produce more complex and interesting features. Services are plain old ruby objects, which are easy to understand.
Finally, this also allows us to test much more on isolation, by calling directly the services, and this can make composition easier.