The humble ActiveModel

ActiveModel is one of my most used tools in Rails applications. I use it in service objects, form objects and objects that represent external entities.

Why? Because it provides a nice interface for validating inputs and results, it can have callbacks for pre and post-processing data, and it integrates well into various Rails conventions.

A simple login form is my go to example for explaining how nice ActiveModel is to work with.

I have seen people do login flows in controllers, in User models and in interactors. But no other approach is as straightforward as creating a Login model.
#!/usr/bin/env ruby
# /app/models/login.rb
class Login
  include ActiveModel::Model
  include ActiveModel::Validations::Callbacks

  attr_accessor :username, :password

  validates :username, presence: true
  validates :password, presence: true
  validate :validate_user_exists!
  validate :validate_password_for_user!

  before_validation do
    @user = nil
  end

  def validate_user_exists!
    return if user.present?

    errors.add(:username, "not found")
  end

  def validate_password_for_user!
    # `authenticate` comes from Rails
    # Reference:  https://api.rubyonrails.org/classes/ActiveModel/SecurePassword/ClassMethods.html#method-i-has_secure_password
    return if user&.authenticate(password)

    errors.add(:password, "is invalid")
  end

  def user
    @user ||= User.find_by(username: username)
  end
end
Which can then be used in a controller like any other model:
#!/usr/bin/env ruby
class LoginsController < ApplicationController
  def new
    @login = Login.new
  end

  def create
    permitted_params = params.require(:login).permit(:username, :password)
    @login = Login.new(permitted_param)

    if @login.valid?
      session[:current_user_id] = @login.user.id
      redirect_to root_path, notice: "Welcome back!"
    else
      render :new, status: :unprocessable_entity
    end
  end
end
You can use form_for without extra arguments or configuration, I18n works for the attributes and error messages just like it does for ActiveRecord models, and if the login fails the fields get repopulated.

It looks and feels like you are working with an ActiveRecord model, which most of us know and love.

When it comes to Service objects, the ActiveModel approach offers a way to communicate success and failure through validations. You can check the inputs with valid?, or you can check the result, and return a nice error message.
#!/usr/bin/env ruby
class Device::WarehouseReservator < ApplicationModel
  attr_accessor :device, :user

  validates :device, presence: true
  validates :user, presence: true

  after_initialize do
    self.user ||= device.creator
  end

  def reserve!
    return false if invalid?

    response = HTTP.post("http://device.inventory.com/api/v1/register", data: payload)

    if response.ok?
      errors.add(:device, failed to register)
      return false
    end

    self.warehouse_reference = device.create_warehouse_reference(reservation_id: JSON.parse(response.body).fetch(:id))
  end

  def payload
    { device_id: device.id, name: device.name, reservation_holder: user.name }
  end
end

reservator = Device::WarehouseReservator.new(user: User.all.sample, device: Device.all.sample.reserver!
reservator.valid? # => true
reservator.reserver!
Reservator.warehouse_reference.id # => 1

reservator = Device::WarehouseReservator.new(user: nil, device: nil)
reservator.valid? # => false
reservator.reserver! # => false
reservator.errors.full_messages # => ["User missing", "Device missing"]
This is a much nicer way to communicate what went wrong than raising and catching errors, returning symbols or custom Error objects.
ActiveModel is quite a versatile tool for that alone, but there is much more that it can do like:

P.S. Many thanks to Hrvoje S. For reviwing this article.
Subscribe to the newsletter to receive future posts via email