ruby 90 lines · 4 tabs

Enqueuing Notification Batches with a Custom Active Job Argument Serializer for Value Objects

Shared by codesnips Jul 2026
4 tabs
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
4 files · ruby Explain with highlit

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

Share this code

Here's the card — post it anywhere.

Enqueuing Notification Batches with a Custom Active Job Argument Serializer for Value Objects — share card
Link copied