module Notifications
class RecipientSerializer < ActiveJob::Serializers::ObjectSerializer
def serialize?(argument)
argument.is_a?(Notifications::Recipient)
end
def serialize(recipient)
super(
"channel" => recipient.channel.to_s,
"address" => recipient.address,
"name" => recipient.name
)
end
def deserialize(hash)
Notifications::Recipient.new(
channel: hash["channel"],
address: hash["address"],
name: hash["name"]
)
end
end
end
require_relative "boot"
require "rails/all"
Bundler.require(*Rails.groups)
module MyApp
class Application < Rails::Application
config.load_defaults 7.1
config.active_job.queue_adapter = :sidekiq
config.active_job.custom_serializers << "Notifications::RecipientSerializer"
end
end
module Notifications
class Recipient
ATTRS = %i[channel address name].freeze
attr_reader(*ATTRS)
def initialize(channel:, address:, name: nil)
@channel = channel.to_sym
@address = address
@name = name
freeze
end
def to_h
{ channel: channel, address: address, name: name }
end
def ==(other)
other.is_a?(Recipient) && to_h == other.to_h
end
alias eql? ==
def hash
to_h.hash
end
end
end
module Notifications
class NotificationBatchJob < ApplicationJob
queue_as :notifications
retry_on Net::OpenTimeout, wait: :polynomially_longer, attempts: 5
def self.enqueue_batch(recipients, dedupe_key:)
raise ArgumentError, "empty batch" if recipients.blank?
perform_later(recipients, dedupe_key)
end
def perform(recipients, dedupe_key)
return if DeliveryLog.exists?(dedupe_key: dedupe_key)
DeliveryLog.transaction do
DeliveryLog.create!(dedupe_key: dedupe_key, size: recipients.size)
recipients.each do |recipient|
Dispatcher.for(recipient.channel).deliver(
to: recipient.address,
name: recipient.name
)
end
end
end
end
end
Active Job only knows how to serialize a fixed set of primitive and GlobalID-backed types by default, so passing a plain Ruby value object as a job argument raises ActiveJob::SerializationError. This snippet shows the idiomatic Rails escape hatch: registering a custom ActiveJob::Serializer so a domain value object survives the round trip through Redis (or any queue backend) without leaking its internals into raw hashes.
In Notifications::Recipient, the value object is a frozen Struct with a channel and address. Making it immutable and comparable by value is what lets it behave as a stable payload — two recipients with the same fields are ==, which matters for dedup and testing. It knows nothing about jobs; it stays a pure domain type.
RecipientSerializer subclasses ActiveJob::Serializer::ObjectSerializer and implements the three required hooks. serialize? guards which objects this serializer claims, serialize returns a plain hash (the superclass injects the class key needed to route deserialization back here), and deserialize rebuilds the object from that hash. It is registered in config/application.rb via Rails.application.config.active_job.custom_serializers, which is the supported registration point.
NotificationBatchJob is where the batching happens. Rather than enqueuing one job per recipient, enqueue_batch accepts an array of Recipient value objects and pushes a single job — Active Job serializes each element of the array through the registered serializer transparently. The dedupe_key argument gives the job an idempotency anchor: perform checks a DeliveryLog before sending so a retried or double-enqueued batch does not double-notify.
The trade-off worth understanding is that custom serializers couple your queue payload format to the serializer's serialize/deserialize contract; changing the hash shape later requires handling old in-flight payloads, so keys are treated as a stable wire format. Value objects are preferred over passing GlobalID records here because recipients may not be persisted rows — they can be ad-hoc addresses computed at enqueue time. This pattern is the right reach whenever a job needs a rich, type-safe argument that is neither a primitive nor an Active Record model, keeping call sites clean while preserving type identity across the async boundary.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Money
include Comparable
attr_reader :amount, :currency
def initialize(amount, currency = 'USD')
Value objects for domain modeling
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
Share this code
Here's the card — post it anywhere.