ActiveJob Object Mutability: The Photocopy Problem

phanil kumarphanil kumar
3 min read

One of the most confusing aspects of ActiveJob custom serializers isn't how to create them—it's understanding that deserialized objects are completely new instances.

Think of it like photocopying a document. The copy has the same content, but it's not the same piece of paper. Writing on the copy doesn't change the original.

The Problem in Action

# Create a counter object
counter = Counter.new(5)
puts counter.object_id  # => 12345

class MyJob < ApplicationJob
  def perform(counter)
    puts counter.object_id    # => 67890 (DIFFERENT!)
    puts counter.value        # => 5 (same content)

    counter.value = 10        # Change the copy
  end
end

MyJob.perform_later(counter)
puts counter.value  # => Still 5, not 10! 😱

Real-World Gotchas

1. Expecting Mutations to Persist

❌ This Won't Work:

shopping_cart = ShoppingCart.new
shopping_cart.total = 100

class UpdateCartJob < ApplicationJob
  def perform(cart)
    cart.total = 200  # Changes a COPY, not the original
  end
end

UpdateCartJob.perform_later(shopping_cart)
puts shopping_cart.total  # Still 100!

✅ Fix: Use Database Storage

class UpdateCartJob < ApplicationJob
  def perform(cart_id)
    cart = ShoppingCart.find(cart_id)  # Fresh from database
    cart.update!(total: 200)           # Persists changes
  end
end

UpdateCartJob.perform_later(shopping_cart.id)

2. Using Objects as Unique Keys

❌ This Won't Work:

processed_items = Set.new
money = Money.new(100, 'USD')
processed_items << money

class ProcessJob < ApplicationJob
  def perform(money, processed_items)
    if processed_items.include?(money)  # Always FALSE!
      puts "Already processed"
    else
      puts "Processing..."  # Always runs
    end
  end
end

✅ Fix: Use Value-Based Keys

processed_amounts = Set.new
money = Money.new(100, 'USD')
processed_amounts << "#{money.amount}-#{money.currency}"

class ProcessJob < ApplicationJob
  def perform(money, processed_amounts)
    money_key = "#{money.amount}-#{money.currency}"
    if processed_amounts.include?(money_key)  # Now works!
      puts "Already processed"
    end
  end
end

3. Shared Counters and State

❌ This Won't Work:

request_counter = RequestCounter.new
request_counter.count = 5

class TrackRequestJob < ApplicationJob
  def perform(counter)
    counter.increment!  # Increments the COPY
  end
end

# Each job gets its own copy
TrackRequestJob.perform_later(request_counter)
TrackRequestJob.perform_later(request_counter)
puts request_counter.count  # Still 5!

✅ Fix: Use External Storage

class TrackRequestJob < ApplicationJob
  def perform(user_id)
    Redis.current.incr("requests:#{user_id}")  # Shared counter
  end
end

The Golden Rules

  1. Treat serialized objects as read-only copies

  2. Store mutable state externally (database, Redis, files)

  3. Compare values, not object identity

  4. Pass IDs instead of objects when you need to mutate

Quick Test

Ask yourself: "If I photocopied this document and gave you the copy, would you expect changes to your copy to appear on my original?"

If the answer is no, don't expect it to work with ActiveJob either.

Design for Immutability

The best objects for ActiveJob serialization are immutable value objects:

class Money
  attr_reader :amount, :currency

  def initialize(amount, currency)
    @amount = amount
    @currency = currency
    freeze  # Make immutable
  end

  def ==(other)
    amount == other.amount && currency == other.currency
  end
end

Conclusion

ActiveJob serialization creates new objects with the same values, not references to the same objects. Understanding this "photocopy principle" will save you hours of debugging mysterious behavior where changes seem to disappear.

Design your serialized objects to be immutable value containers, and use external storage for any state that needs to persist or be shared between jobs.

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