ruby_cqrs 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/License.txt +21 -0
- data/README.md +33 -0
- data/lib/ruby_cqrs/data/event_store.rb +13 -0
- data/lib/ruby_cqrs/data/in_memory_event_store.rb +70 -0
- data/lib/ruby_cqrs/data/serialization.rb +30 -0
- data/lib/ruby_cqrs/domain/aggregate.rb +107 -0
- data/lib/ruby_cqrs/domain/aggregate_repository.rb +122 -0
- data/lib/ruby_cqrs/domain/event.rb +10 -0
- data/lib/ruby_cqrs/domain/snapshot.rb +9 -0
- data/lib/ruby_cqrs/domain/snapshotable.rb +48 -0
- data/lib/ruby_cqrs/error.rb +3 -0
- data/lib/ruby_cqrs/guid.rb +13 -0
- data/lib/ruby_cqrs/version.rb +3 -0
- data/lib/ruby_cqrs.rb +12 -0
- data/spec/feature/basic_usage_spec.rb +177 -0
- data/spec/feature/snapshot_spec.rb +216 -0
- data/spec/fixture/typical_domain.rb +182 -0
- data/spec/spec_helper.rb +11 -0
- data/spec/support/matchers.rb +8 -0
- data/spec/unit/aggregate_repository_spec.rb +332 -0
- data/spec/unit/aggregate_spec.rb +123 -0
- data/spec/unit/in_memory_event_store_spec.rb +404 -0
- data/spec/unit/serialization_spec.rb +52 -0
- metadata +120 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: a0bff53f5c549da4478fd01b6c2cbfcf37f2c113
|
4
|
+
data.tar.gz: 6b699a6579e93f27d6af1f0305a90f941bddf1d9
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 54d999384f32a945152bf47b482562212a660bb824f0dac7c0495b5d7461adac974919931ec37ad73a8d18a01e619f9097318e23a5898336ef598c119aebd8b6
|
7
|
+
data.tar.gz: 885545a8f2118cee2aa1957967b011225a6360607bd5669307c260d06baf05c7eccbc640fb288521ec39f39df7cba7fc0b01fd993f2bca3304caa3029ee4fb4a
|
data/License.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2015 Raven Chen
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
[](https://travis-ci.org/iravench/ruby_cqrs) [](https://codeclimate.com/github/iravench/ruby_cqrs) [](https://codeclimate.com/github/iravench/ruby_cqrs)
|
2
|
+
|
3
|
+
# RubyCqrs
|
4
|
+
|
5
|
+
A Ruby implementation of CQRS, using event sourcing.
|
6
|
+
|
7
|
+
## Installation
|
8
|
+
|
9
|
+
Add this line to your application's Gemfile:
|
10
|
+
|
11
|
+
```ruby
|
12
|
+
gem 'ruby_cqrs'
|
13
|
+
```
|
14
|
+
|
15
|
+
And then execute:
|
16
|
+
|
17
|
+
$ bundle
|
18
|
+
|
19
|
+
Or install it yourself as:
|
20
|
+
|
21
|
+
$ gem install ruby_cqrs
|
22
|
+
|
23
|
+
## Usage
|
24
|
+
refer to bellow spec for how to use this gem in your code
|
25
|
+
https://github.com/iravench/ruby_cqrs/blob/master/spec/feature/basic_usage_spec.rb
|
26
|
+
|
27
|
+
## Contributing
|
28
|
+
|
29
|
+
1. Fork it ( https://github.com/iravench/ruby_cqrs/fork )
|
30
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
31
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
32
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
33
|
+
5. Create a new Pull Request
|
@@ -0,0 +1,70 @@
|
|
1
|
+
module RubyCqrs
|
2
|
+
module Data
|
3
|
+
class InMemoryEventStore
|
4
|
+
include EventStore
|
5
|
+
|
6
|
+
def initialize
|
7
|
+
@aggregate_store = {}
|
8
|
+
@event_store = {}
|
9
|
+
@snapshot_store = {}
|
10
|
+
end
|
11
|
+
|
12
|
+
def load_by guid, command_context
|
13
|
+
key = guid.to_sym
|
14
|
+
state = { :aggregate_id => guid,
|
15
|
+
:aggregate_type => @aggregate_store[key][:type] }
|
16
|
+
|
17
|
+
if @snapshot_store.has_key? key
|
18
|
+
extract_snapshot_into key, state
|
19
|
+
else
|
20
|
+
state[:events] = @event_store[key][:events]
|
21
|
+
end
|
22
|
+
|
23
|
+
state
|
24
|
+
end
|
25
|
+
|
26
|
+
def save changes, command_context
|
27
|
+
changes.each do |change|
|
28
|
+
key = change[:aggregate_id].to_sym
|
29
|
+
verify_state key, change
|
30
|
+
end
|
31
|
+
changes.each do |change|
|
32
|
+
key = change[:aggregate_id].to_sym
|
33
|
+
create_state key, change
|
34
|
+
update_state key, change
|
35
|
+
end
|
36
|
+
nil
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
def create_state key, change
|
41
|
+
unless @aggregate_store.has_key? key
|
42
|
+
@aggregate_store[key] = { :type => change[:aggregate_type], :version => 0 }
|
43
|
+
@event_store[key] = { :events => [] }
|
44
|
+
end
|
45
|
+
unless @snapshot_store.has_key? key or change[:snapshot].nil?
|
46
|
+
@snapshot_store[key] = {}
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def update_state key, change
|
51
|
+
@aggregate_store[key][:version] = change[:expecting_version]
|
52
|
+
change[:events].each { |event| @event_store[key][:events] << event }
|
53
|
+
@snapshot_store[key] = change[:snapshot] unless change[:snapshot].nil?
|
54
|
+
end
|
55
|
+
|
56
|
+
def extract_snapshot_into key, state
|
57
|
+
snapshot_version = @snapshot_store[key][:version]
|
58
|
+
state[:events] = @event_store[key][:events]\
|
59
|
+
.select { |event_record| event_record[:version] > snapshot_version }
|
60
|
+
state[:snapshot] = @snapshot_store[key]
|
61
|
+
end
|
62
|
+
|
63
|
+
def verify_state key, change
|
64
|
+
raise AggregateConcurrencyError.new("on aggregate #{key}")\
|
65
|
+
if @aggregate_store.has_key? key\
|
66
|
+
and @aggregate_store[key][:version] != change[:expecting_source_version]
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
require 'active_support/inflector'
|
2
|
+
require 'beefcake'
|
3
|
+
|
4
|
+
module RubyCqrs
|
5
|
+
class ObjectNotEncodableError < Error; end
|
6
|
+
class ObjectNotDecodableError < Error; end
|
7
|
+
|
8
|
+
module Data
|
9
|
+
module Encodable
|
10
|
+
|
11
|
+
def try_encode
|
12
|
+
return self.encode.to_s if self.is_a? Beefcake::Message
|
13
|
+
raise ObjectNotEncodableError
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
module RubyCqrs
|
20
|
+
module Data
|
21
|
+
module Decodable
|
22
|
+
|
23
|
+
def try_decode type_str, data
|
24
|
+
obj_type = type_str.constantize
|
25
|
+
raise ObjectNotDecodableError unless obj_type.include? Beefcake::Message
|
26
|
+
obj_type.decode data
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,107 @@
|
|
1
|
+
require 'active_support/inflector'
|
2
|
+
|
3
|
+
module RubyCqrs
|
4
|
+
module Domain
|
5
|
+
module Aggregate
|
6
|
+
attr_reader :aggregate_id, :version
|
7
|
+
|
8
|
+
def initialize
|
9
|
+
@aggregate_id = Guid.create
|
10
|
+
@version = 0
|
11
|
+
@source_version = 0
|
12
|
+
@event_handler_cache = {}
|
13
|
+
@pending_events = []
|
14
|
+
super
|
15
|
+
end
|
16
|
+
|
17
|
+
def is_version_conflicted? client_side_version
|
18
|
+
client_side_version != @source_version
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
def load_from state
|
24
|
+
sorted_events = state[:events].sort { |x, y| x.version <=> y.version }
|
25
|
+
@aggregate_id = state[:aggregate_id]
|
26
|
+
try_load_snapshot_from state
|
27
|
+
sorted_events.each do |event|
|
28
|
+
apply(event)
|
29
|
+
@source_version += 1
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def try_load_snapshot_from state
|
34
|
+
if state.has_key? :snapshot and self.is_a? Snapshotable
|
35
|
+
self.send :apply_snapshot, state[:snapshot][:state]
|
36
|
+
@version = state[:snapshot][:version]
|
37
|
+
@source_version = state[:snapshot][:version]
|
38
|
+
self.send(:reset_countdown, state[:events].size)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def get_changes
|
43
|
+
return nil unless @pending_events.size > 0
|
44
|
+
changes = {
|
45
|
+
:events => @pending_events,
|
46
|
+
:aggregate_id => @aggregate_id,
|
47
|
+
:aggregate_type => self.class.name,
|
48
|
+
:expecting_source_version => @source_version,
|
49
|
+
:expecting_version => @pending_events.max\
|
50
|
+
{ |a, b| a.version <=> b.version }.version
|
51
|
+
}
|
52
|
+
try_extract_snapshot_into changes
|
53
|
+
changes
|
54
|
+
end
|
55
|
+
|
56
|
+
def try_extract_snapshot_into changes
|
57
|
+
snapshot_state = self.send :take_a_snapshot\
|
58
|
+
if self.is_a? Snapshotable and self.send(:should_take_a_snapshot?)
|
59
|
+
unless snapshot_state.nil?
|
60
|
+
raise NotADomainSnapshotError unless snapshot_state.is_a? Snapshot
|
61
|
+
changes[:snapshot] = { :state => snapshot_state,
|
62
|
+
:state_type => snapshot_state.class.name,
|
63
|
+
:version => @version }
|
64
|
+
self.send :set_snapshot_taken
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def commit
|
69
|
+
@pending_events = []
|
70
|
+
@source_version = @version
|
71
|
+
if self.is_a? Snapshotable and self.send :should_reset_snapshot_countdown?
|
72
|
+
self.send(:reset_countdown, 0)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
def raise_event(event)
|
77
|
+
raise NotADomainEventError unless event.is_a? Event
|
78
|
+
apply(event)
|
79
|
+
update_dispatch_detail_for(event)
|
80
|
+
@pending_events << event
|
81
|
+
end
|
82
|
+
|
83
|
+
def update_dispatch_detail_for(event)
|
84
|
+
event.instance_variable_set(:@aggregate_id, @aggregate_id)
|
85
|
+
event.instance_variable_set(:@version, @version)
|
86
|
+
end
|
87
|
+
|
88
|
+
def apply(event)
|
89
|
+
dispatch_handler_for(event)
|
90
|
+
self.send(:snapshot_countdown) if self.is_a? Snapshotable
|
91
|
+
@version += 1
|
92
|
+
end
|
93
|
+
|
94
|
+
def dispatch_handler_for(event)
|
95
|
+
target = retrieve_handler_for(event.class)
|
96
|
+
self.send(target, event)
|
97
|
+
end
|
98
|
+
|
99
|
+
def retrieve_handler_for(event_type)
|
100
|
+
@event_handler_cache[event_type] ||= begin
|
101
|
+
stripped_event_type_name = event_type.to_s.demodulize.underscore
|
102
|
+
"on_#{stripped_event_type_name}".to_sym
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
@@ -0,0 +1,122 @@
|
|
1
|
+
require 'active_support/inflector'
|
2
|
+
require_relative '../guid'
|
3
|
+
|
4
|
+
module RubyCqrs
|
5
|
+
class AggregateNotFound < Error; end
|
6
|
+
class AggregateConcurrencyError < Error; end
|
7
|
+
class AggregateInstanceDuplicatedError < Error; end
|
8
|
+
|
9
|
+
module Domain
|
10
|
+
class AggregateRepository
|
11
|
+
include RubyCqrs::Data::Decodable
|
12
|
+
|
13
|
+
def find_by aggregate_id
|
14
|
+
raise ArgumentError if aggregate_id.nil?
|
15
|
+
raise ArgumentError unless Guid.validate? aggregate_id
|
16
|
+
|
17
|
+
state = @event_store.load_by(aggregate_id, @command_context)
|
18
|
+
raise AggregateNotFound if (state.nil? or state[:aggregate_type].nil? or\
|
19
|
+
((state[:events].nil? or state[:events].empty?) and state[:snapshot].nil?))
|
20
|
+
|
21
|
+
create_instance_from state
|
22
|
+
end
|
23
|
+
|
24
|
+
def save one_or_many_aggregate
|
25
|
+
raise ArgumentError if one_or_many_aggregate.nil?
|
26
|
+
return delegate_persistence_of [ one_or_many_aggregate ] if one_or_many_aggregate.is_a? Aggregate
|
27
|
+
|
28
|
+
raise ArgumentError unless one_or_many_aggregate.is_a? Enumerable and one_or_many_aggregate.size > 0
|
29
|
+
delegate_persistence_of one_or_many_aggregate
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
def initialize event_store, command_context
|
34
|
+
raise ArgumentError unless event_store.is_a? Data::EventStore
|
35
|
+
@event_store = event_store
|
36
|
+
@command_context = command_context
|
37
|
+
end
|
38
|
+
|
39
|
+
def create_instance_from state
|
40
|
+
try_decode_from state
|
41
|
+
instance = state[:aggregate_type].constantize.new
|
42
|
+
instance.send(:load_from, state)
|
43
|
+
instance
|
44
|
+
end
|
45
|
+
|
46
|
+
def delegate_persistence_of aggregates
|
47
|
+
verify_uniqueness_of aggregates
|
48
|
+
|
49
|
+
changes = prep_changes_for(aggregates)
|
50
|
+
if changes.size > 0
|
51
|
+
@event_store.save changes, @command_context
|
52
|
+
aggregates.each do |aggregate|
|
53
|
+
aggregate.send(:commit)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
nil
|
58
|
+
end
|
59
|
+
|
60
|
+
def verify_uniqueness_of aggregates
|
61
|
+
uniq_array = aggregates.uniq { |aggregate| aggregate.aggregate_id }
|
62
|
+
raise AggregateInstanceDuplicatedError unless uniq_array.size == aggregates.size
|
63
|
+
end
|
64
|
+
|
65
|
+
def prep_changes_for aggregates
|
66
|
+
to_return = []
|
67
|
+
aggregates.inject(to_return) do |product, aggregate|
|
68
|
+
raise ArgumentError unless aggregate.is_a? Aggregate
|
69
|
+
aggregate_change = aggregate.send(:get_changes)
|
70
|
+
next if aggregate_change.nil?
|
71
|
+
try_encode_from aggregate_change
|
72
|
+
product << aggregate_change
|
73
|
+
end
|
74
|
+
to_return
|
75
|
+
end
|
76
|
+
|
77
|
+
def try_decode_from state
|
78
|
+
state[:snapshot] = decode_snapshot_state_from state[:snapshot]\
|
79
|
+
if state.has_key? :snapshot
|
80
|
+
|
81
|
+
state[:events].map! { |event_record| decode_event_from event_record }\
|
82
|
+
if state[:events].size > 0
|
83
|
+
end
|
84
|
+
|
85
|
+
def decode_snapshot_state_from snapshot_record
|
86
|
+
snapshot_state = try_decode snapshot_record[:state_type], snapshot_record[:data]
|
87
|
+
{ :state => snapshot_state, :version => snapshot_record[:version] }
|
88
|
+
end
|
89
|
+
|
90
|
+
def decode_event_from event_record
|
91
|
+
decoded_event = try_decode event_record[:event_type], event_record[:data]
|
92
|
+
decoded_event.instance_variable_set(:@aggregate_id, event_record[:aggregate_id])
|
93
|
+
decoded_event.instance_variable_set(:@version, event_record[:version])
|
94
|
+
decoded_event
|
95
|
+
end
|
96
|
+
|
97
|
+
def try_encode_from change
|
98
|
+
if change.has_key? :snapshot
|
99
|
+
encoded_snapshot = encode_data_from change[:snapshot][:state]
|
100
|
+
change[:snapshot] = { :state_type => change[:snapshot][:state_type],
|
101
|
+
:version => change[:snapshot][:version],
|
102
|
+
:data => encoded_snapshot }
|
103
|
+
end
|
104
|
+
|
105
|
+
if change[:events].size > 0
|
106
|
+
change[:events].map! { |event|
|
107
|
+
{ :data => encode_data_from(event),
|
108
|
+
:aggregate_id => event.aggregate_id,
|
109
|
+
:event_type => event.class.name,
|
110
|
+
:version => event.version }
|
111
|
+
}
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
def encode_data_from obj
|
116
|
+
data = obj
|
117
|
+
data = data.try_encode if data.is_a? RubyCqrs::Data::Encodable
|
118
|
+
data
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
module RubyCqrs
|
2
|
+
module Domain
|
3
|
+
module Snapshotable
|
4
|
+
def initialize
|
5
|
+
if self.class.const_defined? :SNAPSHOT_THRESHOLD
|
6
|
+
@snapshot_threshold = self.class.const_get(:SNAPSHOT_THRESHOLD)
|
7
|
+
else
|
8
|
+
@snapshot_threshold = 30
|
9
|
+
end
|
10
|
+
@snapshot_threshold = 30 if @snapshot_threshold <= 0
|
11
|
+
@countdown = @snapshot_threshold
|
12
|
+
@reset_snapshot_countdown_flag = false
|
13
|
+
super
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
def should_take_a_snapshot?
|
18
|
+
@countdown <= 0
|
19
|
+
end
|
20
|
+
|
21
|
+
def snapshot_countdown
|
22
|
+
@countdown -= 1
|
23
|
+
end
|
24
|
+
|
25
|
+
def reset_countdown loaded_event_count
|
26
|
+
@countdown = @snapshot_threshold - loaded_event_count
|
27
|
+
@reset_snapshot_countdown_flag = false
|
28
|
+
end
|
29
|
+
|
30
|
+
def should_reset_snapshot_countdown?
|
31
|
+
@reset_snapshot_countdown_flag
|
32
|
+
end
|
33
|
+
|
34
|
+
def set_snapshot_taken
|
35
|
+
@reset_snapshot_countdown_flag = true
|
36
|
+
end
|
37
|
+
|
38
|
+
# the including domain object should implement these two methods
|
39
|
+
def take_a_snapshot
|
40
|
+
raise NotImplementedError
|
41
|
+
end
|
42
|
+
|
43
|
+
def apply_snapshot snapshot_object
|
44
|
+
raise NotImplementedError
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
data/lib/ruby_cqrs.rb
ADDED
@@ -0,0 +1,12 @@
|
|
1
|
+
require('ruby_cqrs/error')
|
2
|
+
require('ruby_cqrs/guid')
|
3
|
+
|
4
|
+
require('ruby_cqrs/data/event_store')
|
5
|
+
require('ruby_cqrs/data/serialization')
|
6
|
+
require('ruby_cqrs/data/in_memory_event_store')
|
7
|
+
|
8
|
+
require('ruby_cqrs/domain/event')
|
9
|
+
require('ruby_cqrs/domain/snapshot')
|
10
|
+
require('ruby_cqrs/domain/aggregate')
|
11
|
+
require('ruby_cqrs/domain/snapshotable')
|
12
|
+
require('ruby_cqrs/domain/aggregate_repository')
|
@@ -0,0 +1,177 @@
|
|
1
|
+
require_relative('../spec_helper.rb')
|
2
|
+
|
3
|
+
# define your domain object
|
4
|
+
class Customer
|
5
|
+
# mark as a domain object
|
6
|
+
include RubyCqrs::Domain::Aggregate
|
7
|
+
# makr as snapshotable
|
8
|
+
# the default setting is when more 30 events are raised
|
9
|
+
# after the last snapshot is taken, a new snapshot is generated
|
10
|
+
# change the default by defining SNAPSHOT_THRESHOLD = 20(or other number)
|
11
|
+
include RubyCqrs::Domain::Snapshotable
|
12
|
+
|
13
|
+
attr_reader :name, :credit
|
14
|
+
|
15
|
+
# unfortunately, you should not try to define your own initialize method
|
16
|
+
# at the time being, it could potentially cause error when the aggregate
|
17
|
+
# repository try to load your domain object back.
|
18
|
+
|
19
|
+
# define your domain object's behaviors
|
20
|
+
def create_profile name, credit
|
21
|
+
# again, this is like your normal initialize method,
|
22
|
+
# it should get called only once...
|
23
|
+
raise RuntimeError.new('the profile has already been created') unless @name.nil?
|
24
|
+
raise AgumentError if name.nil?
|
25
|
+
raise AgumentError if credit < 100
|
26
|
+
# when business rules are met, raise corresponding event
|
27
|
+
raise_event CustomerCreated.new(:name => name, :credit => credit)
|
28
|
+
end
|
29
|
+
|
30
|
+
def order_product price
|
31
|
+
raise AgumentErrorr if price <= 0
|
32
|
+
raise AgumentError.new("#{@name}'s credit #{@credit} is not enough to pay for a product costs #{price}")\
|
33
|
+
if price > @credit
|
34
|
+
# when business rules are met, raise corresponding event
|
35
|
+
raise_event ProductOrdered.new(:cost => price)
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
# when an event is raised or replayed,
|
40
|
+
# these methods will get called automatically,
|
41
|
+
# manage the domain object's internal state here
|
42
|
+
def on_customer_created customer_created
|
43
|
+
@name = customer_created.name
|
44
|
+
@credit = customer_created.credit
|
45
|
+
end
|
46
|
+
|
47
|
+
def on_product_ordered product_ordered
|
48
|
+
@credit -= product_ordered.cost
|
49
|
+
end
|
50
|
+
|
51
|
+
# when a domain object is marked as snapshotable,
|
52
|
+
# you must implement these two methods to record the object's vital state
|
53
|
+
# and apply the snapshot in order to restore your object's data respectively
|
54
|
+
def take_a_snapshot
|
55
|
+
CustomerSnapshot.new(:name => @name, :credit => @credit)
|
56
|
+
end
|
57
|
+
|
58
|
+
def apply_snapshot snapshot_object
|
59
|
+
@name = snapshot_object.name
|
60
|
+
@credit = snapshot_object.credit
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
# define a snapshot to keep all vital state of your domain object
|
65
|
+
# the repository will use the latest snapshot it can find
|
66
|
+
# and events happened after the snapshot has been taken
|
67
|
+
# to recreate your domain object
|
68
|
+
class CustomerSnapshot
|
69
|
+
include Beefcake::Message
|
70
|
+
include RubyCqrs::Domain::Snapshot
|
71
|
+
|
72
|
+
required :name, :string, 1
|
73
|
+
required :credit, :int32, 2
|
74
|
+
end
|
75
|
+
|
76
|
+
# defined the events your domain object will raise
|
77
|
+
class CustomerCreated
|
78
|
+
include RubyCqrs::Domain::Event
|
79
|
+
include Beefcake::Message
|
80
|
+
|
81
|
+
required :name, :string, 1
|
82
|
+
required :credit, :int32, 2
|
83
|
+
end
|
84
|
+
|
85
|
+
class ProductOrdered
|
86
|
+
include RubyCqrs::Domain::Event
|
87
|
+
include Beefcake::Message
|
88
|
+
|
89
|
+
required :cost, :int32, 1
|
90
|
+
end
|
91
|
+
|
92
|
+
# here goes the spec of how you can use the domain object
|
93
|
+
describe 'Your awesome customer domain objects powered by ruby_cqrs' do
|
94
|
+
# a context related to the command, which is specific to your problem domain
|
95
|
+
# normally, when a command arrives, one or more domain objects are created or loaded
|
96
|
+
# in order to fulfill the command's request
|
97
|
+
let(:command_context) {}
|
98
|
+
# you should implement your own event_store in order to persist your aggregate state
|
99
|
+
# you should read about the InMemoryEventStore implementation
|
100
|
+
# and make sure your response with correct data format
|
101
|
+
let(:event_store) { RubyCqrs::Data::InMemoryEventStore.new }
|
102
|
+
# every time when a command arrives,
|
103
|
+
# an aggregate repository gets created with related command context and event_store implementation
|
104
|
+
let(:repository) { RubyCqrs::Domain::AggregateRepository.new event_store, command_context }
|
105
|
+
|
106
|
+
let(:lucy) do
|
107
|
+
instance = Customer.new
|
108
|
+
instance.create_profile('Lucy', 1000)
|
109
|
+
instance
|
110
|
+
end
|
111
|
+
|
112
|
+
it 'creates a new customer instance' do
|
113
|
+
expect(lucy.name).to eq('Lucy')
|
114
|
+
expect(lucy.credit).to eq(1000)
|
115
|
+
|
116
|
+
expect(lucy.version).to eq(1)
|
117
|
+
end
|
118
|
+
|
119
|
+
it 'operates a new customer instance' do
|
120
|
+
lucy.order_product 100
|
121
|
+
|
122
|
+
expect(lucy.name).to eq('Lucy')
|
123
|
+
expect(lucy.credit).to eq(900)
|
124
|
+
|
125
|
+
expect(lucy.version).to eq(2)
|
126
|
+
end
|
127
|
+
|
128
|
+
it 'saves a new customer instance' do
|
129
|
+
lucy.order_product 100
|
130
|
+
repository.save lucy
|
131
|
+
|
132
|
+
expect(lucy.name).to eq('Lucy')
|
133
|
+
expect(lucy.credit).to eq(900)
|
134
|
+
expect(lucy.version).to eq(2)
|
135
|
+
end
|
136
|
+
|
137
|
+
it 'finds an old customer instance by id' do
|
138
|
+
lucy.order_product 100
|
139
|
+
repository.save lucy
|
140
|
+
lucy_reload = repository.find_by lucy.aggregate_id
|
141
|
+
|
142
|
+
expect(lucy_reload.name).to eq('Lucy')
|
143
|
+
expect(lucy_reload.credit).to eq(900)
|
144
|
+
expect(lucy_reload.version).to eq(2)
|
145
|
+
end
|
146
|
+
|
147
|
+
it 'operates on an reloaded customer instance' do
|
148
|
+
lucy.order_product 100
|
149
|
+
repository.save lucy
|
150
|
+
lucy_reload = repository.find_by lucy.aggregate_id
|
151
|
+
|
152
|
+
lucy_reload.order_product 200
|
153
|
+
|
154
|
+
expect(lucy_reload.name).to eq('Lucy')
|
155
|
+
expect(lucy_reload.credit).to eq(700)
|
156
|
+
expect(lucy_reload.version).to eq(3)
|
157
|
+
end
|
158
|
+
|
159
|
+
it 'generates a customer snapshot when enough events get fired' do
|
160
|
+
(1..30).each { lucy.order_product(10) }
|
161
|
+
repository.save lucy
|
162
|
+
|
163
|
+
# well, you just have to trust a snapshot has been generated :)
|
164
|
+
end
|
165
|
+
|
166
|
+
it 'finds an old customer instance back from snapshot' do
|
167
|
+
(1..30).each { lucy.order_product(10) }
|
168
|
+
repository.save lucy
|
169
|
+
lucy_reload = repository.find_by lucy.aggregate_id
|
170
|
+
|
171
|
+
# again, it's been loaded from a snapshot ;)
|
172
|
+
|
173
|
+
expect(lucy_reload.name).to eq('Lucy')
|
174
|
+
expect(lucy_reload.credit).to eq(700)
|
175
|
+
expect(lucy_reload.version).to eq(31)
|
176
|
+
end
|
177
|
+
end
|