Skip to main content

Best Practices on organizing complex business logic

Rails • Asked by Sergio Rodríguez
Hi! 

I'm new to the community (so be nice) but I've been developing Rails applications professionally for a while now.  I would like some help from the experienced developers in the community on best practices.

A) Disclaimer:
This question is going to be long and the answer(s) will probably need to integrate multiple concepts. Stay with me on this one and we will all probably learn a lot from the discussion.

B) Motivation
I've been struggling with coming up with a sensible convention for organizing complex business logic inside my Rails application. 

The application will be maintained by multiple developers so establishing a sensible convention from the beginning is important.  My experience has shown me that if there is no convention, every developer will code their own version of their "service object", "form object", "use case", "interactor" and what not... and after 6 months of development, your code will contain many different opinions on how to handle complex business logic, bringing maintainability and standarisation problems.

I'm aware that there is no one size fit's all solution for everything. However, having a structured approach that works for 80% of the cases will be a big win in terms of maintainability.

C) A toy example that will illustrate what I mean by "complex logic"

I will split the example in bite sizes to keep things manageable.

1. My application has Users and Organizations and a User must belong to an Organization.

class User < ApplicationRecord
  
  # Data integrity validations
  validates :name, presence: true, length: { minimum: 3 }
  validate :email_format_is_correct

  # Associations
  belongs_to :organization

  private
  def email_format_is_correct
    # ....
  end

end

class Organization < ApplicationRecord
  
  # Data integrity validations
  validates :name, presence: true, length: { minimum: 3 }

  # Associations
  has_many :users

end
Notice that the models have some validations in place that ensure the data integrity of the records. This means that every single record must comply with those validations.

2. The signup form on my app contains fields from both the User and the Organization.  It requests the user for these fields:
  • User name
  • User Email
  • Organisation name
  • Would you llike to subscribe to a newsletter?

3. The signup form requires some contextual validations (validations that are only required for this form and not at the model level)
  • User email is required for this form.
  • The user's email must be on a "white list" that is hosted on an external service for the form to be valid (i.e we need to do an external API call to check).

4. If validations errors occur, either from the model validations or the contextual validations, helpful error messages must be displayed in the from (active model style).

5. Data Integrity requirements
  • If the user or organization fail to save, neither should be created and no "side effects" should be triggered.

6. Side effects
If both records are saved:
  • Send email to user
  • Add user to mailchimp contact list.
  • If user selected to enroll in newsletter, enroll it

D) The challenges of this example (and the reason I need whit it)
This toy example has multiple typical challenges that Rails developers faces:
  • Forms that write on multiple models.
  • Handle simultaneously model-level validations and contextual validations.
  • Steps that depend on other steps: e.g If the user is not in the whitelist, a validation error should stop the execution and return the error in the user email field.
  • Data integrity constraints: you would probably like to save both the user and the organization inside a database transaction.
  • Trigger side-effects.
  • Conditional side effects: If user selected to enroll in newsletter, enroll it

E) My take on it
I'm gravitating towards using the form object pattern that implements all the business logic on save.  It would look something like this:
 
# app/use_cases/signup.rb
class Signup
  include ActiveModel::Model

  attr_accessor :user, :organization, :join_newsletter

  # Delegatation
  delegate :name, :email, to: :user, prefix: true
  delagate :name, to: :organization, prefix: true
 
  def initialize(signup_params)
    @user = User.new(user_params(signup_params))
    @organization = Organization.new(organization_params(signup_params))
  end

  # Contextual Validations
  validates :user_email, presence: true
  validate :user_email_is_on_whitelist

  def save
    # QUESTION: how to bubble up model-level validation to from object?
    if valid?
      ActiveRecord::Base.transaction do
        save_user
        save_organisation
        send_email_to_user
        add_user_to_mailchimp
        enroll_user_to_newsletter if join_newsletter
      end
      
      true
    else
      false
    end

  end

  private
  
  # Custom Validations
  def user_email_is_on_whitelist
    # QUESTION: using wrapper object that calls to external API as a service
    # Does that make sense?
    unless Service::WhitelistValidator.new(user_email).valid?
      errors.add(:user_email, "Your email is not on the whitelist")
    end
  end

  # Input parsing
  def join_newsletter
     ActiveRecord::Type::Boolean.new.cast(@join_newsletter)
  end

  # Strong Params for each internal model
  # QUESTION: the form object is taking the responsibility of strong params. Is this the right place?
  def user_params(signup_params)
    signup_params.require(:user).permit(:name, :email)
  end

  def organization_params(signup_params)
    signup_params.require(:organisation).permit(:name)
  end
  
  # Business logic

  def save_user
    @user.save!
  end

  def save_organisation
    @organisation.save!
  end

  def send_email_to_user
    UserMailer.send_welcome_email(user).deliver_later
  end

  def add_user_to_mailchimp
    Service::MailChimpContact.new(user).create
  end

  def enroll_user_to_newsletter
    Service::NewsLetterEnroller.new(user).enroll
  end


end

F) Questions yet to solve on my take
1. How to bubble up model level validation errors to populate the form's errors?
2. I'm not sure if this form object has too much responsibility: it is in charged of validating input AND orchestrating business logic.
3. I'm using a lot of small service objects that wrap api's to perform their logic (instead of doing it straight into the form object). Is that a good use of service objects and form objects?
4. The form object is also sanitizing the params that go into the user and the organisation (i.e it is doing strong params). Does this make sense?

G) How you can help
1. If you have the answer to any of the questions on my take, please say which question you are answering and your answer.
2. If you sympathize with my take but have improvements to suggest, please comment.
3. If you totally disagree with my take, please say where my approach erodes and suggest an alternate solution.  Please try to avoid blanket advice like "use trailblazer"  or  "use dry-gems".  Although they are perfectly valid, please show me how to use them with a little bit of code (the devil is always in the details).

Login or Create An Account to join the conversation.

Subscribe to the newsletter

Join 22,346+ developers who get early access to new screencasts, articles, guides, updates, and more.

    By clicking this button, you agree to the GoRails Terms of Service and Privacy Policy.

    More of a social being? We're also on Twitter and YouTube.