How I "Service Object"

Where I put business logic in a Rails application

When I build out Rails applications these days, I think of ActiveRecord classes purely as something that validates data and controllers as a way to interface HTTP into these data objects.

More often than not, I have to create controllers that do slightly more complicated things than a CRUD interface into the database, so where does the business logic live? I keep it in the ./app/models directory and use namespaces to help organize it where it makes sense.

Let’s have a look at how I deal with license validation for Terminalwire.

Data validation models

Here’s what the License model looks like at Terminalwire. You’ll notice it’s mostly data validation and dealing with making data easier to work with from Ruby.

class License < ApplicationRecord
  belongs_to :user, required: true
  belongs_to :account, required: true

  has_one :distribution
  has_many :verifications,
    class_name: "License::Verification"

  # Only allow https or wss for production deployments.
  URL_SCHEMES = %w(https wss)
  URL_FORMAT = URI::DEFAULT_PARSER.make_regexp(URL_SCHEMES)
  validates :url,
    presence: true,
    format: {
      with: URL_FORMAT,
      message: "must be a valid #{URL_SCHEMES.to_sentence(words_connector: " or ", two_words_connector: " or ")} URL"
    }
  normalizes :url, with: ->(value) { value.chomp }

  STATUSES = %w[
    pending
    payment_required
    active
    expired
    inactive
  ]
  validates :status, presence: true, inclusion: { in: STATUSES }

  def status
    ActiveSupport::StringInquirer.new(self[:status]) if self[:status]
  end

  before_validation :assign_initial_status, on: :create

  def assign_initial_status
    self.status = "payment_required" if product&.paid?
  end

  def name
    URI(url).then do |url|
      [
        url.host,
        url.path,
      ].join
    end
  end

  validates :key, presence: true, uniqueness: true

  before_validation :assign_random_key, on: :create

  TERMS = %w[
    subscription
    perpetual
  ].freeze

  validates :term, presence: true, inclusion: { in: TERMS }
  attribute :term, :string, default: "perpetual"

  TIERS = %w[
    core
    pro
    enterprise
    startup
    business
  ].freeze

  validates :tier, presence: true, inclusion: { in: TIERS }
  attribute :tier, :string, default: "core"

  def self.random_key
    SecureRandom.uuid
  end

  def product
    ProductPageModel.key(tier)
  end

  protected

  def assign_random_key
    self.key ||= self.class.random_key
  end
end

These records are created by developers who acquire a license for Terminalwire. Missing is the code that validates the license.

Not a Service Object

When I end up with complicated logic that fits within one model or coordinates multiple models, I create a plain ol’ Ruby object and namespace it.

In this case, I created a License::Verifier object that accepts a request object in the save_verifications method and creates a bunch of License::Verification objects in the database. If a license is valid, it’s found in the databaes and logged as a successful license verification. If it’s not found, it’s still saved in the database as an unsuccessful verification and doesn’t have an association license.

class License::Verifier
  include ActiveModel::Model
  include ActiveModel::Attributes

  VERSION = "1.0".freeze

  attribute :url, :string
  validates :url, presence: true

  attribute :status, :string
  attribute :created_at, :string, default: -> { Time.now.utc.iso8601 }

  def data
    {
      version: VERSION,
      url:,
      shell:,
      status:,
      created_at:
    }
  end

  def save_verifications(request)
    validate

    ip_address = request.headers.fetch("Fly-Client-IP", request.remote_ip)
    user_agent = request.user_agent

    if licenses.any?
      licenses.each { _1.verifications.create! url:, ip_address:, user_agent: }
    else
      License::Verification.create! url:, ip_address:, user_agent:
    end
  end

  # Displays message to the Terminalwire client. Ideally this would use Terminalwire's
  # resources, but when I implemented this the protocol would send an unexpected ack to
  # the server. This is a workaround.
  #
  # In the future when the protocol is fixed, add a `commands` key to the protocol that
  # can be used to send messages to the client and dispatched to the appropriate resource.
  def shell
    if valid?
      {
        device: "stdout",
        output: nil
      }
    else
      {
        device: "stderr",
        output: "\n⚠️ Can't find a valid Terminalwire server license for #{url}.\n\n"
      }
    end
  end

  def pack
    MessagePack.pack **data
  end

  # We don't need to verify localhost URLs.
  LOCALHOSTS = %w[
    localhost
    127.0.0.1
    0.0.0.0
    ::1
  ]

  def host
    URI(url).host if url
  end

  def localhost?
    host.in? LOCALHOSTS
  end

  def licenses
    License.where(url:)
  end

  def to_param = self.class.escape url
  def persisted? = url.present?

  def self.find(url)
    new url: unescape(url)
  end

  def self.unescape(url)
    Base64.urlsafe_decode64(url) if url.present?
  end

  def self.escape(url)
    Base64.urlsafe_encode64 url if url.present?
  end
end

Since we’re bikeshedding a bit, I’ll admit that it’s not ideal having HTTP request logic outside of a controller, but reality is messy and sometimes it makes sense to do it this way. I know if it stops making sense, it’s easy for me to move it back out into a controller.

Speaking of controllers, let’s have a look at how this all comes together.

The controller

Here’s what the controller endpoint looks like that the Terminalwire-client calls to validate a license. There’s also an HTML UI that validates a license as well.

module Licenses
  class VerificationsController < ApplicationController
    layout false

    # Sets duration in a header that the client shells use
    # to cache license check responses.
    VERIFICATION_CACHE_TTL = 1.day

    before_action do
      @verifier = License::Verifier.find(params[:id])
    end

    def index
      @verifiers = License::Verifier.all
    end

    # ...

    def show
      if @verifier.valid?
        expires_in VERIFICATION_CACHE_TTL, public: true
      else
        response.status = :not_found
      end

      @verifier.save_verifications request

      respond_to do |format|
        format.html { render phlex }
        format.msgpack { render plain: @verifier.pack }
      end
    end
  end
end

Putting it all together

When a request comes in that’s msgpack format, I “find” a license verifier via License::Verifier.find(params[:id]). That tries to find a License that a developer created, then runs business logic that verifies the license. All License::Verification objects are written to the database.

Do you want to learn Phlex 💪 and enjoy these code examples?

Support Beautiful Ruby by pre-ordering the Phlex on Rails video course.

Order the Phlex on Rails video course for $379 $289