Extending ActiveJob with Custom Serializers: A Practical Guide

phanil kumarphanil kumar
5 min read

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 - returns true if this serializer should handle the object

  • serialize: Converts your object to a hash of simple types (strings, numbers, booleans)

  • deserialize: Rebuilds your object from the serialized hash

  • super: 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:

  1. Create a serializer that inherits from ObjectSerializer

  2. Implement serialize?, serialize, and deserialize

  3. Register it in your initializers

  4. Set up proper autoloading

  5. 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!

0
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