├── .gitignore ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── README.md ├── Rakefile ├── VERSION ├── lib ├── boot.rb ├── bus │ ├── command_bus.rb │ ├── event_bus.rb │ └── router.rb ├── commands │ ├── active_model.rb │ ├── handlers │ │ └── base_handler.rb │ └── invalid_command.rb ├── domain │ └── aggregate_root.rb ├── event_store │ ├── adapters │ │ ├── active_record_adapter.rb │ │ └── in_memory_adapter.rb │ ├── domain_event_storage.rb │ └── domain_repository.rb ├── events │ ├── domain_event.rb │ └── handlers │ │ └── base_handler.rb ├── rcqrs.rb └── support │ ├── guid.rb │ ├── initializer.rb │ └── serialization.rb ├── rcqrs.gemspec └── spec ├── bus ├── command_bus_spec.rb ├── command_router_spec.rb └── event_bus_spec.rb ├── commands ├── command_spec.rb ├── create_company_command.rb └── handlers │ ├── command_handler_spec.rb │ └── create_company_handler.rb ├── domain ├── company.rb ├── company_spec.rb ├── expense.rb └── invoice.rb ├── event_store ├── active_record_adapter_spec.rb └── domain_repository_spec.rb ├── events ├── company_created_event.rb ├── domain_event_spec.rb ├── handlers │ ├── company_created_handler.rb │ └── event_handler_spec.rb └── invoice_created_event.rb ├── initializer_spec.rb ├── mock_router.rb ├── reporting └── company.rb └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | .bundle -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | gem "activesupport", ">= 3.0.0" 4 | gem "activerecord", ">= 3.0.0" 5 | gem "uuidtools" 6 | gem "yajl-ruby", :require => "yajl" 7 | gem "eventful", "1.0.0" 8 | 9 | group :spec do 10 | gem "rspec", ">= 2.5.0" 11 | gem "sqlite3-ruby", "~> 1.3.1", :require => "sqlite3" 12 | end -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: http://rubygems.org/ 3 | specs: 4 | activemodel (3.0.4) 5 | activesupport (= 3.0.4) 6 | builder (~> 2.1.2) 7 | i18n (~> 0.4) 8 | activerecord (3.0.4) 9 | activemodel (= 3.0.4) 10 | activesupport (= 3.0.4) 11 | arel (~> 2.0.2) 12 | tzinfo (~> 0.3.23) 13 | activesupport (3.0.4) 14 | arel (2.0.8) 15 | builder (2.1.2) 16 | diff-lcs (1.1.2) 17 | eventful (1.0.0) 18 | methodphitamine 19 | i18n (0.5.0) 20 | methodphitamine (1.0.0) 21 | rspec (2.5.0) 22 | rspec-core (~> 2.5.0) 23 | rspec-expectations (~> 2.5.0) 24 | rspec-mocks (~> 2.5.0) 25 | rspec-core (2.5.1) 26 | rspec-expectations (2.5.0) 27 | diff-lcs (~> 1.1.2) 28 | rspec-mocks (2.5.0) 29 | sqlite3-ruby (1.3.1) 30 | tzinfo (0.3.24) 31 | uuidtools (2.1.1) 32 | yajl-ruby (0.7.7) 33 | 34 | PLATFORMS 35 | ruby 36 | 37 | DEPENDENCIES 38 | activerecord (>= 3.0.0) 39 | activesupport (>= 3.0.0) 40 | eventful (= 1.0.0) 41 | rspec (>= 2.5.0) 42 | sqlite3-ruby (~> 1.3.1) 43 | uuidtools 44 | yajl-ruby 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010 Ben Smith 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ruby CQRS with Event Sourcing 2 | 3 | A Ruby implementation of Command-Query Responsibility Segregation (CQRS) with Event Sourcing, based upon the ideas of [Greg Young](http://goodenoughsoftware.net/). 4 | 5 | [Find out more about CQRS](http://cqrs.wordpress.com/). 6 | 7 | ## Getting Started 8 | 9 | Dependencies are managed using [Bundler](http://gembundler.com/). 10 | 11 | $ sudo gem install bundler 12 | 13 | Install all of the required gems for this application 14 | 15 | $ sudo bundle install 16 | 17 | ## Specs 18 | 19 | Run the RSpec specifications as follows. 20 | 21 | $ rspec spec/ 22 | 23 | ## Basic Design Overview 24 | 25 | ###UI 26 | 27 | - display query results using read-only reporting datastore 28 | - create commands - must be 'task focused' 29 | - basic validation of command (e.g. required fields, uniqueness using queries against the reporting data store) 30 | 31 | ###Commands 32 | such as `RegisterCompanyCommand` 33 | 34 | - capture users’ intent 35 | - named in the imperative (e.g. create account, upgrade customer, complete checkout) 36 | - can fail or be declined 37 | 38 | ###Command Bus 39 | 40 | - validates command 41 | - routes command to registered handler (there can be only one handler per command) 42 | 43 | ###Command Handler 44 | such as `RegisterCompanyHandler` 45 | 46 | - loads corresponding aggregate root (using domain repository) 47 | - executes action on aggregate root 48 | 49 | ###Aggregate Roots 50 | such as `Company` 51 | 52 | - guard clause (raises exceptions when invalid commands applied) 53 | - calculations (but no state changes) 54 | - create & raise corresponding domain events 55 | - subscribes to domain events to update internal state 56 | 57 | ###Domain Events 58 | such as `CompanyRegisteredEvent` 59 | 60 | - inform something that has already happened 61 | - must be in the past tense and cannot fail 62 | - used to update internal state of the corresponding aggregate root 63 | 64 | ###Event Bus 65 | 66 | - subscribes to domain events 67 | - persist domain events to event store 68 | - routes events to registered handler(s) (can have more than one handler per event) 69 | 70 | ###Event Handler 71 | such as `CompanyRegisteredHandler` 72 | 73 | - update de-normalised reporting data store(s) 74 | - email sending 75 | - execute long running processes (e.g. 3rd party APIs, file upload) 76 | 77 | ###Event Store 78 | 79 | - persists all domain events applied to each aggregate root (stored as JSON) 80 | - reconstitutes aggregate roots from events (from serialised JSON) 81 | - currently two adapters: ActiveRecord and in memory (for testing) 82 | - adapter interface is 2 methods: `find(guid)` and `save(aggregate_root)` 83 | - could be extended to use a NoSQL store -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'rake' 3 | 4 | begin 5 | require 'jeweler' 6 | Jeweler::Tasks.new do |gem| 7 | gem.name = "rcqrs" 8 | gem.summary = %Q{CQRS library in Ruby} 9 | gem.description = %Q{A Ruby implementation of Command-Query Responsibility Segregation (CQRS) with Event Sourcing, based upon the ideas of Greg Young.} 10 | gem.email = "ben@slashdotdash.net" 11 | gem.homepage = "http://github.com/slashdotdash/rcqrs" 12 | gem.authors = ["Ben Smith"] 13 | gem.add_development_dependency "rspec", ">= 1.2.9" 14 | end 15 | Jeweler::GemcutterTasks.new 16 | rescue LoadError 17 | puts "Jeweler (or a dependency) not available. Install it with: gem install jeweler" 18 | end 19 | 20 | require 'spec/rake/spectask' 21 | Spec::Rake::SpecTask.new(:spec) do |spec| 22 | spec.libs << 'lib' << 'spec' 23 | spec.spec_files = FileList['spec/**/*_spec.rb'] 24 | end 25 | 26 | Spec::Rake::SpecTask.new(:rcov) do |spec| 27 | spec.libs << 'lib' << 'spec' 28 | spec.pattern = 'spec/**/*_spec.rb' 29 | spec.rcov = true 30 | end 31 | 32 | task :spec => :check_dependencies 33 | 34 | task :default => :spec 35 | 36 | require 'rake/rdoctask' 37 | Rake::RDocTask.new do |rdoc| 38 | version = File.exist?('VERSION') ? File.read('VERSION') : "" 39 | 40 | rdoc.rdoc_dir = 'rdoc' 41 | rdoc.title = "the-perfect-gem #{version}" 42 | rdoc.rdoc_files.include('README*') 43 | rdoc.rdoc_files.include('lib/**/*.rb') 44 | end 45 | 46 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 0.1.0 -------------------------------------------------------------------------------- /lib/boot.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'bundler' 3 | 4 | Bundler.setup 5 | 6 | require 'rcqrs' -------------------------------------------------------------------------------- /lib/bus/command_bus.rb: -------------------------------------------------------------------------------- 1 | module Bus 2 | class CommandBus 3 | def initialize(router, repository) 4 | @router, @repository = router, repository 5 | end 6 | 7 | # Dispatch command to registered handler 8 | def dispatch(command) 9 | raise Commands::InvalidCommand unless command.valid? 10 | 11 | handler = @router.handler_for(command, @repository) 12 | handler.execute(command) 13 | end 14 | end 15 | end -------------------------------------------------------------------------------- /lib/bus/event_bus.rb: -------------------------------------------------------------------------------- 1 | module Bus 2 | class EventBus 3 | def initialize(router) 4 | @router = router 5 | end 6 | 7 | # Publish event to registered handlers 8 | def publish(event) 9 | @router.handlers_for(event).each do |handler| 10 | handler.execute(event) 11 | end 12 | end 13 | end 14 | end -------------------------------------------------------------------------------- /lib/bus/router.rb: -------------------------------------------------------------------------------- 1 | module Bus 2 | class MissingHandler < StandardError; end 3 | 4 | class Router 5 | protected 6 | def handler_class_for(target) 7 | handler_name = "#{target.class.name.gsub(/Event$|Command$/, '')}Handler" 8 | handler_name.gsub!(/::/, '::Handlers::') if handler_name =~ /::/ 9 | handler_name.constantize 10 | rescue NameError => ex 11 | raise MissingHandler.new("No handler found for #{target.class.name} (expected #{handler_name})") 12 | end 13 | 14 | def handlers 15 | @handlers ||= {} 16 | end 17 | end 18 | 19 | class CommandRouter < Router 20 | # Commands can only have a single handler 21 | def handler_for(target, repository) 22 | handler_class = (handlers[target.class] ||= handler_class_for(target)) 23 | handler_class.new(repository) 24 | end 25 | end 26 | 27 | class EventRouter < Router 28 | # Events may have one or more handlers 29 | def handlers_for(target) 30 | handler_class = (handlers[target.class] ||= handler_class_for(target)) 31 | [ handler_class.new ] # (only currently support single handler) 32 | end 33 | end 34 | end -------------------------------------------------------------------------------- /lib/commands/active_model.rb: -------------------------------------------------------------------------------- 1 | module Commands 2 | module ActiveModel 3 | def self.extended(base) 4 | base.class_eval do 5 | include ::ActiveModel::Conversion 6 | include ::ActiveModel::AttributeMethods 7 | include ::ActiveModel::Validations 8 | extend ::ActiveModel::Naming 9 | 10 | extend ::Rcqrs::Initializer 11 | include Commands::ActiveModel 12 | end 13 | end 14 | 15 | def parse_date(date) 16 | return date.to_date if date.is_a?(Date) || date.is_a?(DateTime) || date.is_a?(ActiveSupport::TimeWithZone) 17 | return nil if date.blank? 18 | 19 | return DateTime.strptime(date, '%d/%m/%Y').to_date 20 | rescue 21 | return date 22 | end 23 | 24 | # Commands are never persisted 25 | def persisted? 26 | false 27 | end 28 | 29 | # def to_key 30 | # nil 31 | # end 32 | # 33 | # def to_model 34 | # self 35 | # end 36 | end 37 | end -------------------------------------------------------------------------------- /lib/commands/handlers/base_handler.rb: -------------------------------------------------------------------------------- 1 | module Commands 2 | module Handlers 3 | class BaseHandler 4 | def initialize(repository) 5 | @repository = repository 6 | end 7 | 8 | def execute(command) 9 | raise NotImplementedError, 'method to be implemented in handler' 10 | end 11 | end 12 | end 13 | end -------------------------------------------------------------------------------- /lib/commands/invalid_command.rb: -------------------------------------------------------------------------------- 1 | module Commands 2 | class InvalidCommand < StandardError; end 3 | end -------------------------------------------------------------------------------- /lib/domain/aggregate_root.rb: -------------------------------------------------------------------------------- 1 | module Domain 2 | module AggregateRoot 3 | def self.extended(base) 4 | base.class_eval do 5 | include InstanceMethods 6 | end 7 | end 8 | 9 | def create_from_event(event) 10 | self.new.tap do |aggregate| 11 | aggregate.send(:apply, event) 12 | end 13 | end 14 | 15 | module InstanceMethods 16 | attr_reader :guid, :version, :source_version 17 | 18 | # Replay the given events, ordered by version 19 | def load(events) 20 | @replaying = true 21 | 22 | events.sort_by {|e| e.version }.each do |event| 23 | replay(event) 24 | end 25 | ensure 26 | @replaying = false 27 | end 28 | 29 | def replaying? 30 | @replaying 31 | end 32 | 33 | # Events applied since the source version (unsaved events) 34 | def pending_events 35 | @pending_events.sort_by {|e| e.version } 36 | end 37 | 38 | # Are there any 39 | def pending_events? 40 | @pending_events.any? 41 | end 42 | 43 | def commit 44 | @pending_events = [] 45 | @source_version = @version 46 | end 47 | 48 | protected 49 | 50 | def initialize 51 | @version = 0 52 | @source_version = 0 53 | @pending_events = [] 54 | @event_handlers = {} 55 | end 56 | 57 | def apply(event) 58 | apply_event(event) 59 | update_event(event) 60 | 61 | @pending_events << event 62 | end 63 | 64 | private 65 | 66 | # Replay an existing event loaded from storage 67 | def replay(event) 68 | apply_event(event) 69 | @source_version += 1 70 | end 71 | 72 | def apply_event(event) 73 | invoke_event_handler(event) 74 | @version += 1 75 | end 76 | 77 | def update_event(event) 78 | event.aggregate_id = @guid 79 | event.version = @version 80 | event.timestamp = Time.now.gmtime 81 | end 82 | 83 | def invoke_event_handler(event) 84 | target = handler_for(event.class) 85 | self.send(target, event) 86 | end 87 | 88 | # Map event type to method name: CompanyRegisteredEvent => on_company_registered(event) 89 | def handler_for(event_type) 90 | @event_handlers[event_type] ||= begin 91 | target = event_type.to_s.demodulize.underscore.sub(/_event$/, '') 92 | "on_#{target}".to_sym 93 | end 94 | end 95 | end 96 | end 97 | end -------------------------------------------------------------------------------- /lib/event_store/adapters/active_record_adapter.rb: -------------------------------------------------------------------------------- 1 | require 'active_record' 2 | 3 | module EventStore 4 | module Adapters 5 | # Represents every aggregate created 6 | class EventProvider < ActiveRecord::Base 7 | def self.find(guid) 8 | return nil if guid.blank? 9 | where(:aggregate_id => guid).first 10 | end 11 | 12 | def events 13 | Event.for(aggregate_id) 14 | end 15 | end 16 | 17 | class Event < ActiveRecord::Base 18 | scope :for, lambda { |guid| where(:aggregate_id => guid).order(:version) } 19 | end 20 | 21 | class ActiveRecordAdapter < EventStore::DomainEventStorage 22 | def initialize(options={}) 23 | options.reverse_merge!(:adapter => 'sqlite3', :database => 'events.db') 24 | establish_connection(options) 25 | ensure_tables_exist 26 | end 27 | 28 | def find(guid) 29 | EventProvider.find(guid) 30 | end 31 | 32 | def save(aggregate) 33 | provider = find_or_create_provider(aggregate) 34 | save_events(aggregate.pending_events) 35 | provider.update_attribute(:version, aggregate.version) 36 | end 37 | 38 | def transaction(&block) 39 | ActiveRecord::Base.transaction do 40 | yield 41 | end 42 | end 43 | 44 | def provider_connection 45 | EventProvider.connection 46 | end 47 | 48 | def event_connection 49 | Event.connection 50 | end 51 | 52 | private 53 | 54 | def find_or_create_provider(aggregate) 55 | if provider = EventProvider.find(aggregate.guid) 56 | raise AggregateConcurrencyError unless provider.version == aggregate.source_version 57 | else 58 | provider = create_provider(aggregate) 59 | end 60 | provider 61 | end 62 | 63 | def create_provider(aggregate) 64 | EventProvider.create!( 65 | :aggregate_id => aggregate.guid, 66 | :aggregate_type => aggregate.class.name, 67 | :version => 0) 68 | end 69 | 70 | def save_events(events) 71 | events.each do |event| 72 | Event.create!( 73 | :aggregate_id => event.aggregate_id, 74 | :event_type => event.class.name, 75 | :version => event.version, 76 | :data => event.attributes_to_json) 77 | end 78 | end 79 | 80 | # Connect to a different database for event storage models 81 | def establish_connection(options) 82 | EventProvider.establish_connection(options) 83 | Event.establish_connection(options) 84 | end 85 | 86 | def ensure_tables_exist 87 | ensure_event_providers_table_exists 88 | ensure_events_table_exists 89 | end 90 | 91 | def ensure_event_providers_table_exists 92 | return if EventProvider.table_exists? 93 | 94 | provider_connection.create_table(:event_providers) do |t| 95 | t.string :aggregate_id, :limit => 36, :primary => true 96 | t.string :aggregate_type, :null => false 97 | t.integer :version, :null => false 98 | t.timestamps 99 | end 100 | 101 | provider_connection.add_index :event_providers, :aggregate_id, :unique => true 102 | end 103 | 104 | def ensure_events_table_exists 105 | return if Event.table_exists? 106 | 107 | # no primary key as we do not update or delete from this table 108 | event_connection.create_table(:events, :id => false) do |t| 109 | t.string :aggregate_id, :limit => 36, :null => false 110 | t.string :event_type, :null => false 111 | t.integer :version, :null => false 112 | t.text :data, :null => false 113 | t.timestamp :created_at, :null => false 114 | end 115 | 116 | event_connection.add_index :events, :aggregate_id 117 | event_connection.add_index :events, [:aggregate_id, :version] 118 | end 119 | end 120 | end 121 | end -------------------------------------------------------------------------------- /lib/event_store/adapters/in_memory_adapter.rb: -------------------------------------------------------------------------------- 1 | require 'active_record' 2 | 3 | module EventStore 4 | module Adapters 5 | module InMemory 6 | class EventProvider 7 | attr_reader :aggregate_id, :aggregate_type, :version, :events 8 | 9 | def initialize(aggregate) 10 | @aggregate_id = aggregate.guid 11 | @aggregate_type = aggregate.class.name 12 | @version = aggregate.version 13 | @events = aggregate.pending_events.map {|e| Event.new(e) } 14 | end 15 | end 16 | 17 | class Event 18 | attr_reader :aggregate_id, :event_type, :version, :data 19 | 20 | def initialize(event) 21 | @aggregate_id = event.aggregate_id 22 | @event_type = event.class.name 23 | @version = event.version 24 | @data = event.attributes_to_json 25 | end 26 | end 27 | end 28 | 29 | class InMemoryAdapter < EventStore::DomainEventStorage 30 | attr_reader :storage 31 | 32 | def initialize(options={}) 33 | @storage = {} 34 | end 35 | 36 | def find(guid) 37 | @storage[guid] 38 | end 39 | 40 | def save(aggregate) 41 | @storage[aggregate.guid] = InMemory::EventProvider.new(aggregate) 42 | end 43 | end 44 | end 45 | end -------------------------------------------------------------------------------- /lib/event_store/domain_event_storage.rb: -------------------------------------------------------------------------------- 1 | module EventStore 2 | class DomainEventStorage 3 | def find(guid) 4 | raise 'method to be implemented in adapter' 5 | end 6 | 7 | def save(aggregate) 8 | raise 'method to be implemented in adapter' 9 | end 10 | 11 | # Default does not support transactions 12 | def transaction(&block) 13 | yield 14 | end 15 | end 16 | end -------------------------------------------------------------------------------- /lib/event_store/domain_repository.rb: -------------------------------------------------------------------------------- 1 | module EventStore 2 | class AggregateNotFound < StandardError; end 3 | class AggregateConcurrencyError < StandardError; end 4 | class UnknownAggregateClass < StandardError; end 5 | 6 | class DomainRepository 7 | include Eventful 8 | 9 | def initialize(event_store) 10 | @event_store = event_store 11 | @tracked_aggregates = {} 12 | @within_transaction = false 13 | end 14 | 15 | # Persist the +aggregate+ to the event store 16 | def save(aggregate) 17 | transaction { track(aggregate) } 18 | end 19 | 20 | # Find an aggregate by the given +guid+ 21 | # Track any changes to the returned aggregate, commiting those changes when saving aggregates 22 | # 23 | # == Exceptions 24 | # 25 | # * AggregateNotFound - No aggregate for the given +guid+ was found 26 | # * UnknownAggregateClass - The type of aggregate is unknown 27 | def find(guid) 28 | return @tracked_aggregates[guid] if @tracked_aggregates.has_key?(guid) 29 | 30 | provider = @event_store.find(guid) 31 | raise AggregateNotFound if provider.nil? 32 | 33 | load_aggregate(provider.aggregate_type, provider.events) 34 | end 35 | 36 | # Save changes to the event store within a transaction 37 | def transaction(&block) 38 | yield and return if within_transaction? 39 | 40 | @within_transaction = true 41 | 42 | @event_store.transaction do 43 | yield 44 | persist_aggregates_to_event_store 45 | end 46 | rescue 47 | @tracked_aggregates.clear # abandon changes on exception 48 | raise 49 | ensure 50 | @within_transaction = false 51 | end 52 | 53 | def within_transaction? 54 | @within_transaction 55 | end 56 | 57 | private 58 | 59 | # Track changes to this aggregate root so that any unsaved events 60 | # are persisted when save is called (for any aggregate) 61 | def track(aggregate) 62 | @tracked_aggregates[aggregate.guid] = aggregate 63 | end 64 | 65 | def persist_aggregates_to_event_store 66 | committed_events = [] 67 | 68 | @tracked_aggregates.each do |guid, tracked| 69 | next unless tracked.pending_events? 70 | 71 | @event_store.save(tracked) 72 | committed_events += tracked.pending_events 73 | tracked.commit 74 | end 75 | 76 | committed_events.sort_by(&:timestamp).each {|event| fire(:domain_event, event) } 77 | end 78 | 79 | # Get unsaved events for all tracked aggregates, ordered by time applied 80 | # def pending_events 81 | # @tracked_aggregates.map {|guid, tracked| tracked.pending_events }.flatten!.sort_by(&:timestamp) 82 | # end 83 | 84 | # Recreate an aggregate root by re-applying all saved +events+ 85 | def load_aggregate(klass, events) 86 | create_aggregate(klass).tap do |aggregate| 87 | events.map! {|event| create_event(event) } 88 | aggregate.load(events) 89 | track(aggregate) 90 | end 91 | end 92 | 93 | # Create a new instance an aggregate from the given +events+ 94 | def create_aggregate(klass) 95 | klass.constantize.new 96 | end 97 | 98 | # Create a new instance of the domain event from the serialized json 99 | def create_event(event) 100 | event.event_type.constantize.from_json(event.data).tap do |domain_event| 101 | domain_event.version = event.version.to_i 102 | domain_event.aggregate_id = event.aggregate_id.to_s 103 | end 104 | end 105 | end 106 | end -------------------------------------------------------------------------------- /lib/events/domain_event.rb: -------------------------------------------------------------------------------- 1 | module Events 2 | class DomainEvent 3 | extend Rcqrs::Initializer 4 | include Rcqrs::Serialization 5 | 6 | attr_accessor :aggregate_id, :version, :timestamp 7 | end 8 | end -------------------------------------------------------------------------------- /lib/events/handlers/base_handler.rb: -------------------------------------------------------------------------------- 1 | module Events 2 | module Handlers 3 | class BaseHandler 4 | def execute(event) 5 | raise NotImplementedError, 'method to be implemented in handler' 6 | end 7 | end 8 | end 9 | end -------------------------------------------------------------------------------- /lib/rcqrs.rb: -------------------------------------------------------------------------------- 1 | require 'uuidtools' 2 | require 'active_support' 3 | require 'active_support/core_ext/object/returning' 4 | require 'yajl' 5 | require 'eventful' 6 | 7 | require 'support/guid' 8 | require 'support/serialization' 9 | require 'support/initializer' 10 | 11 | require 'event_store/domain_event_storage' 12 | require 'event_store/domain_repository' 13 | require 'event_store/adapters/active_record_adapter' 14 | require 'event_store/adapters/in_memory_adapter' 15 | 16 | require 'bus/router' 17 | require 'bus/command_bus' 18 | require 'bus/event_bus' 19 | 20 | require 'commands/invalid_command' 21 | require 'commands/active_model' 22 | require 'commands/handlers/base_handler' 23 | 24 | require 'events/domain_event' 25 | require 'events/handlers/base_handler' 26 | 27 | require 'domain/aggregate_root' -------------------------------------------------------------------------------- /lib/support/guid.rb: -------------------------------------------------------------------------------- 1 | module Rcqrs 2 | class Guid 3 | def self.create 4 | UUIDTools::UUID.timestamp_create.to_s 5 | end 6 | 7 | def self.parse(guid) 8 | UUIDTools::UUID.parse(guid) 9 | end 10 | 11 | # Is the given string a valid guid? 12 | def self.valid?(guid) 13 | UUIDTools::UUID.parse_raw(guid).valid? 14 | end 15 | end 16 | end -------------------------------------------------------------------------------- /lib/support/initializer.rb: -------------------------------------------------------------------------------- 1 | module Rcqrs 2 | # == Rcqrs Initializer 3 | # 4 | # Rcqrs::Initializer provides a way to easily initialize Ruby objects 5 | # using named or positional arguments to the constructor 6 | # 7 | # Example: 8 | # 9 | # class Car 10 | # extend Initializer 11 | # attr_reader :manufacturer, :model 12 | # end 13 | # 14 | # Provides you with: 15 | # 16 | # car = Car.new('Ford', 'Focus') 17 | # car = Car.new(:manufacturer => 'Ford', :model => 'Focus') 18 | # car = Car.new do 19 | # # custom initialize code goes here 20 | # end 21 | # 22 | module Initializer 23 | def self.extended(base) 24 | base.class_eval do 25 | include(InstanceMethods) 26 | end 27 | end 28 | 29 | # Initialize an object using either argument position or named arguments. 30 | # Last argument may be a hash of options such as :attr_reader => true 31 | def initializer(*args, &block) 32 | initializer_attributes = args.dup 33 | 34 | extract_last_arg_as_options!(initializer_attributes) 35 | 36 | define_method :initialize do |*ctor_args| 37 | ctor_named_args = (ctor_args.last.is_a?(Hash) ? ctor_args.pop : {}) 38 | 39 | initialize_from_attributes(ctor_args) 40 | initialize_from_named_args(ctor_named_args) 41 | initialize_from_constructor_args_by_order(initializer_attributes, ctor_args) 42 | 43 | initialize_behavior if block_given? 44 | end 45 | 46 | define_method :initialize_behavior, &block if block_given? 47 | 48 | # Get all attributes defined in the initializer as a hash 49 | define_method :attributes do 50 | (initializer_attributes || []).inject({}) do |attrs, attribute| 51 | attrs.merge!(attribute.to_sym => instance_variable_get("@#{attribute}")) 52 | end 53 | end 54 | 55 | define_method :attributes_to_json do 56 | to_json(attributes) 57 | end 58 | end 59 | 60 | def extract_last_arg_as_options!(args) 61 | last = (args.last.is_a?(Hash) ? args.pop : {}) 62 | 63 | if last[:attr_reader] == true 64 | args.each do |attr| 65 | send(:attr_reader, attr) 66 | end 67 | end 68 | end 69 | 70 | module InstanceMethods 71 | 72 | private 73 | 74 | # allow creation of this object from another initializer object's attributes 75 | def initialize_from_attributes(ctor_args) 76 | if ctor_args.first.respond_to?(:attributes) 77 | obj = ctor_args.shift 78 | 79 | obj.attributes.each do |name, value| 80 | instance_variable_set("@#{name}", value) 81 | end 82 | end 83 | end 84 | 85 | def initialize_from_named_args(ctor_named_args) 86 | ctor_named_args.each_pair do |param_name, param_value| 87 | raise(ArgumentError, "Unknown method #{param_name}, add it to the record attributes") unless respond_to?(:"#{param_name}") 88 | instance_variable_set("@#{param_name}", param_value) 89 | end 90 | end 91 | 92 | def initialize_from_constructor_args_by_order(initializer_attributes, ctor_args) 93 | return if ctor_args.empty? 94 | 95 | initializer_attributes.each_with_index do |arg, index| 96 | instance_variable_set("@#{arg}", ctor_args[index]) 97 | end 98 | end 99 | end 100 | end 101 | end -------------------------------------------------------------------------------- /lib/support/serialization.rb: -------------------------------------------------------------------------------- 1 | module Rcqrs 2 | module Serialization 3 | def self.included(base) 4 | base.extend ClassMethods 5 | end 6 | 7 | def to_json(attributes=self.attributes) 8 | Yajl::Encoder.encode(attributes) 9 | end 10 | 11 | module ClassMethods 12 | def from_json(json) 13 | parsed = Yajl::Parser.parse(json) 14 | self.new(parsed) 15 | end 16 | end 17 | end 18 | end -------------------------------------------------------------------------------- /rcqrs.gemspec: -------------------------------------------------------------------------------- 1 | # Generated by jeweler 2 | # DO NOT EDIT THIS FILE DIRECTLY 3 | # Instead, edit Jeweler::Tasks in Rakefile, and run the gemspec command 4 | # -*- encoding: utf-8 -*- 5 | 6 | Gem::Specification.new do |s| 7 | s.name = %q{rcqrs} 8 | s.version = "0.1.0" 9 | 10 | s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version= 11 | s.authors = ["Ben Smith"] 12 | s.date = %q{2010-09-22} 13 | s.description = %q{A Ruby implementation of Command-Query Responsibility Segregation (CQRS) with Event Sourcing, based upon the ideas of Greg Young.} 14 | s.email = %q{ben@slashdotdash.net} 15 | s.extra_rdoc_files = [ 16 | "LICENSE", 17 | "README.md" 18 | ] 19 | s.files = [ 20 | ".gitignore", 21 | "Gemfile", 22 | "Gemfile.lock", 23 | "README.md", 24 | "lib/boot.rb", 25 | "lib/bus/command_bus.rb", 26 | "lib/bus/event_bus.rb", 27 | "lib/bus/router.rb", 28 | "lib/commands/active_model.rb", 29 | "lib/commands/invalid_command.rb", 30 | "lib/commands/handlers/base_handler.rb", 31 | "lib/domain/aggregate_root.rb", 32 | "lib/event_store/adapters/active_record_adapter.rb", 33 | "lib/event_store/adapters/in_memory_adapter.rb", 34 | "lib/event_store/domain_event_storage.rb", 35 | "lib/event_store/domain_repository.rb", 36 | "lib/events/domain_event.rb", 37 | "lib/events/handlers/base_handler.rb", 38 | "lib/rcqrs.rb", 39 | "lib/support/guid.rb", 40 | "lib/support/initializer.rb", 41 | "lib/support/serialization.rb", 42 | "spec/bus/command_bus_spec.rb", 43 | "spec/bus/command_router_spec.rb", 44 | "spec/bus/event_bus_spec.rb", 45 | "spec/commands/create_company_command.rb", 46 | "spec/commands/handlers/command_handler_spec.rb", 47 | "spec/commands/handlers/create_company_handler.rb", 48 | "spec/domain/company.rb", 49 | "spec/domain/company_spec.rb", 50 | "spec/domain/expense.rb", 51 | "spec/domain/invoice.rb", 52 | "spec/event_store/active_record_adapter_spec.rb", 53 | "spec/event_store/domain_repository_spec.rb", 54 | "spec/events/company_created_event.rb", 55 | "spec/events/domain_event_spec.rb", 56 | "spec/events/handlers/company_created_handler.rb", 57 | "spec/events/handlers/event_handler_spec.rb", 58 | "spec/events/invoice_created_event.rb", 59 | "spec/initializer_spec.rb", 60 | "spec/mock_router.rb", 61 | "spec/reporting/company.rb", 62 | "spec/spec_helper.rb" 63 | ] 64 | s.homepage = %q{http://github.com/slashdotdash/rcqrs} 65 | s.rdoc_options = ["--charset=UTF-8"] 66 | s.require_paths = ["lib"] 67 | s.rubygems_version = %q{1.3.7} 68 | s.summary = %q{CQRS library in Ruby} 69 | s.test_files = [ 70 | "spec/bus/command_bus_spec.rb", 71 | "spec/bus/command_router_spec.rb", 72 | "spec/bus/event_bus_spec.rb", 73 | "spec/commands/create_company_command.rb", 74 | "spec/commands/handlers/command_handler_spec.rb", 75 | "spec/commands/handlers/create_company_handler.rb", 76 | "spec/domain/company.rb", 77 | "spec/domain/company_spec.rb", 78 | "spec/domain/expense.rb", 79 | "spec/domain/invoice.rb", 80 | "spec/event_store/active_record_adapter_spec.rb", 81 | "spec/event_store/domain_repository_spec.rb", 82 | "spec/events/company_created_event.rb", 83 | "spec/events/domain_event_spec.rb", 84 | "spec/events/handlers/company_created_handler.rb", 85 | "spec/events/handlers/event_handler_spec.rb", 86 | "spec/events/invoice_created_event.rb", 87 | "spec/initializer_spec.rb", 88 | "spec/mock_router.rb", 89 | "spec/reporting/company.rb", 90 | "spec/spec_helper.rb" 91 | ] 92 | 93 | if s.respond_to? :specification_version then 94 | current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION 95 | s.specification_version = 3 96 | 97 | if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then 98 | s.add_development_dependency(%q, [">= 1.2.9"]) 99 | else 100 | s.add_dependency(%q, [">= 1.2.9"]) 101 | end 102 | else 103 | s.add_dependency(%q, [">= 1.2.9"]) 104 | end 105 | end 106 | 107 | -------------------------------------------------------------------------------- /spec/bus/command_bus_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), '../spec_helper') 2 | 3 | module Bus 4 | describe CommandBus do 5 | context "when dispatching commands" do 6 | before(:each) do 7 | @router = MockRouter.new 8 | @bus = CommandBus.new(@router, nil) 9 | end 10 | 11 | context "invalid command" do 12 | subject { Commands::CreateCompanyCommand.new } 13 | 14 | it "should raise an InvalidCommand exception when the command is invalid" do 15 | proc { @bus.dispatch(subject) }.should raise_error(Commands::InvalidCommand) 16 | subject.errors[:name].should == ["can't be blank"] 17 | end 18 | end 19 | 20 | context "valid command" do 21 | subject { Commands::CreateCompanyCommand.new('foo') } 22 | 23 | it "should execute handler for given command" do 24 | @bus.dispatch(subject) 25 | @router.handled.should == true 26 | end 27 | end 28 | end 29 | end 30 | end -------------------------------------------------------------------------------- /spec/bus/command_router_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), '../spec_helper') 2 | 3 | module Bus 4 | describe CommandRouter do 5 | context "when getting handler for commands" do 6 | before(:each) do 7 | @router = CommandRouter.new 8 | @repository = mock 9 | end 10 | 11 | it "should raise a Bus::MissingHandler exception when no command handler is found" do 12 | proc { @router.handler_for(nil, @repository) }.should raise_error(Bus::MissingHandler) 13 | end 14 | 15 | it "should find the corresponding handler for a command" do 16 | command = Commands::CreateCompanyCommand.new 17 | handler = @router.handler_for(command, @repository) 18 | handler.should be_instance_of(Commands::Handlers::CreateCompanyHandler) 19 | end 20 | end 21 | end 22 | end -------------------------------------------------------------------------------- /spec/bus/event_bus_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), '../spec_helper') 2 | 3 | module Bus 4 | describe EventBus do 5 | context "when publishing events" do 6 | before(:each) do 7 | @router = MockRouter.new 8 | @bus = EventBus.new(@router) 9 | @bus.publish(Events::CompanyCreatedEvent.new) 10 | end 11 | 12 | it "should execute handler(s) for raised event" do 13 | @router.handled.should == true 14 | end 15 | end 16 | end 17 | end -------------------------------------------------------------------------------- /spec/commands/command_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), '../spec_helper') 2 | 3 | # convert ActiveModel lint tests to RSpec 4 | shared_examples_for "ActiveModel" do 5 | require 'test/unit/assertions' 6 | 7 | include Test::Unit::Assertions 8 | include ActiveModel::Lint::Tests 9 | 10 | # to_s is to support ruby-1.9 11 | ActiveModel::Lint::Tests.public_instance_methods.map{|m| m.to_s}.grep(/^test/).each do |m| 12 | example m.gsub('_',' ') do 13 | send m 14 | end 15 | end 16 | 17 | def model 18 | subject 19 | end 20 | end 21 | 22 | module Commands 23 | describe CreateCompanyCommand do 24 | before(:each) do 25 | @command = Commands::CreateCompanyCommand.new(:name => 'ACME corp') 26 | end 27 | 28 | subject { @command } 29 | it_should_behave_like "ActiveModel" 30 | 31 | specify { @command.valid? } 32 | 33 | context "invalid command" do 34 | before(:each) do 35 | @command = Commands::CreateCompanyCommand.new 36 | @command.valid? 37 | end 38 | 39 | specify { @command.invalid? } 40 | specify { @command.errors[:name].should == ["can't be blank"] } 41 | end 42 | end 43 | end -------------------------------------------------------------------------------- /spec/commands/create_company_command.rb: -------------------------------------------------------------------------------- 1 | module Commands 2 | class CreateCompanyCommand 3 | extend ActiveModel 4 | 5 | attr_reader :name 6 | validates_presence_of :name 7 | 8 | initializer :name 9 | end 10 | end -------------------------------------------------------------------------------- /spec/commands/handlers/command_handler_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), '../../spec_helper') 2 | 3 | module Commands 4 | module Handlers 5 | describe CreateCompanyHandler do 6 | before(:each) do 7 | @repository = mock 8 | @handler = CreateCompanyHandler.new(@repository) 9 | end 10 | 11 | context "when executing command" do 12 | before(:each) do 13 | @repository.should_receive(:save) {|company| @company = company } 14 | @command = Commands::CreateCompanyCommand.new(:name => 'ACME corp') 15 | @handler.execute(@command) 16 | end 17 | 18 | specify { @company.should be_instance_of(Domain::Company) } 19 | specify { @company.name.should == @command.name } 20 | end 21 | end 22 | end 23 | end -------------------------------------------------------------------------------- /spec/commands/handlers/create_company_handler.rb: -------------------------------------------------------------------------------- 1 | module Commands 2 | module Handlers 3 | class CreateCompanyHandler < BaseHandler 4 | def execute(command) 5 | company = Domain::Company.create(command.name) 6 | @repository.save(company) 7 | end 8 | end 9 | end 10 | end -------------------------------------------------------------------------------- /spec/domain/company.rb: -------------------------------------------------------------------------------- 1 | module Domain 2 | class Company 3 | extend AggregateRoot 4 | 5 | attr_reader :name 6 | attr_reader :invoices 7 | 8 | def self.create(name) 9 | event = Events::CompanyCreatedEvent.new(:guid => Rcqrs::Guid.create, :name => name) 10 | create_from_event(event) 11 | end 12 | 13 | def create_invoice(number, date, description, amount) 14 | vat = amount * 0.175 15 | apply(Events::InvoiceCreatedEvent.new(number, date, description, amount, vat)) 16 | end 17 | 18 | private 19 | 20 | def on_company_created(event) 21 | @guid, @name = event.guid, event.name 22 | @invoices = [] 23 | end 24 | 25 | def on_invoice_created(event) 26 | @invoices << Invoice.new(event.attributes) 27 | end 28 | end 29 | end -------------------------------------------------------------------------------- /spec/domain/company_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), '../spec_helper') 2 | 3 | module Domain 4 | describe Company do 5 | context "when creating" do 6 | before(:each) do 7 | @company = Company.create('ACME Corp') 8 | end 9 | 10 | subject { @company } 11 | 12 | it "should set the company name" do 13 | @company.name.should == 'ACME Corp' 14 | end 15 | 16 | it "should have a Events::CompanyCreatedEvent event" do 17 | @company.pending_events.length.should == 1 18 | @company.pending_events.last.should be_an_instance_of(Events::CompanyCreatedEvent) 19 | end 20 | 21 | specify { @company.pending_events?.should == true } 22 | specify { @company.version.should == 1 } 23 | specify { @company.source_version.should == 0 } 24 | specify { @company.pending_events.length.should == 1 } 25 | 26 | context "when commiting pending changes" do 27 | before(:each) do 28 | @company.commit 29 | end 30 | 31 | it "should have no pending changes" do 32 | @company.pending_events?.should == false 33 | end 34 | 35 | it "should have 0 pending events" do 36 | @company.pending_events.length.should == 0 37 | end 38 | 39 | it "should update source version" do 40 | @company.source_version.should == 1 41 | end 42 | 43 | specify { @company.version.should == 1 } 44 | end 45 | 46 | context "when adding an invoice" do 47 | before(:each) do 48 | @company.create_invoice("invoice-1", Time.now, "First invoice", 100) 49 | end 50 | 51 | it "should create a new invoice" do 52 | @company.invoices.length.should == 1 53 | end 54 | 55 | it "should have a Events::InvoiceCreatedEvent event" do 56 | @company.pending_events.length.should == 2 57 | @company.pending_events.last.should be_an_instance_of(Events::InvoiceCreatedEvent) 58 | end 59 | end 60 | end 61 | 62 | context "when loading from events" do 63 | before(:each) do 64 | events = [ 65 | Events::CompanyCreatedEvent.new(Rcqrs::Guid.create, 'ACME Corp'), 66 | Events::InvoiceCreatedEvent.new('1', Time.now, '', 100, 17.5), 67 | Events::InvoiceCreatedEvent.new('2', Time.now, '', 50, 17.5/2) 68 | ] 69 | events.each_with_index {|e, i| e.version = i + 1 } 70 | 71 | @company = Company.new 72 | @company.load(events) 73 | end 74 | 75 | subject { @company } 76 | specify { @company.version.should == 3 } 77 | specify { @company.source_version.should == 3 } 78 | specify { @company.pending_events.length.should == 0 } 79 | specify { @company.replaying?.should == false } 80 | 81 | it "should have created 2 invoices" do 82 | @company.invoices.length.should == 2 83 | end 84 | end 85 | end 86 | end -------------------------------------------------------------------------------- /spec/domain/expense.rb: -------------------------------------------------------------------------------- 1 | module Domain 2 | class Expense 3 | extend Rcqrs::Initializer 4 | initializer :date, :description, :gross, :vat, :attr_reader => true 5 | end 6 | end -------------------------------------------------------------------------------- /spec/domain/invoice.rb: -------------------------------------------------------------------------------- 1 | module Domain 2 | class Invoice 3 | extend Rcqrs::Initializer 4 | initializer :number, :date, :description, :gross, :vat, :attr_reader => true 5 | end 6 | end -------------------------------------------------------------------------------- /spec/event_store/active_record_adapter_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), '../spec_helper') 2 | 3 | module EventStore 4 | module Adapters 5 | describe ActiveRecordAdapter do 6 | before(:each) do 7 | # Use an in-memory sqlite db 8 | @adapter = ActiveRecordAdapter.new(:adapter => 'sqlite3', :database => ':memory:') 9 | @aggregate = Domain::Company.create('ACME Corp') 10 | end 11 | 12 | context "when saving events" do 13 | before(:each) do 14 | @adapter.save(@aggregate) 15 | @provider = @adapter.find(@aggregate.guid) 16 | end 17 | 18 | it "should persist a single event provider (aggregate)" do 19 | count = @adapter.provider_connection.select_value('select count(*) from event_providers').to_i 20 | count.should == 1 21 | end 22 | 23 | it "should persist a single event" do 24 | count = @adapter.event_connection.select_value('select count(*) from events').to_i 25 | count.should == 1 26 | end 27 | 28 | specify { @provider.aggregate_type.should == 'Domain::Company' } 29 | specify { @provider.aggregate_id.should == @aggregate.guid } 30 | specify { @provider.version.should == 1 } 31 | specify { @provider.events.count.should == 1 } 32 | 33 | context "persisted event" do 34 | before(:each) do 35 | @event = @provider.events.first 36 | end 37 | 38 | specify { @event.aggregate_id.should == @aggregate.guid } 39 | specify { @event.event_type.should == 'Events::CompanyCreatedEvent' } 40 | specify { @event.version.should == 1 } 41 | end 42 | end 43 | 44 | context "when saving incorrect aggregate version" do 45 | before(:each) do 46 | @adapter.save(@aggregate) 47 | end 48 | 49 | it "should raise AggregateConcurrencyError exception" do 50 | proc { @adapter.save(@aggregate) }.should raise_error(AggregateConcurrencyError) 51 | end 52 | end 53 | 54 | context "when finding events" do 55 | it "should return nil when aggregate not found" do 56 | @adapter.find('').should == nil 57 | end 58 | end 59 | 60 | context "when saving aggregate within a transaction" do 61 | before (:each) do 62 | begin 63 | @adapter.transaction do 64 | @adapter.save(@aggregate) 65 | event_provider_count.should == 1 66 | event_count.should == 1 67 | 68 | raise 'rollback' 69 | end 70 | rescue 71 | # expected rollback exception 72 | end 73 | end 74 | 75 | def event_provider_count 76 | @adapter.provider_connection.select_value('select count(*) from event_providers').to_i 77 | end 78 | 79 | def event_count 80 | @adapter.event_connection.select_value('select count(*) from events').to_i 81 | end 82 | 83 | it "should rollback saved event provider" do 84 | event_provider_count.should == 0 85 | end 86 | 87 | it "should rollback saved events" do 88 | event_count.should == 0 89 | end 90 | end 91 | end 92 | end 93 | end -------------------------------------------------------------------------------- /spec/event_store/domain_repository_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), '../spec_helper') 2 | 3 | module EventStore 4 | describe DomainRepository do 5 | before(:each) do 6 | @storage = Adapters::InMemoryAdapter.new 7 | @repository = DomainRepository.new(@storage) 8 | @aggregate = Domain::Company.create('ACME Corp.') 9 | end 10 | 11 | context "when saving an aggregate" do 12 | before(:each) do 13 | @domain_event_raised = false 14 | @repository.on(:domain_event) {|source, event| @domain_event_raised = true } 15 | @repository.save(@aggregate) 16 | end 17 | 18 | it "should persist the aggregate's applied events" do 19 | @storage.storage.has_key?(@aggregate.guid) 20 | end 21 | 22 | it "should raise domain event" do 23 | @domain_event_raised.should == true 24 | end 25 | end 26 | 27 | context "when saving an aggregate within a transaction" do 28 | before(:each) do 29 | @repository.transaction do 30 | @repository.within_transaction?.should == true 31 | @repository.save(@aggregate) 32 | end 33 | end 34 | 35 | it "should persist the aggregate's applied events" do 36 | @storage.storage.has_key?(@aggregate.guid) 37 | end 38 | 39 | it "should not be within a transaction afterwards" do 40 | @repository.within_transaction?.should == false 41 | end 42 | end 43 | 44 | context "when finding an aggregate that does not exist" do 45 | it "should raise an EventStore::AggregateNotFound exception" do 46 | proc { @repository.find('missing') }.should raise_error(EventStore::AggregateNotFound) 47 | end 48 | end 49 | 50 | context "when loading an aggregate" do 51 | before(:each) do 52 | @storage.save(@aggregate) 53 | @retrieved = @repository.find(@aggregate.guid) 54 | end 55 | 56 | subject { @retrieved } 57 | it { should be_instance_of(Domain::Company) } 58 | specify { @retrieved.version.should == 1 } 59 | specify { @retrieved.pending_events.length.should == 0 } 60 | end 61 | 62 | context "when reloading same aggregate" do 63 | before(:each) do 64 | @storage.save(@aggregate) 65 | @mock_storage = mock 66 | @repository = DomainRepository.new(@mock_storage) 67 | @mock_storage.should_receive(:find).with(@aggregate.guid).once.and_return { @storage.find(@aggregate.guid) } 68 | end 69 | 70 | it "should only retrieve aggregate once" do 71 | @repository.find(@aggregate.guid) 72 | @repository.find(@aggregate.guid) 73 | end 74 | end 75 | end 76 | end -------------------------------------------------------------------------------- /spec/events/company_created_event.rb: -------------------------------------------------------------------------------- 1 | module Events 2 | class CompanyCreatedEvent < DomainEvent 3 | attr_reader :guid, :name 4 | 5 | initializer :guid, :name 6 | end 7 | end -------------------------------------------------------------------------------- /spec/events/domain_event_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), '../spec_helper') 2 | 3 | module Events 4 | describe CompanyCreatedEvent do 5 | context "serialization" do 6 | before(:each) do 7 | @event = CompanyCreatedEvent.new(Rcqrs::Guid.create, 'ACME Corp') 8 | end 9 | 10 | it "should serialize to json" do 11 | json = @event.to_json 12 | json.length.should be > 0 13 | end 14 | 15 | it "should deserialize from json" do 16 | deserialized = CompanyCreatedEvent.from_json(@event.to_json) 17 | 18 | deserialized.name.should == @event.name 19 | deserialized.guid.should == @event.guid 20 | end 21 | end 22 | end 23 | end -------------------------------------------------------------------------------- /spec/events/handlers/company_created_handler.rb: -------------------------------------------------------------------------------- 1 | module Events 2 | module Handlers 3 | class CompanyCreatedHandler < BaseHandler 4 | def execute(event) 5 | ::Reporting::Company.new(event.guid, event.name) 6 | 7 | # ... persist to reporting data store 8 | end 9 | end 10 | end 11 | end -------------------------------------------------------------------------------- /spec/events/handlers/event_handler_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), '../../spec_helper') 2 | 3 | module Events 4 | module Handlers 5 | describe CompanyCreatedHandler do 6 | before(:each) do 7 | @handler = CompanyCreatedHandler.new 8 | end 9 | 10 | context "when executing" do 11 | before(:each) do 12 | @event = Events::CompanyCreatedEvent.new(:guid => Rcqrs::Guid.create, :name => 'ACME corp') 13 | @company = @handler.execute(@event) 14 | end 15 | 16 | specify { @company.should be_instance_of(Reporting::Company) } 17 | specify { @company.guid.should == @event.guid } 18 | specify { @company.name.should == @event.name } 19 | end 20 | end 21 | end 22 | end -------------------------------------------------------------------------------- /spec/events/invoice_created_event.rb: -------------------------------------------------------------------------------- 1 | module Events 2 | class InvoiceCreatedEvent < DomainEvent 3 | attr_reader :date, :number, :description, :gross, :vat 4 | initializer :date, :number, :description, :gross, :vat 5 | end 6 | end -------------------------------------------------------------------------------- /spec/initializer_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), 'spec_helper') 2 | 3 | module Rcqrs 4 | class Car 5 | extend Initializer 6 | 7 | attr_reader :manufacturer, :model, :bhp, :ps 8 | 9 | initializer :manufacturer, :model, :bhp do 10 | @ps = bhp.to_f / 2 11 | end 12 | end 13 | 14 | class Bike 15 | extend Initializer 16 | initializer :manufacturer, :attr_reader => true 17 | end 18 | 19 | describe Car do 20 | context "when creating with positional arguments" do 21 | before(:each) do 22 | @car = Car.new('Ford', 'Focus', 115) 23 | end 24 | 25 | it "should set the manufacturer" do 26 | @car.manufacturer.should == 'Ford' 27 | end 28 | 29 | it "should set the manufacturer" do 30 | @car.model.should == 'Focus' 31 | end 32 | 33 | it "should set the bhp and ps from power" do 34 | @car.bhp.should == 115 35 | @car.ps.should == 57.5 36 | end 37 | end 38 | 39 | context "when creating with named arguments" do 40 | before(:each) do 41 | @car = Car.new(:model => 'Focus', :manufacturer => 'Ford') 42 | end 43 | 44 | it "should set the manufacturer" do 45 | @car.manufacturer.should == 'Ford' 46 | end 47 | 48 | it "should set the manufacturer" do 49 | @car.model.should == 'Focus' 50 | end 51 | end 52 | 53 | context "when creating with arguments hash" do 54 | before(:each) do 55 | arguments = { :manufacturer => 'Ford', :model => 'Focus' } 56 | @car = Car.new(arguments) 57 | end 58 | 59 | it "should set the manufacturer" do 60 | @car.manufacturer.should == 'Ford' 61 | end 62 | 63 | it "should set the manufacturer" do 64 | @car.model.should == 'Focus' 65 | end 66 | end 67 | 68 | context "when creating with invalid parameter" do 69 | it "should raise an exception" do 70 | proc { Car.new(:invalid => 'x') }.should raise_error(ArgumentError) 71 | end 72 | end 73 | 74 | context "when getting the attributes" do 75 | before(:each) do 76 | @car = Car.new(:manufacturer => 'Ford', :model => 'Focus', :bhp => 115) 77 | end 78 | 79 | it "should return hash of all attributes" do 80 | @car.attributes.should == { :manufacturer => 'Ford', :model => 'Focus', :bhp => 115 } 81 | end 82 | 83 | it "should allow creation of new object from existing object's attributes" do 84 | cloned = Car.new(@car.attributes) 85 | cloned.manufacturer.should == 'Ford' 86 | end 87 | end 88 | 89 | context "when defining attrs automatically" do 90 | before (:each) do 91 | @bike = Bike.new('Planet X') 92 | end 93 | 94 | it "should have attr_reader defined" do 95 | @bike.manufacturer.should == 'Planet X' 96 | end 97 | end 98 | 99 | context "when initializing from another initialized object with attributes" do 100 | before (:each) do 101 | @bike = Bike.new('Planet X') 102 | @car = Car.new(@bike) 103 | end 104 | 105 | it "should set manufacturer" do 106 | @car.manufacturer.should == @bike.manufacturer 107 | end 108 | end 109 | end 110 | end -------------------------------------------------------------------------------- /spec/mock_router.rb: -------------------------------------------------------------------------------- 1 | class MockRouter 2 | attr_reader :handled 3 | 4 | def handler_for(command, repository) 5 | self 6 | end 7 | 8 | def handlers_for(event) 9 | [ self ] 10 | end 11 | 12 | def execute(event_or_command) 13 | @handled = true 14 | end 15 | end -------------------------------------------------------------------------------- /spec/reporting/company.rb: -------------------------------------------------------------------------------- 1 | module Reporting 2 | class Company 3 | extend Rcqrs::Initializer 4 | 5 | attr_reader :guid, :name 6 | 7 | initializer :guid, :name 8 | end 9 | end -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'bundler' 3 | 4 | Bundler.setup(:default, :spec) 5 | 6 | require File.join(File.dirname(__FILE__), '/../lib/rcqrs') 7 | 8 | require 'mock_router' 9 | require 'commands/create_company_command' 10 | require 'commands/handlers/create_company_handler' 11 | require 'events/company_created_event' 12 | require 'events/invoice_created_event' 13 | require 'events/handlers/company_created_handler' 14 | require 'domain/invoice' 15 | require 'domain/company' 16 | require 'reporting/company' --------------------------------------------------------------------------------