Event serialization formats

By default RailsEventStore will use YAML as a serialization format. The reason is that YAML is available out of box and can serialize and deserialize data types which are not easily handled in other formats. As an example, JSON cannot out of box handle deserializing dates. You get back String instead of a Date.

However, if you don't like YAML or you have different needs you can choose to use different serializers (even whole mappers).

Configuring a different serializer

You can pass a different serializer as a dependency when instantiating the client.

Here is an example on how to configure RailsEventStore to serialize events' data and metadata using JSON.

# config/environments/*.rb

Rails.application.configure do
  config.to_prepare do
    Rails.configuration.event_store = RailsEventStore::Client.new(
      mapper: RubyEventStore::Mappers::Default.new(
        serializer: JSON
      )
    )
  end
end

The provided serializer must respond to load and dump.

Bear in mind that serializers have their limitations. For example JSON would convert symbols to strings and you have to prepare for that when retrieving events.

JSON.load(JSON.dump({foo: :bar}))
=> {"foo"=>"bar"}

One way to approach this is to have your own event adapter, specific for the project you're working on.

class MyEvent < RailsEventStore::Event
  def data
    ActiveSupport::HashWithIndifferentAccess.new(super)
  end
end

OrderPlaced = Class.new(MyEvent)

That shields you from data keys being transformed from symbols into strings. It doesn't do anything with data values though so beware.

event_store.publish(OrderPlaced.new(event_id: 'e34fc19a-a92f-4c21-8932-a10f6fb2602b', data: { foo: :bar }))
event = event_store.read.event('e34fc19a-a92f-4c21-8932-a10f6fb2602b')

event.data[:foo]
# => "bar"

event.data['foo']
# => "bar"

Another way to achive that could be define your own custom mapper & transformation.

Additionally, if you are using Postgres, you can configure the migration to use JSON or JSONB data types for the data and metadata fields by passing in a --data-type to the generator

$ rails generate rails_event_store_active_record:migration --data-type=jsonb

Configuring a different mapper

Configuring a different mapper makes it possible to define events however you want and store in them in the database. You no longer need to use RubyEventStore::Event (or RailsEventStore::Event) for events. Any object can be used as events, provided you tell us how to map it to columns that we store in the DB.

Available mappers

  • RubyEventStore::Mappers::Default
    • works with RubyEventStore::Event or RailsEventStore::Event
    • constructor takes named arguments:
    • serializer: (described above)
    • events_class_remapping: - which can be used for mapping old event names to new ones after you refactored your codebase.
  • RubyEventStore::Mappers::Protobuf
    • works with Ruby classes generated by google-protobuf gem
  • RubyEventStore::Mappers::NullMapper
    • works with RubyEventStore::Event or RailsEventStore::Event
    • no transformations, useful in tests
  • RubyEventStore::Mappers::EncryptionMapper
    • works with RubyEventStore::Event or RailsEventStore::Event
    • constructor takes named arguments:
    • key_repository - which is responsible for providing encryption keys used to encrypt/descrypt payload by encryption transformation
    • serializer: (described above)

Custom mapper

Mapper is defined as a pipeline of transformations that transforms domain event object into serialized record and back from serialized recoed to domain event. I.e. DefaultMapper is implemented as a set of 3 transformations. Transformations works on transformation item objects, it is given to transformation methods (it is expected that load & dump methods are implemented) and it is result of any transformation performed. Except transformations each pipeline mapper needs to be given 2 edge transformations:

Extended implementation of DefaultMapper with explicit default arguments:

module RubyEventStore
  module Mappers
    class Default < PipelineMapper
      def initialize(serializer: YAML, events_class_remapping: {})
        super(Pipeline.new(
          to_domain_event: Transformation::DomainEvent.new,
          to_serialized_record: Transformation::SerializedRecord.new,
          transformations: [
            Transformation::EventClassRemapper.new(events_class_remapping),
            Transformation::SymbolizeMetadataKeys.new,
            Transformation::Serialization.new(serializer: serializer),
          ]
        ))
      end
    end
  end
end

When you define new custom mapper you could use mapper PipelineMapper as base class and provide your transformations pipeline.

Each transformation must implement 2 methods: dump(item) & load(item). Both methods take a RubyEventStore::Mappers::Transformation::Item and returns new instance of it, transformed. Transformation shall not modify given transformation item and shall return new instance as a result.

If you use different classes to represent domain event & serialized record you shall also define edge transformations for your pipeline definition.

Example of custom mapper build based on mappers pipeline:

require 'msgpack'

class MessagePackSerialization
  def dump(item)
    RubyEventStore::Mappers::Transformation::Item.new(
      event_id:   item.event_id,
      metadata:   item.metadata.to_msg_pack,
      data:       item.data.to_msg_pack,
      event_type: item.event_type
    )
  end

  def load(item)
    RubyEventStore::Mappers::Transformation::Item.new(
      event_id:   item.event_id,
      metadata:   MessagePack.unpack(item.metadata),
      data:       MessagePack.unpack(item.data),
      event_type: item.event_type
    )
  end
end

class MyHashToMessagePackMapper < RubyEventStore::Mappers::PipelineMapper
  def initialize
    super(RubyEventStore::Mappers::Pipeline.new(
      transformations: [
        MessagePackSerialization.new,
      ]
    ))
  end
end

You could build your own transformations or use existing ones combined with the ones build by you. We strongly encourage you to share your transformations as a part of RES-contrib.

Check out the code of our mappers and transformations on github for examples on how to implement mappers & transformations.

You can pass a different mapper as a dependency when instantiating the client.

# config/environments/*.rb

Rails.application.configure do
  config.to_prepare do
    Rails.configuration.event_store = RailsEventStore::Client.new(
      mapper: MyHashToMessagePackMapper.new
    )
  end
end

Now you should be able to publish your events:

class OrderPlaced < RubyEventStore::Event
end

event_store = Rails.configuration.event_store

event_store.publish(OrderPlaced.new(data: {
  'event_id' => SecureRandom.uuid,
  'order_id' => 1,
  'order_amount' => BigDecimal.new('120.55'),
}), stream_name: 'Order$1')