As you build more advanced solutions, you may find that certain interactions in your system depend on more than one bounded context. Order, Inventory, Payments, Delivery. To deliver one feature often many sub-system are involved. But you want the modules to be isolated and independent. Yet something must coordinate their work and business processes. Welcome the choreographer - the Saga Pattern a.k.a. Process Manager.
4. Commands
params,
form objects,
real command objects
module Seating
class BookEntranceCommand
include Command
attribute :booking_id, String
attribute :event_id, Integer
attribute :seats, Array[Seat]
attribute :places, Array[GeneralAdmission]
validates_presence_of :event_id, :booking_id
validate do
unless (seats.present? || places.present?)
errors.add(:base, "Missing seats or places")
end
end
end
end
5. Events
module Seating
module Events
SeatingSetUp = Class.new(EventStore::Event)
SeatingDisabled = Class.new(EventStore::Event)
EntranceBooked = Class.new(EventStore::Event)
EntranceUnBooked = Class.new(EventStore::Event)
end
end
6. Send PDF via
Postal
1. PostalAddedToOrder
2. PostalAddressFilledOut
3. PdfGenerated
4. PaymentPaid
5. => SendPdfViaPostal
8. Publish events
class RegisterUserService
def call(email, password)
user = User.register(email, password)
event_store.publish(Users::UserRegisteredByEmail.new(
id: user.id,
email: user.email,
))
end
end
17. Sync handlers
from EventStore
module Search
class UserRegisteredHandler
def self.perform(event)
new.call(event)
rescue => e
Honeybadger.notify(e)
end
def call(event)
Elasticsearch::Model.client.index(
index: 'users',
type: 'admin_search_user',
id: event.data.fetch(:id),
body: {
email: event.data.fetch(:email)
})
end
end
end
18. Contain
exceptions
in sync handlers
module Search
class UserRegisteredHandler
def self.perform(event)
new.call(event)
rescue => e
Honeybadger.notify(e)
end
end
end
class RegisterUserService
def call(email, password)
ActiveRecord::Base.transaction do
user = User.register(email, password)
event_store.publish(UserRegisteredByEmail.new(
id: user.id,
email: user.email,
))
end
end
end
19. Async handlers
def call(handler_class, event)
if handler_class.instance_variable_defined?(:@queue)
Resque.enqueue(handler_class, YAML.dump(event))
else
handler_class.perform(YAML.dump(event))
end
end
20. Async handlers
(after commit)
def call(handler_class, event)
if handler_class.instance_variable_defined?(:@queue)
if ActiveRecord::Base.connection.transaction_open?
ActiveRecord::Base.
connection.
current_transaction.
add_record( FakeActiveRecord.new(
handler,
YAML.dump(event))
)
else
Resque.enqueue(handler_class, YAML.dump(event))
end
else
handler_class.perform(YAML.dump(event))
end
end
https://blog.arkency.com/2015/10/run-it-in-background-job-after-commit/
23. Initializing the
state of a saga
class PostalSaga
singleton_class.prepend(YamlDeserializeFact)
@queue = :low_priority
# add_index "sagas", ["order_id"], unique: true
class State < ActiveRecord::Base
self.table_name = 'sagas'
def self.get_by_order_id(order_id) do
transaction do
yield lock.find_or_create_by(order_id: order_id)
end
rescue ActiveRecord::RecordNotUnique
retry
end
end
def call(fact)
data = fact.data
State.get_by_order_id(data.fetch(:order_id)) do |state|
state.do_something
state.save!
end
end
end
24. Processing an
event by a saga
class Postal::FilledOut
singleton_class.prepend(YamlDeserializeFact)
@queue = :low_priority
def self.perform(event)
new().call(event)
rescue => e
Honeybadger.notify(e, { context: { event: event } } )
raise
end
def call(event)
data = event.data
order_id = data.fetch(:order_id)
State.get_by_order_id(order_id) do |state|
state.filled_out(
filled_out_at: Time.zone.now,
adapter: Rails.configuration.insurance_adapter,
)
end
end
end
25. Triggering a
command
class Postal::State < ActiveRecord::Base
def added_to_basket(added_to_basket_at:, uploader:)
self.added_to_basket_at ||= added_to_basket_at
save!
maybe_send_postal_via_api(uploader: uploader)
end
def filled_out(filled_out_at:, uploader:)
self.filled_out_at ||= filled_out_at
save!
maybe_send_postal_via_api(uploader: uploader)
end
def paid(paid_at:, uploader:)
self.paid_at ||= paid_at
save!
maybe_send_postal_via_api(uploader: uploader)
end
def tickets_pdf_generated(generated_at:, pdf_id:, uploader:)
return if self.tickets_generated_at
self.tickets_generated_at ||= generated_at
self.pdf_id ||= pdf_id
save!
maybe_send_postal_via_api(uploader: uploader)
end
26. Triggering a
command
class Postal::State < ActiveRecord::Base
private
def maybe_send_postal_via_api(uploader:)
return unless added_to_basket_at && paid_at && filled_out_at
tickets_generated_at
return if uploaded_at
uploader.transmit(Pdf.find(pdf_id))
self.uploaded_at = Time.now
save!
rescue
# error handling...
end
end
27. Triggering a
command
(better way)
class Postal::State < ActiveRecord::Base
private
def maybe_send_postal_via_api
return unless added_to_basket_at && paid_at && filled_out_at
tickets_generated_at
return if uploaded_at
self.uploaded_at = Time.now
save!
command_bus.send(DeliverPostalPdf.new({
order_id: order_id,
pdf_id: pdf_id
}))
end
end
https://github.com/pawelpacana/command_bus