Hi DEV community!
Today I want to share something with you folks I've been waiting for weeks now but was to busy to write 😁
It's about the question "How to structure rails applications".
Finding a good and maintainable structure for large rails applications can be challenging. When you don't have a good approach, you'll end up with fat controllers and even fatter models. You keep on coding and at some point you'll lose the overview and have to maintain a spaghetti (code) monster.
Rails already comes with some features to extract code from your controllers, models and views by using concerns, helpers and so on. This is nice but far not enough.
Over the years I used a bunch of different approaches in a bunch of different rails apps. Starting with simple lib classes, tried Trailblazer, different gems and stuff. Finally I found a robust solution how to structure my apps to keep them clean, DRY, testable and maintainable: UseCases and Services (and Behaviors).
Recently I've took alle the code which I have grown over the last 2 years and published it as a gem. Think of it as the framework and support for UseCases, Services and Behaviors.
Enough talked, let's dip into my take on UseCases, Services and how this gem helps you to write them without boilerplate code.
For that purpose I distinguish between two types of code: low-level technical code and high-level business logic. Services are for low-level code. UseCases for business logic. Pretty simple.
Now let's see what that even means ...
Services for Low Level Code
Services are simple ruby classes which contain non-domain logic for low level, technical stuff. For example downloading a file from a FTP server, communication with an API, CSV generation, attaching a webcam image to a record, whatever.
Services are placed in the app/services
directory and should be named like csv_generation_service.rb
.
A example service could look like this:
class PDFGenerationService < Rails::Service
attr_reader :pdf_template, :values
# Constructor.
def initialize
super 'pdf_generation'
prepare
validate_libreoffice
end
# Entry point.
#
# @param [PdfTemplate] pdf_template PdfTemplate record.
# @param [Hash<String, String>] values Mapping of variables to their values.
#
# @returns [String] Path to PDF file.
def call(pdf_template, values = {})
@pdf_template = pdf_template
@values = prepare_variable_values(values)
write_odt_file
replace_variables
generate_pdf
@pdf_file_path
ensure
delete_tempfile
end
end
The Rails::Service
class comes with some handy features to make your life easier:
- Call style invocation:
PDFGenerationService.(some, params)
which is the same asPDFGenerationService.call(some, params)
- They are configurable and automatically load their configuration both from
config/services/shared.yml
andconfig/services/[service_name].yml
. You can access the config as a hash via callingconfig
within the service. - Each service writes it's own log file to
log/services/[service_name].log
. You can access the logger vialogger
within the service.
Services replace what was usually done via lib files. They have a clear structure, are reusable, DRY and are easy to test.
UseCases for Business Logic
While the service contains low-level non-business logic, UseCases are for the opposite: high-level business logic and they replace fat controllers. A UseCase takes params and has a outcome, which is successfully or failed. Examples are: Place an item in the cart, create a new user or delete a comment.
Use Cases should be placed in the app/use_cases
directory and the file and class name should start with a verb like create_blog_post.rb
. I also highly recommend to namespace all use cases with the model module like blog_posts/create.rb
.
Example UseCase for creating a blog post:
module BlogPosts
class Create < Rails::UseCase
attr_accessor :title, :content, :author, :skip_notifications
validates :title, presence: true
validates :content, presence: true
validates :author, presence: true
step :build_post
step :save!
step :notify_subscribers, unless: -> { skip_notifications }
private def build_post
@record = BlogPost.new(
title: title,
content: content,
created_by: author,
type: :default
)
end
private def notify_subscribers
# ... send some mails ...
end
end
end
Let's take a look on the details:
- We can define the params of the UseCase by defining
attr_accessors
. - The params should always passed as hash and are automatically assigned to instance variables.
- We can define validations for those params via ActiveRecord Validations. These are automatically executed when calling the UseCase
- The logic of the UseCase is defined by
step
s. Each step calls the method with the same name. They can be skipped viaif
orunless
- Steps are executed in the defined order. Only when a step succeeds (returns
true
) the next step will be executed. Steps can be skipped viaif
orunless
. - The UseCase should assign the main record which is handled within the Case to
@record
. - Calling
save!
without argument will try to save that record or raises an exception. Also the@record
will automatically passed into the outcome. - raising a
UseCase::Error
immediately stops the UseCase and saves the exception in the outcome.
Calling a UseCase looks like this:
outcome = BlogPost::Create.perform(
title: 'Super Awesome Stuff!',
content: 'Lorem Ipsum Dolor Sit Amet',
created_by: current_user,
skip_notifications: false
)
puts outcome.inspect
# => {
# success: true,
# record: BlogPost(...)
# errors: [],
# exception: nil
# }
-
outcome.success?
Tells whether the UseCase was successful. -
outcome.record
Contains the record which was assigned to@record
. -
outcome.errors
Contain validation messages when the validation failed. -
outcome.exception
Contains theUseCase::Error
exception when any was raised.
UseCases may call other UseCases and use Services.
Each controller action or GraphQL mutation should do nothing than calling a UseCase and handle the outcome. This way you have packed your business logic in highly reusable, robust, elegant and clean ruby classes.
Behaviors
When working with UseCases you will probably have duplicated code at some point. Behaviors allow you to share code between UseCases.
Behaviors should be placed in the app/behaviors
directory and the file and module name should named in a way it can be prefixed with with
and should end with behavior
, like current_user_behavior.rb
(read: with current user
).
To use a Behavior in a UseCase, use the with
directive, like with CurrentUserBehavior
.
Example Behavior for accessing the current user of AuthLogic:
module CurrentUserBehavior
private def session
UserSession.find
end
private def current_user
UserSession.find&.record
end
end
Example usage:
module BlogPosts
class Create < Rails::UseCase
with CurrentUserBehavior
# ...
private def build_post
@record = BlogPost.new(
title: title,
content: content,
created_by: current_user,
type: :default
)
end
# ...
end
end
This way you have an elegant way to share logic between your UseCases to keep them as small as possible.
I hope I could explain the advantages of using these classes to structure a rails application and would be happy when this helps you to improve your app. Please give feedback for the gem and don't forget to star the project 😉
Thanks for the read. Have a nice day and keep coding! ❤️ 👨💻
Header image made by Safar Safarov @ Unsplash
Top comments (4)
I've been meaning to write something similar - perhaps with different naming, but more or less the same ideas at heart.
100% this! I feel that as a Rails community we more or less figured out what should be in the model, in the view etc., but we still struggle with controllers. And, inevitably, they end up as a convoluted mess. Your approach helps to extract the actual logic very nicely! Controller should only serve as a proxy from HTTP world to application world IMO.
Hi Paweł,
nice to hear that you liked the article :)
I'm absolutely on your side: Controllers should handle authentication, rendering, redirects and notifications, but should be free of business logic.
Nice work.
I definitely agree with the overall opinions here. And I do see a lot of similarities with use-cases and form objects, and maybe a more conventional way of behaviours could be having simple concerns for the form objects (UseCases). 🤔
I like the distinction of when to use Services versus UseCases, this has often been a decision that isn't always made consistently on projects/codebases.
HI Unathi! Thanks for your response :)
Yes, you're right there is a big similarity between UseCases and FormObjects. Rails Form Objects are one of the inspiration sources for this gem. I encourage everyone to use what he/she/it likes the most. The both provide the same purpose. And yes, you don't need a gem for everything. You can do most of the stuff with rails builtin tools.
However I really like the term "UseCase" and defining the workflow via steps is really elegant in my opinion. That's why wanted to have a gem with some kind of "micro framework" and I'm looking forward to extend the gem with some additional tools and feature (without harming the simplicity of the gem).