event_sorcerer 0.1.0 → 0.1.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +137 -0
- data/event_sorcerer.gemspec +1 -1
- data/lib/event_sorcerer.rb +20 -5
- data/lib/event_sorcerer/aggregate.rb +4 -4
- data/lib/event_sorcerer/aggregate_proxy.rb +9 -4
- data/lib/event_sorcerer/event_applicator.rb +2 -4
- data/lib/event_sorcerer/version.rb +1 -1
- metadata +6 -6
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 204474547eba7a0e9289b8dda4f55548ba225fa6
|
4
|
+
data.tar.gz: 84fb8dfbd0220067c63bfe526899bb33ac9b4982
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: bd80f51216f11bd01f9a6d4eace6accfa0f703bbd0c9a8fff5e0265862a1e66f749e7592fd5d20b91ecb8ff980c30ba582f023d8118242d75256897bef9abb8a
|
7
|
+
data.tar.gz: d13b3e23dc632637ce415212b60873a038a443fc13a585a46072afeb944f5d803df62bf835d4ac35695bc99720d0731b900968797cfa784b88d971ab1f696f97
|
data/README.md
CHANGED
@@ -2,8 +2,145 @@
|
|
2
2
|
|
3
3
|
Generic event-sourcing scaffold.
|
4
4
|
|
5
|
+
Disclaimer: This is still a work-in-progress, is not feature complete and _is_ subject to change.
|
6
|
+
|
5
7
|
[![Code Climate](https://codeclimate.com/github/SebastianEdwards/event_sorcerer/badges/gpa.svg)](https://codeclimate.com/github/SebastianEdwards/event_sorcerer)
|
6
8
|
|
9
|
+
## What is event-sourcing?
|
10
|
+
|
11
|
+
Event-sourcing means using events as the primary source of truth for your domain models. Rather than storing the current state of your domain ala ActiveRecord or any other ORM, you append all mutating events to a log. To restore the current state of a domain object you initialize a new instance and replay the stored events against it.
|
12
|
+
|
13
|
+
## That sounds unconventional. Why would I want to do that?
|
14
|
+
|
15
|
+
Event-sourcing captures the intent of user's interacting with your system, gives you an audit log for free and allows for painless creation of new projections of your data in the future.
|
16
|
+
|
17
|
+
## New projections of your data?
|
18
|
+
|
19
|
+
Imagine replaying a domain model's events into objects that prepare it for being loaded into a relational store. Then, using those same events preparing it for a graph database or a full-text search engine. Use the right tool (read model) for the job.
|
20
|
+
|
21
|
+
## I still don't really understand...
|
22
|
+
|
23
|
+
Greg Young gave a talk on the subject which will probably explain ES concepts much better than I can. It's available here: [CQRS and Event Sourcing - Code on the Beach 2014](https://www.youtube.com/watch?v=JHGkaShoyNs).
|
24
|
+
|
25
|
+
## So what does this gem give me?
|
26
|
+
|
27
|
+
You can mixin `EventSorcerer::Aggregate` to your domain model and get a DSL for defining your events, plus an ActiveRecord-like interface for creating, finding and saving. It also gives you a unit-of-work, event-bus hooks and time-shifts your system during event replay.
|
28
|
+
|
29
|
+
## What's the catch?
|
30
|
+
|
31
|
+
This gem is like a coloring book. You get an outline but you have to color it in with your own storage engine and event bus.
|
32
|
+
|
33
|
+
## That sounds scary.
|
34
|
+
|
35
|
+
It isn't; you just need to subclass a couple of classes and implement a few methods. I'll add some examples at some point showing how to use it with a couple of different datastores.
|
36
|
+
|
37
|
+
## Example
|
38
|
+
|
39
|
+
Here we have a domain model representing a good ol' game of rugby. It allows the game to be started and stopped and points to be scored. Things to notice:
|
40
|
+
|
41
|
+
- Event definition is very simple. Just wrap the event methods in an `events` block. Note: as it stands in the current version your arguments must all be JSON serializable. Keyword arguments are supported.
|
42
|
+
- Validation is done with exceptions, a conceptually simple model, give invalid input and it blows up (for you to rescue and give a reasonable response to the user of course).
|
43
|
+
- Rather than keeping a current score in the database, we use events to track score-increasing events. Now, we not only know the score at any point in the game but we also know the context (the why) around the score (tries vs penalties, etc.)
|
44
|
+
- We could use fat events to allow interesting projections in the future. Fat events means storing more context than we currently need (storage is cheap!). For example we could track the scoring player for each try and create a projection which shows how many tries each player made during the entire game. We could even replay multiple games into one projection to find a player's total tally for a season.
|
45
|
+
|
46
|
+
```ruby
|
47
|
+
class RugbyGame
|
48
|
+
class Team < Struct.new(:name, :score)
|
49
|
+
def add_points(points)
|
50
|
+
@score += points
|
51
|
+
end
|
52
|
+
|
53
|
+
def to_s
|
54
|
+
"#{name}: #{score}"
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
class DuplicateTeamName < RuntimeError; end
|
59
|
+
class GameNotInProgress < RuntimeError; end
|
60
|
+
class TeamNotPlaying < RuntimeError; end
|
61
|
+
|
62
|
+
attr_reader :team_one
|
63
|
+
attr_reader :team_two
|
64
|
+
|
65
|
+
def game_in_progress?
|
66
|
+
@game_in_progress == true
|
67
|
+
end
|
68
|
+
|
69
|
+
def scores
|
70
|
+
"#{team_one} - #{team_two}"
|
71
|
+
end
|
72
|
+
|
73
|
+
def team_by_name(name)
|
74
|
+
return team_one if name == team_one.name
|
75
|
+
return team_two if name == team_two.name
|
76
|
+
|
77
|
+
fail TeamNotPlaying
|
78
|
+
end
|
79
|
+
|
80
|
+
events do
|
81
|
+
def game_started(first_team, second_team)
|
82
|
+
fail DuplicateTeamName if first_team_name == second_team_name
|
83
|
+
|
84
|
+
@team_one = Team.new(first_team_name, 0)
|
85
|
+
@team_two = Team.new(second_team_name, 0)
|
86
|
+
@game_in_progress = true
|
87
|
+
|
88
|
+
self
|
89
|
+
end
|
90
|
+
|
91
|
+
def game_ended
|
92
|
+
@game_in_progress = false
|
93
|
+
|
94
|
+
self
|
95
|
+
end
|
96
|
+
|
97
|
+
def try_scored(scoring_team)
|
98
|
+
fail GameNotInProgress unless game_in_progress?
|
99
|
+
|
100
|
+
team_by_name(scoring_team).add_points 5
|
101
|
+
|
102
|
+
self
|
103
|
+
end
|
104
|
+
|
105
|
+
def try_converted(scoring_team)
|
106
|
+
fail GameNotInProgress unless game_in_progress?
|
107
|
+
|
108
|
+
team_by_name(scoring_team).add_points 2
|
109
|
+
|
110
|
+
self
|
111
|
+
end
|
112
|
+
|
113
|
+
def drop_goal_scored(scoring_team)
|
114
|
+
...
|
115
|
+
end
|
116
|
+
|
117
|
+
def penalty_kick_scored(scoring_team)
|
118
|
+
...
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
```
|
123
|
+
|
124
|
+
Here's how you'd use the above class:
|
125
|
+
|
126
|
+
```ruby
|
127
|
+
game = RugbyGame.new
|
128
|
+
game.game_started('All Blacks', 'Wallabies')
|
129
|
+
game.try_scored('All Blacks')
|
130
|
+
game.try_converted('All Blacks')
|
131
|
+
game.drop_goal_scored('Wallabies')
|
132
|
+
|
133
|
+
...
|
134
|
+
|
135
|
+
game.game_ended
|
136
|
+
game.scores => "All Blacks: 29 - Wallabies: 28"
|
137
|
+
game.save
|
138
|
+
|
139
|
+
... later ...
|
140
|
+
game = RugbyGame.find(6)
|
141
|
+
game.scores => "All Blacks: 29 - Wallabies: 28"
|
142
|
+
```
|
143
|
+
|
7
144
|
## Installation
|
8
145
|
|
9
146
|
Add this line to your application's Gemfile:
|
data/event_sorcerer.gemspec
CHANGED
@@ -23,6 +23,6 @@ Gem::Specification.new do |spec|
|
|
23
23
|
spec.add_development_dependency 'rubocop'
|
24
24
|
spec.add_development_dependency 'rake'
|
25
25
|
|
26
|
-
spec.add_runtime_dependency 'invokr', '0.
|
26
|
+
spec.add_runtime_dependency 'invokr', '~> 0.9.6'
|
27
27
|
spec.add_runtime_dependency 'timecop', '0.7.1'
|
28
28
|
end
|
data/lib/event_sorcerer.rb
CHANGED
@@ -52,17 +52,25 @@ module EventSorcerer
|
|
52
52
|
@message_bus || fail(UnsetMessageBus)
|
53
53
|
end
|
54
54
|
|
55
|
-
# Public: Mutex to synchronize usage of non-threadsafe time-traveling gems.
|
56
|
-
def time_travel_lock
|
57
|
-
@time_travel_lock ||= Mutex.new
|
58
|
-
end
|
59
|
-
|
60
55
|
# Public: Returns the unit_of_work for the current thread. Defaults to
|
61
56
|
# NoUnitOfWork.
|
62
57
|
def unit_of_work
|
63
58
|
Thread.current[:unit_of_work] || NoUnitOfWork
|
64
59
|
end
|
65
60
|
|
61
|
+
# Public: Executes a block with time frozen to a given moment.
|
62
|
+
#
|
63
|
+
# time - Time to freeze time at.
|
64
|
+
#
|
65
|
+
# Returns value of block.
|
66
|
+
def with_time(time)
|
67
|
+
time_travel_lock.synchronize do
|
68
|
+
Timecop.freeze(time) do
|
69
|
+
yield
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
66
74
|
# Public: Creates a new UnitOfWork and sets it for the current thread
|
67
75
|
# within the block. Executes the work after block completion.
|
68
76
|
#
|
@@ -79,5 +87,12 @@ module EventSorcerer
|
|
79
87
|
|
80
88
|
result
|
81
89
|
end
|
90
|
+
|
91
|
+
private
|
92
|
+
|
93
|
+
# Private: Mutex to synchronize usage of non-threadsafe time-traveling gem.
|
94
|
+
def time_travel_lock
|
95
|
+
@time_travel_lock ||= Mutex.new
|
96
|
+
end
|
82
97
|
end
|
83
98
|
end
|
@@ -111,7 +111,7 @@ module EventSorcerer
|
|
111
111
|
# block - the block to yield the event stream to.
|
112
112
|
#
|
113
113
|
# Returns the return value of the given block.
|
114
|
-
def with_all_event_streams_for_type(
|
114
|
+
def with_all_event_streams_for_type()
|
115
115
|
yield event_store.read_event_streams_for_type(name)
|
116
116
|
end
|
117
117
|
|
@@ -122,7 +122,7 @@ module EventSorcerer
|
|
122
122
|
# block - the block to yield the event stream to.
|
123
123
|
#
|
124
124
|
# Returns the return value of the given block.
|
125
|
-
def with_all_loaders_for_type(prohibit_new = true
|
125
|
+
def with_all_loaders_for_type(prohibit_new = true)
|
126
126
|
with_all_event_streams_for_type do |streams|
|
127
127
|
loaders = streams.map do |stream|
|
128
128
|
AggregateLoader.new(self, stream.id, stream.events,
|
@@ -140,7 +140,7 @@ module EventSorcerer
|
|
140
140
|
# block - the block to yield the event stream to.
|
141
141
|
#
|
142
142
|
# Returns the return value of the given block.
|
143
|
-
def with_event_stream_for_id(id
|
143
|
+
def with_event_stream_for_id(id)
|
144
144
|
yield event_store.read_event_stream(id)
|
145
145
|
end
|
146
146
|
|
@@ -151,7 +151,7 @@ module EventSorcerer
|
|
151
151
|
# block - the block to yield the event stream to.
|
152
152
|
#
|
153
153
|
# Returns the return value of the given block.
|
154
|
-
def with_loader_for_id(id, prohibit_new = true
|
154
|
+
def with_loader_for_id(id, prohibit_new = true)
|
155
155
|
with_event_stream_for_id(id) do |stream|
|
156
156
|
yield AggregateLoader.new(self, stream.id, stream.events,
|
157
157
|
stream.current_version, prohibit_new)
|
@@ -60,14 +60,14 @@ module EventSorcerer
|
|
60
60
|
# Private: Handles the serialization of event arguments and pushes the
|
61
61
|
# Event object onto the _dirty_events array. Increments the local
|
62
62
|
# version number for the aggregate.
|
63
|
-
def add_dirty_event!(method_sym, *arguments)
|
63
|
+
def add_dirty_event!(time, method_sym, *arguments)
|
64
64
|
increment_version!
|
65
65
|
|
66
66
|
method = @_aggregate.method(method_sym)
|
67
67
|
method.parameters.each.with_index.select { |(type, _), _| type == :req }
|
68
68
|
|
69
69
|
details = ArgumentHashifier.hashify(method.parameters, arguments.dup)
|
70
|
-
@_dirty_events << Event.new(method_sym,
|
70
|
+
@_dirty_events << Event.new(method_sym, time, details)
|
71
71
|
|
72
72
|
self
|
73
73
|
end
|
@@ -103,9 +103,14 @@ module EventSorcerer
|
|
103
103
|
def send_event_to_aggregate(method_sym, *arguments, &block)
|
104
104
|
fail EventArgumentError if block
|
105
105
|
|
106
|
-
|
106
|
+
time = Time.now
|
107
|
+
add_dirty_event!(time, method_sym, *arguments)
|
107
108
|
|
108
|
-
@_aggregate.
|
109
|
+
expected_args = arguments.slice(0, @_aggregate.method(method_sym).arity)
|
110
|
+
|
111
|
+
EventSorcerer.with_time(time) do
|
112
|
+
@_aggregate.send method_sym, *expected_args
|
113
|
+
end
|
109
114
|
rescue StandardError => e
|
110
115
|
undo_dirty_event!
|
111
116
|
raise e
|
@@ -12,10 +12,8 @@ module EventSorcerer
|
|
12
12
|
#
|
13
13
|
# Returns self.
|
14
14
|
def self.apply_event!(aggregate, event)
|
15
|
-
EventSorcerer.
|
16
|
-
|
17
|
-
Invokr.invoke method: event.name, on: aggregate, with: event.details
|
18
|
-
end
|
15
|
+
EventSorcerer.with_time(event.created_at) do
|
16
|
+
Invokr.invoke method: event.name, on: aggregate, using: event.details
|
19
17
|
end
|
20
18
|
|
21
19
|
self
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: event_sorcerer
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1.
|
4
|
+
version: 0.1.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Sebastian Edwards
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2014-
|
11
|
+
date: 2014-12-03 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: bundler
|
@@ -84,16 +84,16 @@ dependencies:
|
|
84
84
|
name: invokr
|
85
85
|
requirement: !ruby/object:Gem::Requirement
|
86
86
|
requirements:
|
87
|
-
- -
|
87
|
+
- - "~>"
|
88
88
|
- !ruby/object:Gem::Version
|
89
|
-
version: 0.
|
89
|
+
version: 0.9.6
|
90
90
|
type: :runtime
|
91
91
|
prerelease: false
|
92
92
|
version_requirements: !ruby/object:Gem::Requirement
|
93
93
|
requirements:
|
94
|
-
- -
|
94
|
+
- - "~>"
|
95
95
|
- !ruby/object:Gem::Version
|
96
|
-
version: 0.
|
96
|
+
version: 0.9.6
|
97
97
|
- !ruby/object:Gem::Dependency
|
98
98
|
name: timecop
|
99
99
|
requirement: !ruby/object:Gem::Requirement
|