├── .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'
--------------------------------------------------------------------------------