Best Practices on organizing complex business logic
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.
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:
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).