Extending ActiveJob with Custom Serializers: A Practical Guide


When working with ActiveJob in Rails, you might encounter a frustrating limitation: not all Ruby objects can be passed as job arguments. By default, ActiveJob only supports basic types like strings, numbers, booleans, arrays, hashes, and Active Record objects. But what happens when you need to pass custom objects like value objects, service objects, or third-party library objects?
That's where custom serializers come to the rescue!
The Problem: Unsupported Argument Types
Let's say you're building an e-commerce application that processes payments. You have a Money
object from the popular Money gem, and you want to pass it to a background job:
class ProcessPaymentJob < ApplicationJob
def perform(amount, user)
PaymentService.charge(amount, user)
end
end
# This will fail! ๐ฅ
money = Money.new(2500, 'USD') # $25.00
ProcessPaymentJob.perform_later(money, current_user)
You'll get an error like:
ActiveJob::SerializationError: Unsupported argument type: Money
The Traditional Workaround (And Why It's Not Great)
Most developers work around this by breaking down the object:
# Passing individual components
ProcessPaymentJob.perform_later(money.amount, money.currency, current_user)
class ProcessPaymentJob < ApplicationJob
def perform(amount, currency, user)
money = Money.new(amount, currency)
PaymentService.charge(money, user)
end
end
This works, but it has several downsides:
Repetitive: You have to reconstruct the object in every job
Error-prone: Easy to forget a parameter or pass them in wrong order
Cluttered: Job signatures become messy with multiple primitive arguments
Brittle: Changes to the Money object require updating all jobs
The Solution: Custom Serializers
ActiveJob allows you to extend its serialization system by creating custom serializers. Here's how to do it properly:
Step 1: Create the Serializer
Create a new file app/serializers/money_serializer.rb
:
class MoneySerializer < ActiveJob::Serializers::ObjectSerializer
# This method determines if this serializer should handle the object
def serialize?(argument)
argument.is_a?(Money)
end
# Convert the complex object to simple, JSON-compatible data
def serialize(money)
super(
"amount" => money.amount,
"currency" => money.currency.to_s
)
end
# Reconstruct the original object from serialized data
def deserialize(hash)
Money.new(hash["amount"], hash["currency"])
end
end
Key Points:
serialize?
: Acts as a type guard - returnstrue
if this serializer should handle the objectserialize
: Converts your object to a hash of simple types (strings, numbers, booleans)deserialize
: Rebuilds your object from the serialized hashsuper
: Don't forget this! It adds metadata about which serializer was used
Step 2: Register the Serializer
Create config/initializers/custom_serializers.rb
:
Rails.application.config.active_job.custom_serializers << MoneySerializer
Step 3: Handle Autoloading
This is crucial! Add to config/application.rb
:
module YourApp
class Application < Rails::Application
# Serializers need to be loaded once, not reloaded during development
config.autoload_once_paths << "#{root}/app/serializers"
end
end
Why is this needed? ActiveJob needs access to serializers during initialization, before the normal autoloading system kicks in. Without this, your serializers might not be available when jobs are deserialized.
Step 4: Use It!
Now you can use your custom objects naturally:
money = Money.new(2500, 'USD')
ProcessPaymentJob.perform_later(money, current_user)
class ProcessPaymentJob < ApplicationJob
def perform(amount, user)
# amount is automatically a Money object again!
PaymentService.charge(amount, user)
end
end
Real-World Examples
Example 1: Address Value Object
class AddressSerializer < ActiveJob::Serializers::ObjectSerializer
def serialize?(argument)
argument.is_a?(Address)
end
def serialize(address)
super(
"street" => address.street,
"city" => address.city,
"state" => address.state,
"zip" => address.zip,
"country" => address.country
)
end
def deserialize(hash)
Address.new(
street: hash["street"],
city: hash["city"],
state: hash["state"],
zip: hash["zip"],
country: hash["country"]
)
end
end
Example 2: Configuration Object
class EmailConfigSerializer < ActiveJob::Serializers::ObjectSerializer
def serialize?(argument)
argument.is_a?(EmailConfig)
end
def serialize(config)
super(
"template" => config.template,
"subject" => config.subject,
"variables" => config.variables,
"send_at" => config.send_at&.iso8601
)
end
def deserialize(hash)
EmailConfig.new(
template: hash["template"],
subject: hash["subject"],
variables: hash["variables"],
send_at: hash["send_at"] ? Time.parse(hash["send_at"]) : nil
)
end
end
Best Practices
1. Keep Serialized Data Simple
Only use JSON-compatible types in your serialized hash:
# โ
Good
super("amount" => money.amount, "currency" => money.currency.to_s)
# โ Bad - symbols aren't JSON-compatible
super("amount" => money.amount, "currency" => money.currency.to_sym)
2. Handle Nil Values
def serialize(time_range)
super(
"start_time" => time_range.start_time&.iso8601,
"end_time" => time_range.end_time&.iso8601
)
end
def deserialize(hash)
TimeRange.new(
start_time: hash["start_time"] ? Time.parse(hash["start_time"]) : nil,
end_time: hash["end_time"] ? Time.parse(hash["end_time"]) : nil
)
end
3. Version Your Serializers
For production apps, consider versioning:
def serialize(money)
super(
"version" => 1,
"amount" => money.amount,
"currency" => money.currency.to_s
)
end
def deserialize(hash)
case hash["version"]
when 1
Money.new(hash["amount"], hash["currency"])
else
# Handle legacy format
Money.new(hash["amount"], hash["currency"])
end
end
4. Test Your Serializers
# spec/serializers/money_serializer_spec.rb
RSpec.describe MoneySerializer do
let(:serializer) { MoneySerializer.new }
let(:money) { Money.new(2500, 'USD') }
describe '#serialize?' do
it 'returns true for Money objects' do
expect(serializer.serialize?(money)).to be true
end
it 'returns false for other objects' do
expect(serializer.serialize?("not money")).to be false
end
end
describe 'round-trip serialization' do
it 'preserves the money object' do
serialized = serializer.serialize(money)
deserialized = serializer.deserialize(serialized)
expect(deserialized).to eq(money)
expect(deserialized.amount).to eq(2500)
expect(deserialized.currency.to_s).to eq('USD')
end
end
end
Common Pitfalls
1. Forgetting autoload_once_paths
Without proper autoloading setup, your serializers might not be available during job deserialization, leading to confusing errors.
2. Using Complex Types in Serialization
Don't nest other custom objects in your serialized hash unless they also have serializers.
3. Not Calling super
The super
call adds important metadata that ActiveJob uses to know which deserializer to use.
4. Assuming Object Mutability
The deserialized object is a new instance. Don't rely on object identity or mutation from the original object.
When to Use Custom Serializers
Custom serializers are perfect for:
Value objects (Money, Address, DateRange)
Configuration objects (EmailSettings, ApiConfig)
Third-party library objects (Geocoder::Result, etc.)
Immutable data structures
Avoid them for:
Active Record objects (use built-in GlobalID serialization)
Large objects (consider storing in cache/database instead)
Objects with complex dependencies (services, connections, etc.)
Conclusion
Custom serializers unlock the full power of ActiveJob by letting you pass any object as job arguments. They keep your job code clean, reduce errors, and make your background processing more maintainable.
The pattern is simple:
Create a serializer that inherits from
ObjectSerializer
Implement
serialize?
,serialize
, anddeserialize
Register it in your initializers
Set up proper autoloading
When designing objects for ActiveJob serialization, think of them as data transfer objects rather than stateful entities.
With custom serializers, your background jobs become as flexible and expressive as the rest of your Ruby code. No more breaking down complex objects into primitive arguments or reconstructing them in every job!
Subscribe to my newsletter
Read articles from phanil kumar directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

phanil kumar
phanil kumar
Ruby/Rails