Service Objects

Not your average method masquerading as a class behind a #call method

I’m a critic of service objects, not because they’re bad, but because they’re commonly misunderstood by well-meaning developers as a way to encapsulate business logic. The problem is how it’s implemented. Often a developer takes a perfectly good method.

# Perfectly good method
def hello(name)
  puts "Hello, #{name}!"
end

Then hides it behind a class, moves the parameters to an initializer, then hides the method invocation behind a #call method.

# The greeter service object
class Greeter < ServiceObject
  def initialize(name)
    @name = name
  end

  def call
    puts "Hello, #{@name}!"
  end
end

Now instead of 3 lines of code, we have 9. 🥳

A real service object

Remember how I said service objects are misunderstood? That’s because they’re actually good when properly used. For the Phlex on Rails video course I’m building out infrastructure for HLS video streaming on Tigris. Tigris is an S3-compatible object storage service that replicates data to servers close to my customers on Fly.io boxes.

Here’s how I set up a service object to interact with my Tigris S3 service that will store my video files.

Tigris = Aws::S3::Resource.new(
  access_key_id: ENV["VIDEO_AWS_ACCESS_KEY_ID"],
  secret_access_key: ENV["VIDEO_AWS_SECRET_ACCESS_KEY"],
  endpoint: ENV["VIDEO_S3_ENDPOINT_URL"]
)

Bucket = Tigris.bucket(ENV["VIDEO_S3_BUCKET_NAME"])

Then if I want to use the service, I call the methods you’d expect.

# Print the playlist
playlist = M3u8::Reader.new.read Bucket.object("introduction/index.m3u8").get.body.read

See how clear that is? You can read it and have a pretty good idea of how to use it if you roughly understand how the service would work.

Video controller

Here’s what the code currently looks like in the project. It’s a work in progress, but if a person opens this controller they can get a pretty good idea of the services they’re interacting with, how it’s configured, and then get to work in the controller actions.

require "net/http"
require "aws-sdk-s3"

class VideosController < ApplicationController
  SEGMENT_EXPIRES_IN = 1.hour

  Tigris = Aws::S3::Resource.new(
    access_key_id: ENV["VIDEO_AWS_ACCESS_KEY_ID"],
    secret_access_key: ENV["VIDEO_AWS_SECRET_ACCESS_KEY"],
    endpoint: ENV["VIDEO_S3_ENDPOINT_URL"]
  )

  Bucket = Tigris.bucket(ENV["VIDEO_S3_BUCKET_NAME"])

  def show
    respond_to do |format|
      format.m3u8 do
        list = read_m3u8(params.fetch(:variant), "index.m3u8")
        list.items.each do |item|
          segment = object(params.fetch(:variant), item.segment)
          item.segment = segment.presigned_url(:get, expires_in: Integer(SEGMENT_EXPIRES_IN))
        end
        render plain: list
      end
    end
  end

  def index
    respond_to do |format|
      format.m3u8 do
        render plain: read_m3u8("index.m3u8")
      end
    end
  end

  private

  def object(*)
    Bucket.object File.join(params.fetch(:path), *)
  end

  def read_object(*key)
    object(*key).get.body.read
  end

  def read_m3u8(*key)
    M3u8::Reader.new.read read_object(*key)
  end
end

I might move the Tigris and Bucket constants into an initializer, but for now it’s fine for prototyping and getting the video service up and running.

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