Successfully reported this slideshow.
We use your LinkedIn profile and activity data to personalize ads and to show you more relevant ads. You can change your ad preferences anytime.

2 years after the first event - The Saga Pattern

1,729 views

Published on

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.

Published in: Technology
  • Be the first to comment

2 years after the first event - The Saga Pattern

  1. 1. 2 years after the first event - The Saga Pattern
  2. 2. Message Driven Architecture ● Commands ● Events
  3. 3. 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
  4. 4. 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
  5. 5. Send PDF via Postal 1. PostalAddedToOrder 2. PostalAddressFilledOut 3. PdfGenerated 4. PaymentPaid 5. => SendPdfViaPostal
  6. 6. How did we get there?
  7. 7. 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
  8. 8. Publish events event_store.publish(SeasonPassConnectedToAConference.new({ pass: { internal_id: pass.id, external_id: pass.external_id, }, season_id: season.id, booking_id: booking_id(event, pass), id: ticket.id, event_id: event.id, ticket_id: ticket.id, ticket_type_id: ticket.ticket_type_id, barcode: ticket.barcode, seat: { uuid: seat.uuid, label: seat.label, category: seat.category, }, holder: { user_id: holder.user_id, email: holder.email, name: holder.name, }, }))
  9. 9. Introduce handlers module Search class UserRegisteredHandler singleton_class.prepend(YamlDeserializeFact) 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
  10. 10. Introduce handlers class WelcomeEmailForImportedUsers singleton_class.prepend(YamlDeserializeFact) @queue = :other def call(fact) data = fact.data organizer = User.find(data.fetch(:organizer_id)) organization = Organization.find(data.fetch(:organization_id)) season = Season.find(data.fetch(:season_id)) config = Seasons::Config.find_by_organizer_id(organizer.id) || Seasons::Config.new( class_name: "Seasons::Mailer", method_name: "welcome_imported_season_pass_holder", ) I18n.with_locale(organization.default_locale) do config.class_name.constantize.send(config.method_name, organization: organization, email: data.fetch(:email), reset_password_token: data.fetch(:password_token), season_name: season.name, organizer_name: organizer.name, ).deliver end end end
  11. 11. One class handles multiple events TicketRefunded: stream: "Order$%{order_id}" handlers: - Scanner::TicketRevokedHandler - Salesforce::EventDataChangedHandler - Reporting::Financials::TicketRefundedHandler - Seating::ReleasePlaces TicketCancelledByAdmin: stream: "Order$%{order_id}" handlers: - Scanner::TicketRevokedHandler module Scanner class TicketRevokedHandler singleton_class.prepend(YamlDeserializeFact) def call(event) data = event.data revoke = Scanner::Revoke.new revoke.call( barcode: data.fetch(:barcode), event_id: data.fetch(:event_id), ) end end end
  12. 12. Handler+State= Saga class CheckBankSettingsSaga singleton_class.prepend(::YamlDeserializeFact) class State < ActiveRecord::Base self.table_name = 'check_bank_settings_saga' end def call(event) data = event.data case event when ConferenceCreated, ConferenceCloned store_event(data) when TicketKindCreated check_bank_settings(data) end end
  13. 13. Handler+State= Saga def store_event(data) State.create!( event_id: data[:event_id], organization_id: data[:organization_id], organizer_id: data[:user_id], checked: false ) end def check_bank_settings(data) State.transaction do record = State.lock.find_by_event_id(data[:event_id]) return if record.checked? record.update_attribute(:checked, true) Organizer::CheckBankSettings.new.call( record.organizer_id, record.organization_id, record.event_id ) end end end
  14. 14. Payment paid too late 1. OrderPlaced 2. OrderExpired 3. PaymentPaid 4. => ReleasePayment
  15. 15. How to implement a Saga? Technical details
  16. 16. 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
  17. 17. 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
  18. 18. 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
  19. 19. 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/
  20. 20. YAML module Search class UserRegisteredHandler singleton_class.prepend(::YamlDeserializeEvent) def self.perform(event) new.call(event) rescue => e Honeybadger.notify(e) end end end module YamlDeserializeFact def perform(arg) case arg when String super(YAML.load(arg)) else super end end end
  21. 21. Re-raise exceptions in async handlers module Search class UserRegisteredHandler @queue = :low_priority def self.perform(event) new.call(event) rescue => e Honeybadger.notify(e) raise end end end
  22. 22. 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
  23. 23. 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
  24. 24. 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
  25. 25. 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
  26. 26. 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
  27. 27. Thank you! Make events, not CRUD
  28. 28. 35% DISCOUNT with code WROCLOVE2016 https://blog.arkency.com/products/
  29. 29. Resources ● original saga patterns ○ http://vasters. com/clemensv/2012/09/01/Sagas.aspx ○ http://kellabyte.com/2012/05/30/clarifying-the- saga-pattern/ ● saga - process manager ○ https://msdn.microsoft.com/en- us/library/jj591569.aspx ○ http://udidahan.com/2009/04/20/saga- persistence-and-event-driven-architectures/ ● other ○ http://verraes.net/2014/05/functional- foundation-for-cqrs-event-sourcing/ ○ http://stackoverflow. com/questions/15528015/what-is-the-difference- between-a-saga-a-process-manager-and-a- document-based-ap ○ http://theawkwardyeti.com/comic/moment/

×