Avoid coupling between Bounded Contexts using Weak Schema
… and check why 5600+ Rails engineers read also this
Avoid coupling between Bounded Contexts using Weak Schema
The Rails Event Store comes with a bounded_context gem (and a generator) that simplifies making your application modular. Using the command:
rails generate bounded_context:bounded_context YOUR-BOUNDED-CONTEXT-NAME
you can quickly generate folder structure, add load path and start working on implementing your business logic without friction.
You could see how we’ve used this gem in our sample application showing how to use CQRS and Event Sourcing to implement a sample business process.
The idea of bounded context is to have separate modules, with its own
ubiquitous language and with its concepts (you know, a Customer
in
ordering context could be a DeliveryAddress
in the shipping context, and a Customer
in invoicing context may be a different concept than the one on ordering context).
But we still need to communicate the business events between contexts. Because no context is an information silo. We build systems. We build things that cooperate. That system cooperation is what makes the difference. This is where business processes are defined. Probably a lot of companies have similar ordering, invoicing & shipping contexts build in their system. Also, most of them are quite similar. At least the non-core ones. Is there any e-commerce company that builds its invoicing system? Yeah, I know Amazon might have one, but do you think it is a reasonable thing to invest in building custom invoicing system by small e-commerce shop? Or it is better to buy access to an existing solution and invest in integrating it into your business process?
Coupling here
And that’s the place where the coupling is introduced. At least in our simple (sometimes too simple) sample application. So where the coupling is?
Event sourced aggregates defined in modules (bounded contexts) are using
module defined domain events for both - storing state changes (event sourcing) and
for communicating business events between system components (via Rails Event Store pub/sub).
The default configuration of RailsEventStore::Client
uses a mapper with YAML
serializer. Also the RailsEventStore::Event
uses class name as event type.
And here is the problem.
Why?
Why there is a problem?
This is coupling we have on several levels:
- coupling of domain events persistence with publishing them to other components, especially to other bounded contexts,
- coupling of event type to event’s implementation (by using a class name),
- coupling between bounded contexts as with this implementation all of them must “know” the same class (i.e. shipping BC needs to be able to use ordering domain events).
There are tradeoffs, but we have deliberately made those choices because of several reasons. The main of them was:
- for the sake of simplicity when you start with Rails Event Store,
- backward compatibility… because some use that setup in your production projects,
How to decouple
1st: decouple persistence from communicating business events.
You must not store as an internal state change of aggregate and publish outside of the bounded context the same message (event). You could use the mailbox pattern, known from Actor Model to handle incoming messages and a outbox pattern to communicate important business facts that have happened in the bounded context (module). The events used to store aggregate state changes are now only internal implementation of this module and must not be exposed outside of it. Keep them private in the scope of the module. This also means you could no longer use a class name as an event type.
This sample code is a definition of “business” events in separate modules:
module Ordering
class OrderCompleted < RailsEventStore::Event
def event_type
'ordered'
end
end
end
module Shipping
class DeliveryScheduled < RailsEventStore::Event
def event_type
'ordered'
end
end
end
Overriding the event_type
method will allow to identify the event and match
it to different event’s classes in both modules. To do it you need to define
event class remapping in each module’s Rails Event Store configuration:
module Ordering
def event_store
mapper = RubyEventStore::Mappers::Default.new(
events_class_remapping: {
'ordered' => 'Ordering::OrderCompleted',
}
)
RailsEventStore::Client.new(mapper: mapper)
end
end
module Shipping
def event_store
mapper = RubyEventStore::Mappers::Default.new(
events_class_remapping: {
'ordered' => 'Shipping::DeliveryScheduled',
}
)
RailsEventStore::Client.new(mapper: mapper)
end
end
2nd: decouple domain events schema
But the domain event is not just a name (event type). Event if we decouple from event class we still might have coupling on the event’s schema level.
module Ordering
class OrderCancelled < Event
event_type 'cancelled'
attribute :order_no, Types::Strict::Integer
attribute :reason, Types::Strict::String.optional
end
end
module Shipping
class DeliveryRevoked < Event
event_type 'cancelled'
attribute :order_no, Types::Coercible::String
end
end
Here we have 2 events. In the beginning, they look different.
They have different class names, different schema - however they
share some attributes. As defined before these events share event
type. As a base class, I use here my implementation of base
event class, compatible with RailsEventStore::Event
but
allowing to define attributes using dry-schema
and dry-types
gems.
You could see the implementation of this base class
here.
These events have a different schema. But the way they are defined allows
usage of the Weak Schema technique.
However to be albe to use the weak schema we need to change the serialization
format in Rails Event Store. YAML
has been a really bad idea ;)
Fortunatelly for us it is very simple with Default
mapper:
module Ordering
def event_store
mapper = RubyEventStore::Mappers::Default.new(
serializer: JSON,
events_class_remapping: {
'ordered' => 'Ordering::OrderCompleted',
}
)
RailsEventStore::Client.new(mapper: mapper)
end
end
module Shipping
def event_store
mapper = RubyEventStore::Mappers::Default.new(
serializer: JSON,
events_class_remapping: {
'ordered' => 'Shipping::DeliveryScheduled',
}
)
RailsEventStore::Client.new(mapper: mapper)
end
end
BTW do you know that it is just a wrapper for a PipelineMapper
and you could build your mapper by composing any transformations you need?
But this is a story for a different post.
There are some rules that you need to be aware of to use the weak schema. The rules for mapping are simple. When reading the event from the event store, you look at the serialized JSON and the event instance. And then the rules apply:
- Exists on JSON and instance -> value from JSON
- Exists on JSON but not on the instance -> NOP
- Exists on the instance but not in JSON -> default value
You could read more about Weak Schema in Event Versioning book by Greg Young (available for free to read on LeanPub).
With the use of dry-types
attributes, we could also define coercion rules
(i.e. replacing integers with strings) and define default values.
3rd: decouple persistence & pub/sub
The last coupling to avoid is the persistence & publishing of the domain events. I’ve already mentioned the solution here. You just don’t publish outside of your bounded context (module) the internal events you use to persist state changes of the aggregates. This technique has several advantages:
- you define “the contract” between your BCs - read more about Open Host Service & Published Language context relationships
- changes of the contract could be versioned - i.e. you could publish 2 versions of the same public event until all downstream contexts (clients) will catch up and will be able to handle the latest version
- the changes in internal domain events schema do not have an impact on published public events
- you could enrich published events with additional data and publish the result of several internal events as an summary event
- use event class remapping as a simple form of bounded context anti-corruption layer
The separation could be done via physical separation of data in different data stores. In this solution, each BC should have its private data store and specific Rails Event Store configuration, and an additional Rails Event Store (or any other pub/sub implementation that will support Weak Schema) as a communication interface between different bounded contexts.
In a modular-monolith application, we could simplify this by using only a single instance of Rails Event Store and separate domain events on streams level. This will require more reliance on conventions and discipline of the development team as there is no such restriction implemented in Rails Event Store.
The mixed model is also possible. Separate instances of Rails Event Store with a wrapper
for EventRepository
to force the convention by adding module prefix to stream names.
This way we still have single data store but each context (module) could only write
to its streams and “public” streams via the RES instance which is used to
communicate between bounded contexts.