ActiveJob Object Mutability: The Photocopy Problem


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
Treat serialized objects as read-only copies
Store mutable state externally (database, Redis, files)
Compare values, not object identity
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.
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