ruby_cqrs 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
+
[![Build Status](https://travis-ci.org/iravench/ruby_cqrs.svg?branch=master)](https://travis-ci.org/iravench/ruby_cqrs) [![Code Climate](https://codeclimate.com/github/iravench/ruby_cqrs/badges/gpa.svg)](https://codeclimate.com/github/iravench/ruby_cqrs) [![Test Coverage](https://codeclimate.com/github/iravench/ruby_cqrs/badges/coverage.svg)](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
|