├── .ruby-version ├── lib ├── pub_sub.rb ├── pub_sub │ ├── version.rb │ ├── domain_event.rb │ ├── dry.rb │ ├── testing.rb │ ├── testing │ │ ├── rails_event_store.rb │ │ ├── event_data_helper.rb │ │ └── subscribe_to.rb │ ├── event_worker.rb │ ├── domain_event_handler.rb │ ├── emit.rb │ ├── event_with_type.rb │ ├── event_handler_builder.rb │ ├── payload_attribute.rb │ ├── subscriptions_linter.rb │ ├── subscriptions_list.rb │ ├── event_class_factory.rb │ └── event_emission.rb └── pubsub_on_rails.rb ├── Gemfile ├── .gitignore ├── pubsub_on_rails.gemspec ├── LICENSE.md └── README.md /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.2.1 2 | -------------------------------------------------------------------------------- /lib/pub_sub.rb: -------------------------------------------------------------------------------- 1 | module PubSub 2 | end 3 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /lib/pub_sub/version.rb: -------------------------------------------------------------------------------- 1 | module PubSub 2 | VERSION = '1.1.0' 3 | end 4 | -------------------------------------------------------------------------------- /lib/pub_sub/domain_event.rb: -------------------------------------------------------------------------------- 1 | module PubSub 2 | class DomainEvent < Dry::Struct 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /lib/pub_sub/dry.rb: -------------------------------------------------------------------------------- 1 | require 'dry-struct' 2 | 3 | module Types 4 | include Dry.Types() 5 | end 6 | -------------------------------------------------------------------------------- /lib/pub_sub/testing.rb: -------------------------------------------------------------------------------- 1 | require 'pub_sub/testing/rails_event_store' 2 | require 'pub_sub/testing/event_data_helper' 3 | require 'pub_sub/testing/subscribe_to' 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Also see https://github.com/github/gitignore/blob/master/Ruby.gitignore 2 | 3 | Gemfile.lock 4 | *.gem 5 | .bundle 6 | .env 7 | rdoc 8 | tmp 9 | .idea 10 | .ruby-gemset 11 | -------------------------------------------------------------------------------- /lib/pub_sub/testing/rails_event_store.rb: -------------------------------------------------------------------------------- 1 | module PubSub 2 | module Testing 3 | module RailsEventStore 4 | def event_store 5 | Rails.configuration.event_store 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/pubsub_on_rails.rb: -------------------------------------------------------------------------------- 1 | require 'rails_event_store' 2 | 3 | require 'pub_sub/dry' 4 | require 'pub_sub' 5 | require 'pub_sub/emit' 6 | require 'pub_sub/event_with_type' 7 | require 'pub_sub/subscriptions_list' 8 | require 'pub_sub/domain_event' 9 | require 'pub_sub/domain_event_handler' 10 | -------------------------------------------------------------------------------- /lib/pub_sub/event_worker.rb: -------------------------------------------------------------------------------- 1 | module PubSub 2 | class EventWorker 3 | include Sidekiq::Job 4 | 5 | def perform(class_name, event_id) 6 | class_name.constantize.new( 7 | event_store.read.event(event_id) 8 | ).call! 9 | end 10 | 11 | private 12 | 13 | def event_store 14 | Rails.configuration.event_store 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/pub_sub/testing/event_data_helper.rb: -------------------------------------------------------------------------------- 1 | module PubSub 2 | module Testing 3 | module EventDataHelper 4 | def event_data_for(event_name, **payload) 5 | event_class = PubSub::EventClassFactory.build( 6 | event_name, abstract_event_class: payload.delete(:abstract_event_class) 7 | ) 8 | 9 | if event_class.ancestors.include?(PubSub::EventWithType) 10 | event_class.new(data: payload) 11 | else 12 | event_class.new(payload).attributes 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/pub_sub/domain_event_handler.rb: -------------------------------------------------------------------------------- 1 | module PubSub 2 | class DomainEventHandler 3 | def initialize(event) 4 | @event = event 5 | end 6 | 7 | def call 8 | raise NotImplementedError 9 | end 10 | 11 | def call! 12 | call if process_event? 13 | end 14 | 15 | private 16 | 17 | attr_reader :event 18 | 19 | def process_event? 20 | true 21 | end 22 | 23 | def event_data 24 | @event_data ||= OpenStruct.new(event_data_hash) 25 | end 26 | 27 | def event_data_hash 28 | event.data 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/pub_sub/emit.rb: -------------------------------------------------------------------------------- 1 | require 'pub_sub/event_class_factory' 2 | require 'pub_sub/event_emission' 3 | 4 | module PubSub 5 | module Emit 6 | def emit(event_name, explicit_payload = {}) 7 | abstract_event_class = explicit_payload.delete(:abstract_event_class) 8 | event_class = EventClassFactory.build( 9 | event_name, 10 | domain_name: self.class.name.deconstantize.demodulize, 11 | abstract_event_class: 12 | ) 13 | 14 | EventEmission.new(abstract_event_class, event_class, event_name, explicit_payload, self).call 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/pub_sub/event_with_type.rb: -------------------------------------------------------------------------------- 1 | module PubSub 2 | class EventWithType < RailsEventStore::Event 3 | def initialize(event_id: SecureRandom.uuid, metadata: nil, data: {}) 4 | super( 5 | event_id:, 6 | metadata:, 7 | data: self.class.instance_variable_get(:@schema_validator).new( 8 | data.deep_symbolize_keys 9 | ).attributes 10 | ) 11 | end 12 | 13 | def stream_names 14 | [] 15 | end 16 | 17 | def self.schema(&block) 18 | instance_variable_set(:@schema_validator, Class.new(Dry::Struct, &block)) 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/pub_sub/event_handler_builder.rb: -------------------------------------------------------------------------------- 1 | module PubSub 2 | class EventHandlerBuilder 3 | def initialize(class_name, subscription_type) 4 | @class_name = class_name 5 | @subscription_type = subscription_type.to_sym 6 | end 7 | 8 | def call(event) 9 | if async? 10 | EventWorker.perform_async(class_name.to_s, event.event_id) 11 | else 12 | class_name.new(event).call! 13 | end 14 | end 15 | 16 | protected 17 | 18 | attr_reader :class_name, :subscription_type 19 | 20 | def ==(other) 21 | class_name == other.class_name && subscription_type == other.subscription_type 22 | end 23 | 24 | private 25 | 26 | def async? 27 | subscription_type == :async 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /pubsub_on_rails.gemspec: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | $:.unshift File.expand_path('../lib', __FILE__) 4 | require 'pub_sub/version' 5 | 6 | Gem::Specification.new do |s| 7 | s.name = 'pubsub_on_rails' 8 | s.version = PubSub::VERSION 9 | s.authors = ['Stevo'] 10 | s.email = ['b.kosmowski@selleo.com'] 11 | s.homepage = 'https://github.com/Selleo/pubsub_on_rails' 12 | s.licenses = ['MIT'] 13 | s.summary = 'Opinionated publish-subscribe pattern for ruby and rails' 14 | s.description = 'Opinionated publish-subscribe pattern for ruby and rails' 15 | 16 | s.files = Dir.glob('{bin/*,lib/**/*,[A-Z]*}') 17 | s.platform = Gem::Platform::RUBY 18 | s.require_paths = ['lib'] 19 | s.add_dependency 'dry-struct' 20 | s.add_dependency 'sidekiq' 21 | s.add_dependency 'rails_event_store' 22 | s.add_dependency 'ruby_event_store-rspec' 23 | end 24 | -------------------------------------------------------------------------------- /lib/pub_sub/payload_attribute.rb: -------------------------------------------------------------------------------- 1 | module PubSub 2 | class PayloadAttribute 3 | CannotEvaluate = Class.new(StandardError) 4 | 5 | def initialize(attribute_name, explicit_payload, context) 6 | @attribute_name = attribute_name 7 | @explicit_payload = explicit_payload 8 | @context = context 9 | end 10 | 11 | def get 12 | return explicit_payload.fetch(attribute_name) if explicit_payload.key?(attribute_name) 13 | 14 | identifier? ? context.send(getter_name)&.id : context.send(getter_name) 15 | rescue NoMethodError 16 | raise CannotEvaluate, getter_name 17 | end 18 | 19 | private 20 | 21 | attr_reader :attribute_name, :explicit_payload, :context 22 | 23 | def identifier? 24 | !context.respond_to?(attribute_name, true) && attribute_name.to_s.end_with?('_id') 25 | end 26 | 27 | def getter_name 28 | identifier? ? attribute_name.to_s.chomp('_id') : attribute_name 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/pub_sub/testing/subscribe_to.rb: -------------------------------------------------------------------------------- 1 | RSpec::Matchers.define :subscribe_to do |event_name| 2 | match do |domain| 3 | handler_class = build_handler_class(event_name, domain) 4 | event_class = build_event_class(event_name) 5 | subscription_type = async? ? :async : :sync 6 | 7 | expect( 8 | PubSub::EventHandlerBuilder.new(handler_class, subscription_type) 9 | ).to have_subscribed_to_events(event_class).in(event_store) 10 | end 11 | 12 | chain :asynchronously do 13 | @asynchronously = true 14 | end 15 | 16 | private 17 | 18 | def build_handler_class(event_name, domain) 19 | handler_name = event_name.to_s.sub('__', '/').camelize 20 | handler_name.remove!('::') 21 | "#{domain.name}::#{handler_name}Handler".constantize 22 | end 23 | 24 | def build_event_class(event_name) 25 | event_class_name = event_name.to_s.sub('__', '/').camelize 26 | "PubSub::#{event_class_name}Event".constantize 27 | end 28 | 29 | def async? 30 | @asynchronously 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT LICENSE 2 | 3 | Copyright (c) 2019 Stevo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/pub_sub/subscriptions_linter.rb: -------------------------------------------------------------------------------- 1 | module PubSub 2 | class SubscriptionsLinter 3 | MissingSubscriptions = Class.new(StandardError) 4 | 5 | def initialize(subscriptions) 6 | @subscriptions = subscriptions 7 | end 8 | 9 | def lint! 10 | raise MissingSubscriptions, error_message if missing_subscriptions.present? 11 | end 12 | 13 | private 14 | 15 | attr_reader :subscriptions 16 | 17 | def error_message 18 | "The following subscriptions are missing: \n#{missing_subscriptions.join("\n")}" 19 | end 20 | 21 | def missing_subscriptions 22 | (handled_subscription_names - all_subscription_names) 23 | end 24 | 25 | # :reek:UtilityFunction, :reek:DuplicateMethodCall 26 | def handled_subscription_names 27 | Dir[Rails.root.join('app/event_handlers/*/*.rb')].map do |file_path| 28 | file_path. 29 | sub(Rails.root.join('app/event_handlers/').to_s, ''). 30 | sub('_handler.rb', '') 31 | end 32 | end 33 | 34 | def all_subscription_names 35 | subscriptions.flat_map do |domain_name, subscriptions| 36 | subscriptions.keys.map do |event_name| 37 | "#{domain_name}/#{event_name.sub('__', '_')}" 38 | end 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/pub_sub/subscriptions_list.rb: -------------------------------------------------------------------------------- 1 | require 'pub_sub/subscriptions_linter' 2 | require 'pub_sub/event_handler_builder' 3 | 4 | module PubSub 5 | class SubscriptionsList 6 | include Singleton 7 | 8 | cattr_accessor :config_path 9 | self.config_path = 'config/subscriptions.yml' 10 | 11 | def self.load!(event_store) 12 | instance.event_store = event_store 13 | instance.load! 14 | end 15 | 16 | def self.lint! 17 | instance.lint! 18 | end 19 | 20 | attr_accessor :event_store 21 | 22 | def load! 23 | domain_subscriptions.each do |domain_name, subscriptions| 24 | subscriptions.each do |event_name, subscription_type| 25 | if event_name == 'all_events' 26 | subscribe_to_all_events(domain_name, subscription_type) 27 | else 28 | subscribe_to_event(domain_name, event_name, subscription_type) 29 | end 30 | end 31 | end 32 | end 33 | 34 | def lint! 35 | SubscriptionsLinter.new(domain_subscriptions).lint! 36 | end 37 | 38 | def initialize 39 | @domain_subscriptions = YAML.load_file(self.class.config_path) 40 | end 41 | 42 | private 43 | 44 | attr_reader :domain_subscriptions 45 | 46 | def subscribe_to_all_events(domain_name, subscription_type) 47 | handler_class = "#{domain_name.camelize}Handler".constantize 48 | event_store.subscribe_to_all_events( 49 | EventHandlerBuilder.new(handler_class, subscription_type) 50 | ) 51 | end 52 | 53 | def subscribe_to_event(domain_name, event_name, subscription_type) 54 | event_domain, name = event_name.split('__').map(&:camelize) 55 | event_class = "PubSub::#{event_domain}::#{name}Event".constantize 56 | handler_class = "#{domain_name.camelize}::#{event_domain}#{name}Handler".constantize 57 | event_store.subscribe( 58 | EventHandlerBuilder.new(handler_class, subscription_type), to: [event_class] 59 | ) 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/pub_sub/event_class_factory.rb: -------------------------------------------------------------------------------- 1 | module PubSub 2 | class EventClassFactory 3 | EventClassDoesNotExist = Class.new(StandardError) 4 | 5 | def self.build(event_name, domain_name: nil, abstract_event_class: nil) 6 | new( 7 | event_name, 8 | domain_name:, 9 | abstract_event_class: 10 | ).build_event_class 11 | end 12 | 13 | def initialize(event_name, domain_name:, abstract_event_class:) 14 | @event_name = event_name.to_s 15 | @abstract_event_class = abstract_event_class 16 | @domain_name = domain_name 17 | end 18 | 19 | def build_event_class 20 | event_class = res_event_class_name.safe_constantize 21 | 22 | return event_class if event_class.present? 23 | 24 | event_class = event_class_name.safe_constantize 25 | 26 | return event_class if event_class.present? 27 | 28 | if abstract_event_class.nil? 29 | raise(EventClassDoesNotExist, event_class_name) 30 | else 31 | register_new_event_class 32 | end 33 | end 34 | 35 | private 36 | 37 | attr_reader :event_name, :abstract_event_class, :domain_name 38 | 39 | def register_new_event_class 40 | event_class_namespace.const_set(event_class_name.demodulize, Class.new(abstract_event_class)) 41 | end 42 | 43 | def event_class_namespace 44 | event_class_name.deconstantize.constantize 45 | end 46 | 47 | def event_name_includes_domain? 48 | event_name.include?('__') 49 | end 50 | 51 | def event_name_with_domain 52 | if event_name_includes_domain? 53 | event_name.to_s.downcase.sub('__', '/') 54 | else 55 | [domain_name&.underscore, event_name].compact.join('/') 56 | end 57 | end 58 | 59 | def res_event_class_name 60 | @res_event_class_name ||= "pub_sub/#{event_name_with_domain}_event".classify 61 | end 62 | 63 | def event_class_name 64 | @event_class_name ||= "#{event_name_with_domain}_event".classify 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/pub_sub/event_emission.rb: -------------------------------------------------------------------------------- 1 | require 'pub_sub/payload_attribute' 2 | 3 | module PubSub 4 | Error = Class.new(StandardError) 5 | EventMissing = Class.new(Error) 6 | EventPayloadArgumentMissing = Class.new(Error) 7 | 8 | class EventEmission 9 | def initialize(abstract_event_class, event_class, event_name, explicit_payload, context) 10 | @abstract_event_class = abstract_event_class 11 | @event_class = event_class 12 | @event_name = event_name 13 | @explicit_payload = explicit_payload 14 | @context = context 15 | end 16 | 17 | def call 18 | if event_class.ancestors.include?(PubSub::EventWithType) 19 | event_store.publish(event, stream_name:) 20 | else 21 | raise(EventMissing, event_name) 22 | end 23 | end 24 | 25 | private 26 | 27 | attr_reader :abstract_event_class, :event_class, :event_name, :explicit_payload, :context 28 | 29 | def event 30 | event_class.new(data: full_payload) 31 | end 32 | 33 | def event_name_includes_domain? 34 | event_name.to_s.include?('__') 35 | end 36 | 37 | def stream_name 38 | return event_name.to_s.downcase if event_name_includes_domain? 39 | 40 | "#{domain}__#{event_name}" 41 | end 42 | 43 | def domain 44 | if abstract_event_class 45 | abstract_event_class.name.deconstantize.underscore 46 | else 47 | context.class.name.deconstantize.demodulize.underscore 48 | end 49 | end 50 | 51 | # rubocop:disable Metrics/MethodLength 52 | def full_payload 53 | attribute_names.each_with_object({}) do |attribute_name, result| 54 | result[attribute_name] = PayloadAttribute.new( 55 | attribute_name, explicit_payload, context 56 | ).get 57 | rescue PayloadAttribute::CannotEvaluate => e 58 | next if schema.key(attribute_name).default? 59 | 60 | raise( 61 | EventPayloadArgumentMissing, 62 | "Event [#{event_class.name}] expects [#{attribute_name}] " \ 63 | "payload attribute to be either exposed as [#{e.message}] method in emitting object " \ 64 | 'or provided as argument' 65 | ) 66 | end 67 | end 68 | # rubocop:enable Metrics/MethodLength 69 | 70 | def attribute_names 71 | (abstract_event_class || event_class.instance_variable_get(:@schema_validator)).attribute_names 72 | end 73 | 74 | def schema 75 | (abstract_event_class || event_class.instance_variable_get(:@schema_validator)).schema 76 | end 77 | 78 | def event_store 79 | Rails.configuration.event_store 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PubSub on Rails 2 | 3 | PubSub on Rails is a gem facilitating opinionated approach to leveraging publish/subscribe messaging pattern in Ruby on Rails applications. 4 | 5 | There are many programming techniques that are powerful yet complex. The beauty of publish/subscribe patterns is that it is powerful while staying simple. 6 | 7 | Instead of using callbacks or directly and explicitly executing series of actions, action execution is requested using an event object combined with event subscription. 8 | This helps in keeping code isolation high, and therefore makes large codebases maintainable and testable. 9 | 10 | While it has little to do with event sourcing, it encompasses a couple of ideas related to domain-driven development. 11 | Therefore it is only useful in applications in which domains/bounded-contexts can be identified. 12 | This is especially true for applications covering many side effects, integrations and complex business logic. 13 | 14 | ## Installation 15 | 16 | ```ruby 17 | # Gemfile 18 | 19 | gem 'pubsub_on_rails', '~> 1.1.0' 20 | 21 | # config/initializers/pub_sub.rb 22 | 23 | require 'pub_sub/subscriptions_list' 24 | 25 | Rails.configuration.to_prepare do 26 | Rails.configuration.event_store = event_store = RailsEventStore::Client.new( 27 | repository: RailsEventStoreActiveRecord::EventRepository.new(serializer: RubyEventStore::NULL) 28 | ) 29 | 30 | PubSub::SubscriptionsList.config_path = 31 | Rails.root.join('config/subscriptions.yml') 32 | PubSub::SubscriptionsList.load!(event_store) 33 | end 34 | ``` 35 | 36 | ## Migrating from 0.0.7 to 1.0.0 37 | 38 | 1. Update gem to version `1.0.0` 39 | 40 | ```ruby 41 | # Gemfile 42 | 43 | gem 'pubsub_on_rails', '~> 1.0.0' 44 | ``` 45 | 46 | 2. Run Rails Event Store migrations 47 | 48 | **MySQL** 49 | ``` 50 | bin/rails generate rails_event_store_active_record:migration 51 | bin/rails db:migrate 52 | ``` 53 | 54 | **PostgreSQL** 55 | ``` 56 | bin/rails generate rails_event_store_active_record:migration --data-type=jsonb 57 | bin/rails db:migrate 58 | ``` 59 | 60 | 3. Update initializer to use Rails Event Store Client 61 | 62 | ```ruby 63 | # config/initializers/pub_sub.rb 64 | 65 | require 'pub_sub/subscriptions_list' 66 | 67 | Rails.configuration.to_prepare do 68 | Rails.configuration.event_store = event_store = RailsEventStore::Client.new( 69 | repository: RailsEventStoreActiveRecord::EventRepository.new(serializer: RubyEventStore::NULL) 70 | ) 71 | 72 | PubSub::SubscriptionsList.config_path = 73 | Rails.root.join('config/subscriptions.yml') 74 | PubSub::SubscriptionsList.load!(event_store) 75 | end 76 | ``` 77 | 78 | 4. Override `EventWorker` or override `EventHandlerBuilder` if needed 79 | 80 | For example when you want to have different workers for different events: 81 | 82 | ```ruby 83 | # config/initializers/pub_sub.rb 84 | 85 | PubSub::EventHandlerBuilder.class_eval do 86 | def call(event) 87 | if async? 88 | if class_name.to_s.include?('MyType') 89 | SingleThreadEventWorker.perform_in(2.seconds, class_name.to_s, event.event_id) 90 | else 91 | EventWorker.perform_in(2.seconds, class_name.to_s, event.event_id) 92 | end 93 | else 94 | class_name.new(event).call! 95 | end 96 | end 97 | end 98 | ``` 99 | 100 | 5. Add event objects for Rails Event Store streams. Check [Event](README.md#Event) section. 101 | 6. Update test cases to use new matchers. Check [Testing](README.md#Testing) section. 102 | 103 | ## Entities 104 | 105 | There are five entities that are core to PubSub on Rails: domains, events, event publishers, event handlers and subscriptions. 106 | 107 | ### Domain 108 | 109 | Domain is simply a named context in application. You can refer to it as "module", "subsystem", "engine", whatever you like. 110 | Good names for domains are "ordering", "messaging", "logging", "accounts", "logistics" etc. 111 | Your app does not need to have code isolated inside domains, but using Component-Based Rails Applications concept (CBRA) sounds like a nice idea to be combined with PubSub on Rails. 112 | 113 | Domain example: 114 | 115 | ```ruby 116 | # app/domains/messaging.rb 117 | 118 | module Messaging 119 | end 120 | ``` 121 | 122 | ### Event 123 | 124 | Event is basically an object indicating that something has happened (event has occured). 125 | There are two important things that need to be considered when planning an event: its **name** and its **payload** (fields). 126 | 127 | Name of event should describe an action that has just happened, also it should be namespaced with the name of the domain it has occurred within. 128 | Some examples of good event names: `Ordering::OrderCancelled`, `Messaging::IncorrectLoginNotificationSent`, `Accounts::UserCreated`, `Bookings::CheckinDateChanged`, `Reporting::MonthlySalesReportGenerationRequested` 129 | 130 | Payload of event is just simple set of fields that should convey critical information related to the event. 131 | As the payload is very important for each event (it acts as a contract between publisher and handler), PubSub on Rails leverages `Dry::Struct` and `Dry::Types` to ensure both presence and correct type of attributes events are created with. 132 | It is a good rule of a thumb not to create too many fields for each event and just start with the minimal set. It is easy to add more fields to event's payload later (while it might be cumbersome to remove or change them). 133 | 134 | Event example: 135 | 136 | ```ruby 137 | # app/events/ordering/order_created_event.rb 138 | 139 | module PubSub 140 | module Ordering 141 | class OrderCreatedEvent < PubSub::EventWithType 142 | schema do 143 | attribute :order_id, Types::Strict::Integer 144 | attribute :customer_id, Types::Strict::Integer 145 | attribute :line_items, Types::Strict::Array 146 | attribute :total_amount, Types::Strict::Float 147 | attribute :comment, Types::Strict::String.optional 148 | end 149 | end 150 | end 151 | end 152 | ``` 153 | 154 | Since we are using Rails Event Store to handle events, it gives us a possibility to create **stream** of events. We can treat them as sub-list of events. To be able to use that functionality we need to declare which streams given event should be part of. By default we add event to stream based on its name. In case of our example it is `ordering__order_created`. We can provide also custom streams even based on some additional data from the event attributes (for example to group all events related to given order). 155 | 156 | Event example: 157 | 158 | ```ruby 159 | # app/events/rails_event_store/ordering/order_created_event.rb 160 | 161 | module PubSub 162 | module Ordering 163 | class OrderCreatedEvent < PubSub::EventWithType 164 | def stream_names 165 | [ 166 | "order__#{data[:order_id]}" 167 | ] 168 | end 169 | end 170 | end 171 | end 172 | ``` 173 | 174 | ### Event publisher 175 | 176 | Event publisher is any class capable of emitting an event. 177 | Usually a great places to start emitting events are model callbacks, service objects or event handlers. 178 | It is very preferable to emit one specific event from only one place, as in most cases this makes the most sense and makes the whole solution more comprehensible. 179 | 180 | Event publisher example: 181 | 182 | ```ruby 183 | # app/models/order.rb 184 | 185 | class Order < ApplicationRecord 186 | include PubSub::Emit 187 | 188 | belongs_to :customer 189 | has_many :line_items 190 | 191 | #... 192 | 193 | after_create do 194 | emit(:ordering__order_created, order_id: id) 195 | end 196 | end 197 | ``` 198 | 199 | ### Event handler 200 | 201 | Event handler is a class that encapsulates logic that should be executed in reaction to event being emitted. 202 | One event can be handled by many handlers, but only one unique handler within each domain. 203 | Event handlers can be executed synchronously or asynchronously. The latter is recommended for both performance and error-recovery reasons. 204 | 205 | Event handler example: 206 | 207 | ```ruby 208 | # app/event_handlers/messaging/ordering_order_created_handler.rb 209 | 210 | module Messaging 211 | class OrderingOrderCreatedHandler < PubSub::DomainEventHandler 212 | def call 213 | OrderMailer.order_creation_notification(order).deliver_now 214 | end 215 | 216 | private 217 | 218 | def order 219 | Order.find(event_data.order_id) 220 | end 221 | end 222 | end 223 | ``` 224 | 225 | All fields of event's payload are accessible through `event_data` method, which is a simple struct. 226 | 227 | #### Conditionally processing events 228 | 229 | If in any case you would like to control if given handler should be executed or not (maybe using feature flags), you can override `#process_event?` method. 230 | 231 | ```ruby 232 | # app/event_handlers/messaging/ordering_order_created_handler.rb 233 | 234 | module Messaging 235 | class OrderingOrderCreatedHandler < PubSub::DomainEventHandler 236 | # ... 237 | 238 | private 239 | 240 | def process_event? 241 | Features.notifications_enabled? 242 | end 243 | end 244 | end 245 | ``` 246 | 247 | ### Subscription 248 | 249 | Subscription is "the glue", the binds events with their corresponding handlers. 250 | Each subscription binds one or all events with one handler. 251 | Subscription defines if given handler should be executed in synchronous or asynchronous way. 252 | 253 | Subscription example: 254 | 255 | ```yaml 256 | # config/subscriptions.yml 257 | 258 | messaging: 259 | ordering__order_created: async 260 | ``` 261 | 262 | ## Testing 263 | 264 | Most of entities in Pub/Sub approach should be tested, yet both domain and event classes can be tested implicitly. 265 | It is recommended to start testing from testing subscription itself, then ensure that both event emission and handling are in place. Depending on situation the recommended order may change though. 266 | 267 | ### RSpec 268 | 269 | The recommended RSpec configuration is as follows: 270 | 271 | ```ruby 272 | # spec/support/pub_sub.rb 273 | 274 | require 'pub_sub/testing' 275 | 276 | RSpec.configure do |config| 277 | config.include PubSub::Testing::RailsEventStore 278 | config.include PubSub::Testing::EventDataHelper 279 | 280 | config.around(:each, in_memory_res_client: true) do |example| 281 | current_event_store = Rails.configuration.event_store 282 | Rails.configuration.event_store = RubyEventStore::Client.new( 283 | repository: RubyEventStore::InMemoryRepository.new 284 | ) 285 | example.run 286 | Rails.configuration.event_store = current_event_store 287 | end 288 | end 289 | ``` 290 | 291 | This will allow you to use `in_memory_res_client` which will not create object (event) in the database and do not call all dependent logic (handlers). 292 | 293 | ### Testing subscription 294 | 295 | Testing subscription is as easy as telling what domains should subscribe to what event in what way. 296 | 297 | Example: 298 | 299 | ```ruby 300 | RSpec.describe Messaging do 301 | it { is_expected.to subscribe_to(:ordering__order_created).asynchronously } 302 | end 303 | ``` 304 | 305 | ### Testing publishers 306 | 307 | To test publisher it is crucial to test if event was emitted under certain conditions (if any). 308 | 309 | Example: 310 | 311 | ```ruby 312 | RSpec.describe Order do 313 | describe 'after_create' do 314 | it 'emits ordering__order_created' do 315 | customer = create(:customer) 316 | line_items = create_list(:line_item, 2) 317 | 318 | Order.create( 319 | customer: customer, 320 | total_amount: 100.99, 321 | comment: 'Small order', 322 | line_items: line_items 323 | ) 324 | 325 | expect(event_store).to have_published( 326 | an_event(PubSub::Ordering::OrderCreatedEvent).with_data( 327 | order_id: fetch_next_id_for(Order), 328 | total_amount: 100.99, 329 | comment: 'Small order', 330 | line_items: line_items 331 | ) 332 | ).in_stream('ordering__order_created') 333 | end 334 | end 335 | end 336 | ``` 337 | 338 | ### Testing handlers 339 | 340 | Handlers can be tested by testing their `call!` method, that calls `call` behind the scenes. 341 | To ensure event payload contract is met, please use `event_data_for` helper to build event payload hash. 342 | It will instantiate event object behind the scenes to ensure it exists and its payload requirements are met. 343 | 344 | Example: 345 | 346 | ```ruby 347 | module Messaging 348 | RSpec.describe OrderingOrderCreatedHandler do 349 | describe '#call!' do 350 | it 'delivers order creation notification' do 351 | order = create(:order) 352 | event_data = event_data_for( 353 | 'ordering__order_created', 354 | order_id: order.id, 355 | total_amount: 100.99, 356 | comment: 'Small order', 357 | line_items: [build(:line_item)] 358 | ) 359 | order_creation_notification = double(:order_creation_notification, deliver_now: true) 360 | allow(OrderMailer).to receive(:order_creation_notification). 361 | with(order).and_return(order_creation_notification) 362 | 363 | OrderingOrderCreatedHandler.new(event_data).call! 364 | 365 | expect(order_creation_notification).to have_received(:deliver_now) 366 | end 367 | end 368 | end 369 | end 370 | ``` 371 | 372 | ### Subscriptions linting 373 | 374 | It is a common problem to implement a publisher and handler and forget about implementing subscription. 375 | Without proper integration testing the problem might stay undetected before identified (hopefully) during manual testing. 376 | This is where subscriptions linting comes into play. All existing event handlers will be verified against registered subscriptions during linting process. 377 | In case of any mismatch, exception will be raised. 378 | 379 | To lint subscriptions, place `PubSub::Subscriptions.lint!` for instance in your `rails_helper.rb` or some initializer of choice. 380 | 381 | ## Logger 382 | 383 | Even though default domain always routes event subscriptions to correspondingly named event handlers, it is possible to implement domains that will route subscriptions in the different way. 384 | The simplest way is to define it manually: 385 | 386 | ```ruby 387 | # app/domains/logging.rb 388 | 389 | module Messaging 390 | def self.ordering__order_created(event_payload) 391 | # whatever you need goes here 392 | end 393 | end 394 | ``` 395 | 396 | This technique can be useful for instance for logging 397 | 398 | ```ruby 399 | # app/domains/logging.rb 400 | 401 | module Logging 402 | def self.event_logger 403 | @event_logger ||= Logger.new("#{Rails.root}/log/#{Rails.env}_event_logger.log") 404 | end 405 | 406 | def self.method_missing(method_name, *event_data) 407 | event_logger.info("Evt: #{method_name}: \n#{event_data.map(&:to_json).join(', ')}\n\n") 408 | end 409 | 410 | def self.respond_to_missing?(method_name, include_private = false) 411 | method_name.to_s.start_with?(/[a-z_]+__/) || super 412 | end 413 | end 414 | ``` 415 | 416 | ```yaml 417 | # config/subscriptions.yml 418 | 419 | logging: 420 | all_events: sync 421 | ``` 422 | 423 | ## Payload verification 424 | 425 | Every time event is emitted, its payload is supplied to corresponding `Dry::Struct` event class and is verified. 426 | This ensures that whenever we emit event we can be sure its payload is matching specification. 427 | 428 | Example: 429 | 430 | ```ruby 431 | module PubSub 432 | module Accounts 433 | class PersonCreatedEvent < PubSub::EventWithType 434 | schema do 435 | attribute :person_id, Types::Strict::Integer 436 | end 437 | end 438 | end 439 | end 440 | ``` 441 | 442 | * `emit(:accounts__person_created, person_id: 1)` is ok 443 | * `emit(:accounts__person_created)` will result in ```PubSub::EventEmission::EventPayloadArgumentMissing: Event [Accounts::PersonCreatedEvent] expects [person_id] payload attribute to be either exposed as [person] method in emitting object or provided as argument``` 444 | * `emit(:accounts__person_created, person_id: 'abc')` will result in ```Dry::Struct::Error: [Accounts::PersonCreatedEvent.new] "abc" (String) has invalid type for :person_id violates constraints (type?(Integer, "abc") failed)``` 445 | 446 | ## Automatic event name prefixing 447 | 448 | When you namespace your code to match your domain names, you can skip prefixing an event name with domain name when emitting it. 449 | 450 | ```ruby 451 | # app/models/oriering/order.rb 452 | 453 | module Ordering 454 | class Order < ApplicationRecord 455 | include PubSub::Emit 456 | 457 | after_create do 458 | emit(:order_created, order_id: id) 459 | # emit(:ordering__order_created, order_id: id) # this will work as well 460 | end 461 | end 462 | end 463 | ``` 464 | 465 | ## Automatic event payload population 466 | 467 | Whenever you emit an event, it will try to populate its payload with data using public interface of object it is emitted from within. 468 | 469 | ```ruby 470 | # app/models/oriering/order.rb 471 | 472 | module Ordering 473 | class Order < ApplicationRecord 474 | include PubSub::Emit 475 | 476 | after_create do 477 | emit(:order_created, order_id: id) 478 | # emit( 479 | # :ordering__order_created, 480 | # order_id: id, # `self` does not implement `order_id`, therefore value has to be provided explicitly here 481 | # total_amount: total_amount, # attribute matches the name of method on `self`, therefore it can be skipped 482 | # comment: comment # same here 483 | # ) 484 | end 485 | end 486 | end 487 | ``` 488 | 489 | # TODO 490 | 491 | - Dynamic event classes 492 | - TYPES declaration 493 | --------------------------------------------------------------------------------