Seven Days of Design Patterns | Part 3 - Form Objects
May 11, 2019
Welcome back to another episode of Design Patterns with yours truly. Today we are going to spend a little bit of time reviewing object-powered forms aka “Form Objects”.
Let’s set the stage by diving into a ficticious software team and observing how they work and react to requirements changing. AwesomePeople Inc is in the middle of launching their new social media platform. The last feature before they can ship is to create a user registration form. It is not quite that simple though, the business wants this site to be viral and has decided to only allow students with a harvard.edu email address to sign up. The team decides to add the validation to the model and ship it.
class User < ApplicationRecord
validates :name, :email, :username, :password
validates :email, format: { with: /@harvard.edu/ }
end
The site launches and is a smashing success on campus! Now the marketing team wants to expand to Yale, Stanford, and MIT. This is no problem for the dev team. They add a few conditionals to the User
model and they ship it.
class User < ApplicationRecord
attr_accessor :school_suffix_requried
SUPPORTED_EMAIL_DOMAINS = %w(
harvard.edu
standford.edu
yale.edu
mit.edu
)
validates :name, :email, :username, :password
validates :email, format: { with: /@harvard.edu/ }, if: -> { school_suffix_requried == 'harvard.edu' }
validates :email, format: { with: /@yale.edu/ }, if: -> { school_suffix_requried == 'yale.edu' }
validates :email, format: { with: /@stanford.edu/ }, if: -> { school_suffix_requried == 'stanford.edu' }
validates :email, format: { with: /@mit.edu/ }, if: -> { school_suffix_requried == 'mit.edu' }
validates :email, inclusion: { in: SUPPORTED_EMAIL_DOMAINS }, if: -> { school_suffix_requried.blank? },
message: 'domain is not currently supported'
end
So far everyone is happy with the solutions the team has continued to ship. They are shipping fast, meeting requirements, and delighting users. This is when the marketing teams decides to make things interesting. Instead of expanding to new schools they want each school to be handled uniquely, as specified in the list below.
- For Harvard students, we need to send a “Harvard branded” email on signup
- For Stanford students, we need to send a “Stanford branded” email on signup and a text
- For Yale students, we need to send an email with no school branding
- For MIT students:
- We need to hit an API they are providing to record the stats of who signed up
- We need to send an “MIT branded” email on signup
Looking at the list of the requirements, Steve and the rest of the dev team begin to scratch their chins. They decide to add the functional code first and pair on improvements later.
class User < ApplicationRecord
attr_accessor :school_suffix_requried
attr_accessor :mobile_number
SUPPORTED_EMAIL_DOMAINS = %w(
harvard.edu
standford.edu
yale.edu
mit.edu
)
validates :name, :email, :username, :password
validates :email, format: { with: /@harvard.edu/ }, if: -> { school_suffix_requried == 'harvard.edu' }
validates :email, format: { with: /@yale.edu/ }, if: -> { school_suffix_requried == 'yale.edu' }
validates :email, format: { with: /@stanford.edu/ }, if: -> { school_suffix_requried == 'stanford.edu' }
validates :email, format: { with: /@mit.edu/ }, if: -> { school_suffix_requried == 'mit.edu' }
validates :email, format: {
with: /[@harvard.edu|@yale.edu|@stanford.edu|@mit.edu]/
},
if: -> { school_suffix_requried.blank? },
message: 'domain is not currently supported'
validates :mobile_phone_number, if: -> { school_suffix_requried == 'mit.edu' }
validates :mobile_phone_number, if: -> { school_suffix_requried == 'mit.edu' }
after_create :send_harvard_email, if: -> { school_suffix_requried == 'harvard.edu' }
after_create :send_stanford_email, if: -> { school_suffix_requried == 'stanford.edu' }
after_create :send_text, if: -> { school_suffix_requried == 'stanford.edu' }
after_create :send_email, if: -> { school_suffix_requried == 'yale.edu' }
after_create :send_mit_email, if: -> { school_suffix_requried == 'mit.edu' }
after_create :send_stats_to_api, if: -> { school_suffix_requried == 'mit.edu' }
private
def send_text
Twilio.sms_messages.create(number: mobile_phone_number, body: "Thank you for signing up with Amazing Inc!")
end
def send_harvard_email
UserMailer.harvard_signup(self).deliver
end
def send_stanford_email
UserMailer.standford_signup(self).deliver
end
def send_mit_email
UserMailer.mit_signup(self).deliver
end
def send_email
UserMailer.signup(self).deliver
end
def send_stats_to_api
HTTParty.post('https://signup-stats.mit.edu/api', body: { email: user.email })
end
end
One of the newer devs on the team, Ben, reaches out to the team on Slack regarding the code. He says that this is starting to smell and will become really hairy as more and more schools are supported. He asks Steve to “pair up” to see what the two of them can clean up together. Before they get started, Ben suggests that they draft some general requirements and go from there. Here is what they came up with:
- Users need to be able to sign up based on school/domain-name
- Users sometimes need tailored/branded emails upon signup success
- Users sometimes need unbranded/standard emails upon signup success
- Users sometimes need to receive text messages upon signup success
- For certain users they need to send API calls after a successful signup
As they list this all out it starts to become clear that they have two general things to account for. First, they need to make sure they have valid data in each case…meaning the standard fields and whatever nuanced ones the customers require (like cell number). The second general requirement is support for custom “success handling” for each school.
Steve suggests extracting out the school concept into an object and proposes it like so:
class School < ApplicationRecord
has_many :users
# The fields:
# name: string | General identifier like "Yale" or "Stadford"
# email_domain: string | email suffix such as "mit.edu"
# slug: friendly identifer such as "brown-university"
# branded_email: boolean
# signup_email: boolean
# signup_text: boolean
end
class User < ApplicationRecord
attr_accessor :mobile_number
belongs_to :school
validates :name, :email, :username, :password
validates :mobile_phone_number, if: -> { school&.signup_text? }
validates :validate_email_domain_for_school, if: -> { email.present? && school.present? }
validates :validate_email_for_supported_schools, if: -> { email.present? && school.nil? }
after_create :send_branded_email, if: -> { school&.signup_email? && school&.branded_email? }
after_create :send_standard_email, if: -> { school&.signup_email? && !school&.branded_email? }
after_create :send_text, if: -> { school&.signup_text? }
after_create :send_stats_to_api, if: -> { school_suffix_requried == 'mit.edu' }
private
def validate_email_domain_for_school
pattern = "/@#{school.email_domain}/"
return if email.match?(pattern)
errors.add(:email, "is not currently supported")
end
def validate_email_for_supported_schools
valid_domains = School.pluck(:email_domain).map{ |domain| "@#{domain}" }
pattern = "/[#{valid_domains.join('|')}]/"
return if email.match?(pattern)
errors.add(:email, "is not currently supported")
end
def send_text
Twilio.sms_messages.create(number: mobile_phone_number, body: "Thank you for signing up with Amazing Inc!")
end
def send_branded_email
UserMailer.branded_signup(self, school).deliver
end
def send_standard_email
UserMailer.signup(self, school).deliver
end
def send_stats_to_api
HTTParty.post('https://signup-stats.mit.edu/api', body: { email: user.email })
end
end
Ben is getting stoked. The signup emails and texts definitely seem to be handled in a more straight forward and standard way. Not only that, but adding support for additional schools could now be as simple as adding a new row in the database. That is pretty valuable and really enables the marketing team to move quicker. Not only that, but this move could also allow the DRYing up of the controllers and view code!
A few things are still bothering him though. That pesky send_stats_to_api
callback on user is making testing more complicated than it should be. Not only that, but it is a special case that doesn’t seem to apply to other schools. It seems that sometimes schools want to introduce requirements that other schools may never use. Last of all..testing the user model is still complicated and involves stubbing out API calls, mailers, etc.
As Ben and Steve are pairing, Ben shares that he heard of a pattern called “Form Objects” which help with issues like this. Mainly, separating UI/UX behaviors and needs from data models. Steve wasn’t aware of this pattern and decides to let Ben drive the session while he observes and learns. Here is what Ben produces:
class UserSignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :school, School
attribute :email, String
attribute :mobile_phone_number, String
attribute :name, String
attribute :username, String
attribute :password, String
validates :name, :email, :username, :password
validates :mobile_phone_number, if: -> { school&.signup_text? }
validates :validate_email_domain_for_school, if: -> { email.present? && school.present? }
validates :validate_email_for_supported_schools, if: -> { email.present? && school.nil? }
def save
if valid?
persist!
else
false
end
end
private
def validate_email_domain_for_school
pattern = "/@#{school.email_domain}/"
return if email.match?(pattern)
errors.add(:email, "is not currently supported")
end
def validate_email_for_supported_schools
valid_domains = School.pluck(:email_domain).map{ |domain| "@#{domain}" }
pattern = "/[#{valid_domains.join('|')}]/"
return if email.match?(pattern)
errors.add(:email, "is not currently supported")
end
def user_attributes
attributes.slice(:school, :email, :name, :username, :password, :mobile_phone_number)
end
def persist!
user = User.new(user_attributes)
ApplicationRecord.transaction do
if user.save
SignupUser.new(user).call
else
flush_errors_to_form(user)
end
end
end
def flush_errors_to_form(user)
user.errors.each_pair do |field, messages|
error_field = respond_to?(field) ? field : :base
messages.each { |message| errors.add(error_field, message) }
end
end
end
And he creates the complimentary “Service” for the user signup process:
class SignupUser
def initialize(user, school)
@user = user
@school = school
end
def call
send_branded_email
send_standard_email
send_text
send_stats_to_api
true
end
private
def send_text
if @school&.signup_text?
Twilio.sms_messages.create(number: mobile_phone_number, body: "Thank you for signing up with Amazing Inc!")
end
end
def send_branded_email
if @school&.signup_email? && @school&.branded_email?
UserMailer.branded_signup(@user, @school).deliver
end
end
def send_standard_email
if @school&.signup_email? && !@school&.branded_email?
UserMailer.signup(@user, @school).deliver
end
end
def send_stats_to_api
if @school&.slug == 'mit'
HTTParty.post('https://signup-stats.mit.edu/api', body: { email: @user.email })
end
end
end
Finally, Ben cleans up the User model.
class User < ApplicationRecord
belongs_to :school, optional: true
validates :name, :email, :username, :password
end
Steve has taken all this change in and has been mulling it over. A few seconds after Ben finishes his refactor he asks about the complexity and overhead of additional objects. Ben is more than thrilled to be able to teach Steve the benefits of this pattern and so he lists out what this refactor achieved:
- Creating a user model in tests or elsewhere in the codebase no longer triggers emails, texts, or API calls
- Changing form error messages or fields in the UI simply translate to alterations in the one form model processing that concern
- Handling sign up success functionality in one spot that is easily changed allows the team to handle that concern with surgical precision. There is only one place that logic lives.
After a few moments Steve’s eyes brighten up and he starts to get excited. In the “Admin” section of the site there is an ability to create users for AwesomePeople Inc. When these users are added, no side-effects should happen. Meaning: no emails, texts, API calls, or otherwise should be triggered..regardless of if they are assigned a school or not.
The scene fades with a close up of Steve and Ben’s monitor. They are going through all of their tests involving users and deleting the stubbed out email, text, and API logic from their previous implementation.
Isn’t it a wonderful feeling to delete code? Or how about removing side-effects from your tests, making them simpler and easier to reason about and change? That is really what the form object pattern is about. It is about capturing the UI needs of a form. Typically, one that needs to save more than one object at a time or when there are special processing needs; like we saw in the story above.
I hope you enjoyed the story and maybe even found a solution to a personal pain in a project you are currently on. Til next week’s design pattern session, see you soon!