loading...
Cover image for Pimp your Rails Application

Pimp your Rails Application

phortx profile image Benjamin Klein ・5 min read

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.

RailsUseCase gem

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 as PDFGenerationService.call(some, params)
  • They are configurable and automatically load their configuration both from config/services/shared.yml and config/services/[service_name].yml. You can access the config as a hash via calling config within the service.
  • Each service writes it's own log file to log/services/[service_name].log. You can access the logger via logger 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 steps. Each step calls the method with the same name. They can be skipped via if or unless
  • Steps are executed in the defined order. Only when a step succeeds (returns true) the next step will be executed. Steps can be skipped via if or unless.
  • 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 the UseCase::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

Posted on by:

phortx profile

Benjamin Klein

@phortx

> Developer of the Open Source and Passion Projects. > Owner of the Habitie.net. > Photographer of the stuff. > Father of the cutest thingy ever. > Surfer of the web. > Sleeper of the naps.

Discussion

markdown guide
 

I've been meaning to write something similar - perhaps with different naming, but more or less the same ideas at heart.

Each controller action or GraphQL mutation should do nothing than calling a UseCase and handle the outcome.

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).