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.