Skip to content

Serializers

Serializers handle the encoding and decoding of message content, transforming Ruby objects into wire formats suitable for transmission and storage.

Overview

Serializers are responsible for: - Encoding: Converting SmartMessage instances to transmittable formats - Decoding: Converting received data back to Ruby objects - Format Support: Handling different data formats (JSON, XML, MessagePack, etc.) - Type Safety: Ensuring data integrity during conversion

Built-in Serializers

JSON Serializer

The default serializer that converts messages to/from JSON format.

Features: - Human-readable output - Wide compatibility - Built on Ruby's standard JSON library - Automatic property serialization

Usage:

# Basic usage
serializer = SmartMessage::Serializer::Json.new

# Configure in message class
class UserMessage < SmartMessage::Base
  property :user_id
  property :email
  property :preferences

  config do
    serializer SmartMessage::Serializer::Json.new
  end
end

# Manual encoding/decoding
message = UserMessage.new(user_id: 123, email: "user@example.com")
encoded = serializer.encode(message)
# => '{"user_id":123,"email":"user@example.com","preferences":null}'

Encoding Behavior: - All defined properties are included - Nil values are preserved - Internal _sm_ properties are included in serialization - Uses Ruby's #to_json method under the hood

Serializer Interface

All serializers must implement the SmartMessage::Serializer::Base interface:

Required Methods

class CustomSerializer < SmartMessage::Serializer::Base
  def initialize(options = {})
    @options = options
    # Custom initialization
  end

  # Convert SmartMessage instance to wire format
  def encode(message_instance)
    # Transform message_instance to your format
    # Return string or binary data
  end

  # Convert wire format back to hash
  def decode(payload)
    # Transform payload string back to hash
    # Return hash suitable for SmartMessage.new(hash)
  end
end

Example: MessagePack Serializer

require 'msgpack'

class MessagePackSerializer < SmartMessage::Serializer::Base
  def encode(message_instance)
    message_instance.to_h.to_msgpack
  end

  def decode(payload)
    MessagePack.unpack(payload)
  end
end

# Usage
class BinaryMessage < SmartMessage::Base
  property :data
  property :timestamp

  config do
    serializer MessagePackSerializer.new
  end
end

Example: XML Serializer

require 'nokogiri'

class XMLSerializer < SmartMessage::Serializer::Base
  def encode(message_instance)
    data = message_instance.to_h
    builder = Nokogiri::XML::Builder.new do |xml|
      xml.message do
        data.each do |key, value|
          xml.send(key, value)
        end
      end
    end
    builder.to_xml
  end

  def decode(payload)
    doc = Nokogiri::XML(payload)
    hash = {}
    doc.xpath('//message/*').each do |node|
      hash[node.name] = node.text
    end
    hash
  end
end

Serialization Patterns

Type Coercion

Handle type conversions during serialization:

class TypedSerializer < SmartMessage::Serializer::Base
  def encode(message_instance)
    data = message_instance.to_h

    # Convert specific types
    data.transform_values do |value|
      case value
      when Time
        value.iso8601
      when Date
        value.to_s
      when BigDecimal
        value.to_f
      else
        value
      end
    end.to_json
  end

  def decode(payload)
    data = JSON.parse(payload)

    # Convert back from strings
    data.transform_values do |value|
      case value
      when /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/
        Time.parse(value)
      else
        value
      end
    end
  end
end

Nested Object Serialization

Handle complex nested structures:

class NestedSerializer < SmartMessage::Serializer::Base
  def encode(message_instance)
    data = deep_serialize(message_instance.to_h)
    JSON.generate(data)
  end

  def decode(payload)
    data = JSON.parse(payload)
    deep_deserialize(data)
  end

  private

  def deep_serialize(obj)
    case obj
    when Hash
      obj.transform_values { |v| deep_serialize(v) }
    when Array
      obj.map { |v| deep_serialize(v) }
    when SmartMessage::Base
      # Serialize nested messages
      obj.to_h
    else
      obj
    end
  end

  def deep_deserialize(obj)
    case obj
    when Hash
      obj.transform_values { |v| deep_deserialize(v) }
    when Array
      obj.map { |v| deep_deserialize(v) }
    else
      obj
    end
  end
end

Serialization Options

Configurable Serializers

class ConfigurableJSONSerializer < SmartMessage::Serializer::Base
  def initialize(options = {})
    @pretty = options[:pretty] || false
    @exclude_nil = options[:exclude_nil] || false
    @date_format = options[:date_format] || :iso8601
  end

  def encode(message_instance)
    data = message_instance.to_h

    # Remove nil values if requested
    data = data.compact if @exclude_nil

    # Format dates
    data = format_dates(data)

    # Generate JSON
    if @pretty
      JSON.pretty_generate(data)
    else
      JSON.generate(data)
    end
  end

  private

  def format_dates(data)
    data.transform_values do |value|
      case value
      when Time, Date
        case @date_format
        when :iso8601
          value.iso8601
        when :unix
          value.to_i
        when :rfc2822
          value.rfc2822
        else
          value.to_s
        end
      else
        value
      end
    end
  end
end

# Usage with options
class TimestampMessage < SmartMessage::Base
  property :event
  property :timestamp

  config do
    serializer ConfigurableJSONSerializer.new(
      pretty: true,
      exclude_nil: true,
      date_format: :unix
    )
  end
end

Error Handling

Serialization Errors

Handle encoding/decoding failures gracefully:

class SafeSerializer < SmartMessage::Serializer::Base
  def encode(message_instance)
    JSON.generate(message_instance.to_h)
  rescue JSON::GeneratorError => e
    # Log the error
    puts "Serialization failed: #{e.message}"

    # Fallback to simple string representation
    message_instance.to_h.to_s
  end

  def decode(payload)
    JSON.parse(payload)
  rescue JSON::ParserError => e
    # Log the error
    puts "Deserialization failed: #{e.message}"

    # Return error indicator or empty hash
    { "_error" => "Failed to deserialize: #{e.message}" }
  end
end

Validation During Serialization

class ValidatingSerializer < SmartMessage::Serializer::Base
  def encode(message_instance)
    validate_before_encoding(message_instance)
    JSON.generate(message_instance.to_h)
  end

  def decode(payload)
    data = JSON.parse(payload)
    validate_after_decoding(data)
    data
  end

  private

  def validate_before_encoding(message)
    required_fields = message.class.properties.select do |prop|
      message.class.required?(prop)
    end

    missing = required_fields.select { |field| message[field].nil? }

    if missing.any?
      raise "Missing required fields: #{missing.join(', ')}"
    end
  end

  def validate_after_decoding(data)
    unless data.is_a?(Hash)
      raise "Expected hash, got #{data.class}"
    end

    # Additional validation logic
  end
end

Performance Considerations

Binary Serialization

For high-performance scenarios, consider binary formats:

class ProtobufSerializer < SmartMessage::Serializer::Base
  def initialize(proto_class)
    @proto_class = proto_class
  end

  def encode(message_instance)
    proto_obj = @proto_class.new(message_instance.to_h)
    proto_obj.serialize_to_string
  end

  def decode(payload)
    proto_obj = @proto_class.parse(payload)
    proto_obj.to_h
  end
end

# Usage
UserProto = Google::Protobuf::DescriptorPool.generated_pool.lookup("User").msgclass

class UserMessage < SmartMessage::Base
  property :user_id
  property :name

  config do
    serializer ProtobufSerializer.new(UserProto)
  end
end

Streaming Serialization

For large messages, consider streaming:

class StreamingSerializer < SmartMessage::Serializer::Base
  def encode(message_instance)
    StringIO.new.tap do |io|
      JSON.dump(message_instance.to_h, io)
    end.string
  end

  def decode(payload)
    StringIO.new(payload).tap do |io|
      JSON.load(io)
    end
  end
end

Compression Support

Compressed Serialization

class CompressedJSONSerializer < SmartMessage::Serializer::Base
  def encode(message_instance)
    json_data = JSON.generate(message_instance.to_h)
    Zlib::Deflate.deflate(json_data)
  end

  def decode(payload)
    json_data = Zlib::Inflate.inflate(payload)
    JSON.parse(json_data)
  end
end

# Usage for large messages
class LargeDataMessage < SmartMessage::Base
  property :dataset
  property :metadata

  config do
    serializer CompressedJSONSerializer.new
  end
end

Testing Serializers

Serializer Testing Patterns

RSpec.describe CustomSerializer do
  let(:serializer) { CustomSerializer.new }
  let(:message) do
    TestMessage.new(
      user_id: 123,
      email: "test@example.com",
      created_at: Time.parse("2025-08-17T10:30:00Z")
    )
  end

  describe "#encode" do
    it "produces valid output" do
      result = serializer.encode(message)
      expect(result).to be_a(String)
      expect(result).not_to be_empty
    end

    it "includes all properties" do
      result = serializer.encode(message)
      # Format-specific assertions
    end
  end

  describe "#decode" do
    it "roundtrips correctly" do
      encoded = serializer.encode(message)
      decoded = serializer.decode(encoded)

      expect(decoded["user_id"]).to eq(123)
      expect(decoded["email"]).to eq("test@example.com")
    end
  end

  describe "error handling" do
    it "handles invalid input gracefully" do
      expect { serializer.decode("invalid") }.not_to raise_error
    end
  end
end

Mock Serializer for Testing

class MockSerializer < SmartMessage::Serializer::Base
  attr_reader :encoded_messages, :decoded_payloads

  def initialize
    @encoded_messages = []
    @decoded_payloads = []
  end

  def encode(message_instance)
    @encoded_messages << message_instance
    "mock_encoded_#{message_instance.object_id}"
  end

  def decode(payload)
    @decoded_payloads << payload
    { "mock" => "decoded", "payload" => payload }
  end

  def clear
    @encoded_messages.clear
    @decoded_payloads.clear
  end
end

Common Serialization Issues

Handling Special Values

class RobustJSONSerializer < SmartMessage::Serializer::Base
  def encode(message_instance)
    data = sanitize_for_json(message_instance.to_h)
    JSON.generate(data)
  end

  private

  def sanitize_for_json(obj)
    case obj
    when Hash
      obj.transform_values { |v| sanitize_for_json(v) }
    when Array
      obj.map { |v| sanitize_for_json(v) }
    when Float
      return nil if obj.nan? || obj.infinite?
      obj
    when BigDecimal
      obj.to_f
    when Symbol
      obj.to_s
    when Complex, Rational
      obj.to_f
    else
      obj
    end
  end
end

Character Encoding

class EncodingAwareSerializer < SmartMessage::Serializer::Base
  def encode(message_instance)
    data = message_instance.to_h
    json = JSON.generate(data)
    json.force_encoding('UTF-8')
  end

  def decode(payload)
    # Ensure proper encoding
    payload = payload.force_encoding('UTF-8')
    JSON.parse(payload)
  end
end

Next Steps

  • Transports - How serializers work with transports
  • Examples - Real-world serialization patterns