aggregates 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.md +20 -0
- data/README.md +322 -0
- data/lib/aggregates.rb +33 -0
- data/lib/aggregates/aggregate_root.rb +72 -0
- data/lib/aggregates/auditor.rb +49 -0
- data/lib/aggregates/command.rb +25 -0
- data/lib/aggregates/command_dispatcher.rb +53 -0
- data/lib/aggregates/command_filter.rb +13 -0
- data/lib/aggregates/command_processor.rb +13 -0
- data/lib/aggregates/command_validation_error.rb +14 -0
- data/lib/aggregates/configuration.rb +37 -0
- data/lib/aggregates/domain_message.rb +23 -0
- data/lib/aggregates/dynamoid/dynamoid_storage_backend.rb +73 -0
- data/lib/aggregates/event.rb +11 -0
- data/lib/aggregates/event_processor.rb +15 -0
- data/lib/aggregates/event_stream.rb +25 -0
- data/lib/aggregates/identity.rb +11 -0
- data/lib/aggregates/in_memory_storage_backend.rb +33 -0
- data/lib/aggregates/message_processor.rb +45 -0
- data/lib/aggregates/storage_backend.rb +35 -0
- data/lib/aggregates/types.rb +9 -0
- data/lib/aggregates/with_aggregate_helpers.rb +22 -0
- metadata +113 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 00b1d88fe05dec5dcc22db0e1349c2dd91645b4e33755f8fbf9e3d3ea9085fab
|
4
|
+
data.tar.gz: ad8bd4c5cd4c1e781776ea4890f59e59d952a6cc30e4509c41e92da649b691ba
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: dbd3330da62e2783c4911116e266511ff5decc8f629eefbc7e671a8128fb7082f62aff6a1edac6018243ed6d1856a823dd84e3cef879edb741376f9a273ddaf8
|
7
|
+
data.tar.gz: c5ff7486bda8e1412cc6624db657c7deeb8f94f5d823cbdc799e9bf42c862bbc0735fa3ba589fc10c64a30275f8aa514f33ab33ff4f345807a2da244dc5438d5
|
data/LICENSE.md
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright 2021 [Resilient Vitality](https://www.resilientvitality.com).
|
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.
|
data/README.md
ADDED
@@ -0,0 +1,322 @@
|
|
1
|
+
<p align="center">
|
2
|
+
<img src="aggregates.png" alt="Aggregates Icon"/>
|
3
|
+
</p>
|
4
|
+
|
5
|
+
# Aggregates
|
6
|
+
|
7
|
+
A ruby gem for writing CQRS applications with pluggable components.
|
8
|
+
|
9
|
+
_Warning:_ This Gem is in active development and probably doesn't work correctly. Tests are really light.
|
10
|
+
|
11
|
+
[![Gem Version](https://badge.fury.io/rb/aggregates.svg)](http://badge.fury.io/rb/aggregates)
|
12
|
+
[![Ruby Style Guide](https://img.shields.io/badge/code_style-community-brightgreen.svg)](https://rubystyle.guide)
|
13
|
+
|
14
|
+
<!-- Tocer[start]: Auto-generated, don't remove. -->
|
15
|
+
|
16
|
+
## Table of Contents
|
17
|
+
|
18
|
+
- [Features](#features)
|
19
|
+
- [Requirements](#requirements)
|
20
|
+
- [Setup](#setup)
|
21
|
+
- [Usage](#usage)
|
22
|
+
- [Defining AggregateRoots](#defining-aggregateroots)
|
23
|
+
- [Creating Commands](#creating-commands)
|
24
|
+
- [Creating Events](#creating-events)
|
25
|
+
- [Processing Commands](#processing-commands)
|
26
|
+
- [Filtering Commands](#filtering-commands)
|
27
|
+
- [Processing Events](#processing-events)
|
28
|
+
- [Executing Commands](#executing-commands)
|
29
|
+
- [Auditing Aggregates](#auditing-aggregates)
|
30
|
+
- [Configuring](#configuring)
|
31
|
+
- [Storage Backends](#storage-backends)
|
32
|
+
- [Dynamoid](#dynamoid)
|
33
|
+
- [Adding Command Processors](#adding-command-processors)
|
34
|
+
- [Adding Event Processors](#adding-event-processors)
|
35
|
+
- [Adding Command Filters](#adding-command-filters)
|
36
|
+
- [Development](#development)
|
37
|
+
- [Tests](#tests)
|
38
|
+
- [Versioning](#versioning)
|
39
|
+
- [Code of Conduct](#code-of-conduct)
|
40
|
+
- [Contributions](#contributions)
|
41
|
+
- [License](#license)
|
42
|
+
- [History](#history)
|
43
|
+
- [Credits](#credits)
|
44
|
+
|
45
|
+
<!-- Tocer[finish]: Auto-generated, don't remove. -->
|
46
|
+
|
47
|
+
## Features
|
48
|
+
|
49
|
+
- Pluggable Event / Command Storage Backends
|
50
|
+
- Tools for Command Validation, Filtering, and Execution.
|
51
|
+
- Opinioned structure for CQRS, Domain-Driven Design, and Event Sourcing.
|
52
|
+
|
53
|
+
## Requirements
|
54
|
+
|
55
|
+
1. [Ruby 3.0+](https://www.ruby-lang.org)
|
56
|
+
|
57
|
+
## Setup
|
58
|
+
|
59
|
+
To install, run:
|
60
|
+
|
61
|
+
gem install aggregates
|
62
|
+
|
63
|
+
Or Add the following to your Gemfile:
|
64
|
+
|
65
|
+
gem "aggregates"
|
66
|
+
|
67
|
+
## Usage
|
68
|
+
|
69
|
+
### Defining AggregateRoots
|
70
|
+
|
71
|
+
An AggregateRoot is a grouping of domain object(s) that work to encapsulate
|
72
|
+
a single part of your Domain or Business Logic. The general design of aggregate roots should be as follows:
|
73
|
+
|
74
|
+
- Create functions that encapsulate different operations on your Aggregate Roots. These functions should enforce buisiness logic constraints and then capture state changes by creating events.
|
75
|
+
- Create event handlers that actually perform the state changes captured by those events.
|
76
|
+
|
77
|
+
A simple example is below:
|
78
|
+
|
79
|
+
```ruby
|
80
|
+
class Post < Aggregates::AggregateRoot
|
81
|
+
# Write functions that encapsulate business logic.
|
82
|
+
def publish(command)
|
83
|
+
apply EventPublished, body: command.body, category: command.category
|
84
|
+
end
|
85
|
+
|
86
|
+
# Modify the state of the aggregate from the emitted events.
|
87
|
+
on EventPublished do |event|
|
88
|
+
@body = event.body
|
89
|
+
@category = event.category
|
90
|
+
end
|
91
|
+
end
|
92
|
+
```
|
93
|
+
|
94
|
+
_Note:_ the message-handling DSL (`on`) supports passing a super class of any given event
|
95
|
+
as well. Every `on` block that applies to the event will be called in order from most specific to least specific.
|
96
|
+
|
97
|
+
### Creating Commands
|
98
|
+
|
99
|
+
Commands are a type of domain message that define the shape and contract of data needed to perform an action. Essentially, they provide the api for interacting with your domain. Commands should have descriptive names capturing the change they are intended to make. For instance, `ChangeUserEmail` or `AddComment`.
|
100
|
+
|
101
|
+
```ruby
|
102
|
+
class PublishPost < Aggregates::Command
|
103
|
+
attribute :body, Types::String
|
104
|
+
attribute :category, Types::String
|
105
|
+
|
106
|
+
# Input Validation Handled via dry-validation.
|
107
|
+
# Reference: https://dry-rb.org/gems/dry-validation/1.6/
|
108
|
+
class Contract < Contract
|
109
|
+
rule(:body) do
|
110
|
+
key.failure('Post not long enough') unless value.length > 10
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
```
|
115
|
+
|
116
|
+
### Creating Events
|
117
|
+
|
118
|
+
An Event describes something that happened. They are named in passed tense.
|
119
|
+
For instance, if the user's email has changed, then you might create an event type called
|
120
|
+
`UserEmailChanged`.
|
121
|
+
|
122
|
+
```ruby
|
123
|
+
class PublishPost < Aggregates::Command
|
124
|
+
attribute :body, Types::String
|
125
|
+
attribute :category, Types::String
|
126
|
+
end
|
127
|
+
```
|
128
|
+
|
129
|
+
### Processing Commands
|
130
|
+
|
131
|
+
The goal of a `CommandProcessor` is to route commands that have passed validation and
|
132
|
+
filtering. They should invoke business logic on their respective aggregates. Doing so is accomplished by using the same message-handling DSL as in our `AggregateRoots`, this time for commands.
|
133
|
+
|
134
|
+
A helper function, `with_aggregate`, is provided to help retrieve the appropriate aggregate
|
135
|
+
for a given command.
|
136
|
+
|
137
|
+
```ruby
|
138
|
+
class PostCommandProcessor < Aggregates::CommandProcessor
|
139
|
+
on PublishPost do |command|
|
140
|
+
with_aggregate(Post, command) do |post|
|
141
|
+
post.publsh(command)
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
145
|
+
```
|
146
|
+
|
147
|
+
_Note:_ the message-handling DSL (`on`) supports passing a super class of any given event
|
148
|
+
as well. Every `on` block that applies to the event will be called in order from most specific to least specific.
|
149
|
+
|
150
|
+
### Filtering Commands
|
151
|
+
|
152
|
+
There are times where commands should not be executed by the domain logic. You can opt to include a condition in your command processor or aggregate. However, that is not always extensible if you have repeated logic between many commands. Additionally, it violates the single responsiblity principal.
|
153
|
+
|
154
|
+
Instead, it is best to support this kind of filtering logic using `CommandFilters`. A `CommandFilter` uses the same Message Handling message-handling DSL as the rest of the `Aggregates` gem. This time, it needs to return a true/false back to the gem to determine whether or not (true/false) the command should be allowed. Many command filters can provide many blocks of the `on` DSL. If any one of the filters rejects the command then the command will not be procesed.
|
155
|
+
|
156
|
+
```ruby
|
157
|
+
class UpdatePostCommand < Aggregates::Command
|
158
|
+
attribute :commanding_user_id, Types::String
|
159
|
+
end
|
160
|
+
|
161
|
+
class UpdatePostBody < UpdatePostCommand
|
162
|
+
attribute :body, Types::String
|
163
|
+
end
|
164
|
+
|
165
|
+
class PostCommandFilter < Aggregates::CommandFilter
|
166
|
+
on UpdatePostCommand do |command|
|
167
|
+
with_aggregate(Post, command) do |post|
|
168
|
+
post.owner_id == command.commanding_user_id
|
169
|
+
end
|
170
|
+
end
|
171
|
+
end
|
172
|
+
```
|
173
|
+
|
174
|
+
In this example, we are using a super class of `UpdatePostBody`.
|
175
|
+
As with all MessageProcessors, calling `on` with a super class
|
176
|
+
will be called when any child class is being processed. In other words,
|
177
|
+
`on UpdatePostCommand` will be called when you call `Aggregates.execute_command`
|
178
|
+
with an instance of `UpdatePostBody`.
|
179
|
+
|
180
|
+
### Processing Events
|
181
|
+
|
182
|
+
Event processors are responsible for responding to events and effecting changes on things
|
183
|
+
that are not the aggregates themselves. Here is where the read side of your CQRS model can take
|
184
|
+
place. Since `Aggregates` does not enforce a storage solution for any component of the application, you will likely want to provide a helper mechanism for updating projections of aggregates into your read model.
|
185
|
+
|
186
|
+
Additionally, the `EventProcessor` type can be used to perform other side effects in other systems. Examples could include sending an email to welcome a user, publish the event to a webhook for a subscribing micro service, or much more.
|
187
|
+
|
188
|
+
```ruby
|
189
|
+
class RssUpdateProcessor < Aggregates::EventProcessor
|
190
|
+
def update_feed_for_new_post(event)
|
191
|
+
# ...
|
192
|
+
end
|
193
|
+
|
194
|
+
on EventPublished do |event|
|
195
|
+
update_feed_for_new_post(event)
|
196
|
+
end
|
197
|
+
end
|
198
|
+
```
|
199
|
+
|
200
|
+
_Note:_ the message-handling DSL (`on`) supports passing a super class of any given event
|
201
|
+
as well. Every `on` block that applies to the event will be called in order from most specific to least specific.
|
202
|
+
|
203
|
+
### Executing Commands
|
204
|
+
|
205
|
+
```ruby
|
206
|
+
aggregate_id = Aggregates.new_aggregate_id
|
207
|
+
command = CreateThing.new(foo: 1, bar: false, aggregate_id: aggregate_id)
|
208
|
+
Aggregates.execute_command command
|
209
|
+
|
210
|
+
increment = IncrementFooThing.new(aggregate_id: aggregate_id)
|
211
|
+
toggle = ToggleBarThing.new(aggregate_id: aggregate_id)
|
212
|
+
Aggregates.execute_commands increment, toggle
|
213
|
+
```
|
214
|
+
|
215
|
+
### Auditing Aggregates
|
216
|
+
|
217
|
+
```ruby
|
218
|
+
aggregate_id = Aggregates.new_aggregate_id
|
219
|
+
# ... Commands and stuff happened.
|
220
|
+
auditor = Aggregates.audit MyAggregateType aggregate_id
|
221
|
+
|
222
|
+
# Each of these returns a list to investigate using.
|
223
|
+
events = auditor.events # Or events_processed_by(time) or events_processed_after(time)
|
224
|
+
commands = auditor.commands # Or commands_processed_by(time) or commands_processed_after(time)
|
225
|
+
|
226
|
+
# Or....
|
227
|
+
# View the state of an aggregate at a certain pont in time.
|
228
|
+
aggregate_at_time = auditor.inspect_state_at(Time.now - 1.hour)
|
229
|
+
```
|
230
|
+
|
231
|
+
### Configuring
|
232
|
+
|
233
|
+
#### Storage Backends
|
234
|
+
|
235
|
+
Storage Backends at the method by which events and commands are stored in
|
236
|
+
the system.
|
237
|
+
|
238
|
+
```ruby
|
239
|
+
Aggregates.configure do |config|
|
240
|
+
config.store_with MyAwesomeStorageBackend.new
|
241
|
+
end
|
242
|
+
```
|
243
|
+
|
244
|
+
##### Dynamoid
|
245
|
+
|
246
|
+
If `Aggregates` can `require 'dynamoid'` then it will provide the `Aggregates::Dynamoid::DynamoidStorageBackend` that
|
247
|
+
stores using the [Dynmoid Gem](https://github.com/Dynamoid/dynamoid) for AWS DynamoDB.
|
248
|
+
|
249
|
+
#### Adding Command Processors
|
250
|
+
|
251
|
+
```ruby
|
252
|
+
Aggregates.configure do |config|
|
253
|
+
# May call this method many times with different processors.
|
254
|
+
config.process_commands_with PostCommandProcessor.new
|
255
|
+
end
|
256
|
+
```
|
257
|
+
|
258
|
+
#### Adding Event Processors
|
259
|
+
|
260
|
+
```ruby
|
261
|
+
Aggregates.configure do |config|
|
262
|
+
# May call this method many times with different processors.
|
263
|
+
config.process_events_with RssUpdateProcessor.new
|
264
|
+
end
|
265
|
+
```
|
266
|
+
|
267
|
+
#### Adding Command Filters
|
268
|
+
|
269
|
+
```ruby
|
270
|
+
Aggregates.configure do |config|
|
271
|
+
config.filter_commands_with MyCommandFilter.new
|
272
|
+
end
|
273
|
+
```
|
274
|
+
|
275
|
+
## Development
|
276
|
+
|
277
|
+
To contribute, run:
|
278
|
+
|
279
|
+
git clone https://github.com/resilient-vitality/aggregates.git
|
280
|
+
cd aggregates
|
281
|
+
bin/setup
|
282
|
+
|
283
|
+
You can also use the IRB console for direct access to all objects:
|
284
|
+
|
285
|
+
bin/console
|
286
|
+
|
287
|
+
## Tests
|
288
|
+
|
289
|
+
To test, run:
|
290
|
+
|
291
|
+
bundle exec rake
|
292
|
+
|
293
|
+
## Versioning
|
294
|
+
|
295
|
+
Read [Semantic Versioning](https://semver.org) for details. Briefly, it means:
|
296
|
+
|
297
|
+
- Major (X.y.z) - Incremented for any backwards incompatible public API changes.
|
298
|
+
- Minor (x.Y.z) - Incremented for new, backwards compatible, public API enhancements/fixes.
|
299
|
+
- Patch (x.y.Z) - Incremented for small, backwards compatible, bug fixes.
|
300
|
+
|
301
|
+
## Code of Conduct
|
302
|
+
|
303
|
+
Please note that this project is released with a [CODE OF CONDUCT](CODE_OF_CONDUCT.md). By
|
304
|
+
participating in this project you agree to abide by its terms.
|
305
|
+
|
306
|
+
## Contributions
|
307
|
+
|
308
|
+
Read [CONTRIBUTING](CONTRIBUTING.md) for details.
|
309
|
+
|
310
|
+
## License
|
311
|
+
|
312
|
+
Copyright 2021 [Resilient Vitality](www.resilientvitality.com).
|
313
|
+
Read [LICENSE](LICENSE.md) for details.
|
314
|
+
|
315
|
+
## History
|
316
|
+
|
317
|
+
Read [CHANGES](CHANGES.md) for details.
|
318
|
+
Built with [Gemsmith](https://www.alchemists.io/projects/gemsmith).
|
319
|
+
|
320
|
+
## Credits
|
321
|
+
|
322
|
+
Developed by [Zach Probst](mailto:zprobst@resilientvitality.com) at [Resilient Vitality](www.resilientvitality.com).
|
data/lib/aggregates.rb
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'zeitwerk'
|
4
|
+
|
5
|
+
loader = Zeitwerk::Loader.for_gem
|
6
|
+
loader.setup
|
7
|
+
|
8
|
+
# A helpful library for building CQRS and Event Sourced Applications.
|
9
|
+
module Aggregates
|
10
|
+
def self.configure
|
11
|
+
yield Configuration.instance
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.new_aggregate_id
|
15
|
+
SecureRandom.uuid.to_s
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.new_message_id
|
19
|
+
SecureRandom.uuid.to_s
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.execute_command(command)
|
23
|
+
CommandDispatcher.instance.execute_command command
|
24
|
+
end
|
25
|
+
|
26
|
+
def self.execute_commands(*commands)
|
27
|
+
CommandDispatcher.instance.execute_commands(*commands)
|
28
|
+
end
|
29
|
+
|
30
|
+
def self.audit(type, aggregate_id)
|
31
|
+
Auditor.new type, aggregate_id
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Aggregates
|
4
|
+
# An AggregateRoot is a central grouping of domain object(s) that work to encapsulate
|
5
|
+
# parts of our Domain or Business Logic.
|
6
|
+
#
|
7
|
+
# The general design of aggregate roots should be as follows:
|
8
|
+
# - Create functions that encapsulate different changes in your Aggregate Roots. These functions should enforce
|
9
|
+
# constraints on the application. Then capture state changes by creating events.
|
10
|
+
#
|
11
|
+
# - Create event handlers that actually performed the state changes captured by the events
|
12
|
+
# made by processing commands using the above functions.
|
13
|
+
class AggregateRoot < EventProcessor
|
14
|
+
attr_reader :id
|
15
|
+
|
16
|
+
# Returns a new instance of an aggregate by loading and reprocessing all events for that aggregate.
|
17
|
+
def self.get_by_id(id)
|
18
|
+
instance = new id
|
19
|
+
instance.replay_history
|
20
|
+
instance
|
21
|
+
end
|
22
|
+
|
23
|
+
# Creates a new instance of an aggregate root. This should not be called directly. Instead, it should
|
24
|
+
# be called by calling AggregateRoot.get_by_id.
|
25
|
+
# :reek:BooleanParameter
|
26
|
+
def initialize(id, mutable: true)
|
27
|
+
super()
|
28
|
+
|
29
|
+
@id = id
|
30
|
+
@mutable = mutable
|
31
|
+
@sequence_number = 1
|
32
|
+
@event_stream = EventStream.new id
|
33
|
+
end
|
34
|
+
|
35
|
+
def process_event(event)
|
36
|
+
super
|
37
|
+
@sequence_number += 1
|
38
|
+
end
|
39
|
+
|
40
|
+
# Takes an event type and some parameters with which to create it. Then performs the following actions
|
41
|
+
# 1.) Builds the final event object.
|
42
|
+
# 2.) Processes the event locally on the aggregate.
|
43
|
+
# 3.) Produces the event on the event stream so that is saved by the storage backend and processed
|
44
|
+
# by the configured processors of the given type.
|
45
|
+
def apply(event, params = {})
|
46
|
+
raise FrozenError unless @mutable
|
47
|
+
|
48
|
+
event = build_event(event, params)
|
49
|
+
process_event event
|
50
|
+
@event_stream.publish event
|
51
|
+
end
|
52
|
+
|
53
|
+
# Loads all events from the event stream of this instance and reprocesses them to
|
54
|
+
# get the current state of the aggregate.
|
55
|
+
def replay_history(up_to: nil)
|
56
|
+
events = @event_stream.load_events
|
57
|
+
events = events.select { |event| event.created_at <= up_to } if up_to.present?
|
58
|
+
events.each do |event|
|
59
|
+
process_event event
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
private
|
64
|
+
|
65
|
+
# Builds a new event from a given event type and parameter set. Includes parameters
|
66
|
+
# needed for all events that are derived from the aggregate's state.
|
67
|
+
def build_event(event, params)
|
68
|
+
default_args = { aggregate_id: @id, sequence_number: @sequence_number }
|
69
|
+
event.new(params.merge(default_args))
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Aggregates
|
4
|
+
# The Auditor captures the state of a given aggregate at time of use. It provides listings of the
|
5
|
+
# commands and events that we executed on a given aggregate.
|
6
|
+
class Auditor
|
7
|
+
attr_reader :type, :aggregate_id
|
8
|
+
|
9
|
+
def initialize(type, aggregate_id)
|
10
|
+
@type = type
|
11
|
+
@aggregate_id = aggregate_id
|
12
|
+
end
|
13
|
+
|
14
|
+
# This method creates a new instance of the aggregate root and replays the events
|
15
|
+
# on the aggregate alone. Only events that happened prior to the time specified are
|
16
|
+
# processed.
|
17
|
+
def inspect_state_at(time)
|
18
|
+
aggregate = @type.new @aggregate_id, mutable: false
|
19
|
+
aggregate.replay_history up_to: time
|
20
|
+
aggregate
|
21
|
+
end
|
22
|
+
|
23
|
+
# Returns all stored events for a given aggregate.
|
24
|
+
def events
|
25
|
+
@events ||= Configuration.storage_backend.load_events_by_aggregate_id(@aggregate_id)
|
26
|
+
end
|
27
|
+
|
28
|
+
# Returns all commands for a given aggregate.
|
29
|
+
def commands
|
30
|
+
@commands ||= Configuration.storage_backend.load_commands_by_aggregate_id(@aggregate_id)
|
31
|
+
end
|
32
|
+
|
33
|
+
def events_processed_by(time)
|
34
|
+
events.select { |event| event.created_at < time }
|
35
|
+
end
|
36
|
+
|
37
|
+
def commands_processed_by(time)
|
38
|
+
commands.select { |event| event.created_at < time }
|
39
|
+
end
|
40
|
+
|
41
|
+
def commands_processed_after(time)
|
42
|
+
commands.select { |event| event.created_at > time }
|
43
|
+
end
|
44
|
+
|
45
|
+
def events_processed_after(time)
|
46
|
+
events.select { |event| event.created_at > time }
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'dry/monads'
|
4
|
+
require 'dry-validation'
|
5
|
+
|
6
|
+
module Aggregates
|
7
|
+
# Commands are a type of message that define the shape and contract data that is accepted for an attempt
|
8
|
+
# at performing a state change on a given aggregate. Essentially, they provide the api for interacting with
|
9
|
+
# your domain. Commands should have descriptive names capturing the change they are intended to make to the domain.
|
10
|
+
# For instance, `ChangeUserEmail` or `AddComment`.
|
11
|
+
class Command < DomainMessage
|
12
|
+
# Provides a default contract for data validation on the command itself.
|
13
|
+
class Contract < Dry::Validation::Contract
|
14
|
+
end
|
15
|
+
|
16
|
+
def validate
|
17
|
+
Contract.new.call(attributes).errors.to_h
|
18
|
+
end
|
19
|
+
|
20
|
+
def validate!
|
21
|
+
errors = validate
|
22
|
+
raise CommandValidationError, errors unless errors.length.zero?
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'singleton'
|
4
|
+
|
5
|
+
module Aggregates
|
6
|
+
# The CommandDispatcher is effectively a router of incoming commands to CommandProcessors that are responsible
|
7
|
+
# for handling them appropriately. By convention, you likely will not need to interact with it directly, instead
|
8
|
+
# simply call Aggregates.process_command or Aggregates.process_commands.
|
9
|
+
class CommandDispatcher
|
10
|
+
include Singleton
|
11
|
+
|
12
|
+
def initialize
|
13
|
+
@config = Configuration.instance
|
14
|
+
end
|
15
|
+
|
16
|
+
# Takes a sequence of commands and executes them one at a time.
|
17
|
+
def process_commands(*commands)
|
18
|
+
commands.each do |command|
|
19
|
+
process_command command
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
# Takes a single command and processes it. The command will be validated through it's contract, sent to command
|
24
|
+
# processors and finally stored with the configured StorageBackend used for messages.
|
25
|
+
def process_command(command)
|
26
|
+
command.validate!
|
27
|
+
return unless should_process? command
|
28
|
+
|
29
|
+
send_to_processors command
|
30
|
+
store command
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def should_process?(command)
|
36
|
+
# Each command processor is going to give a true/false value for itself.
|
37
|
+
# So if they all allow it, then we can return true. Else false.
|
38
|
+
@config.command_filters.all? do |command_filter|
|
39
|
+
command_filter.allow? command
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def send_to_processors(command)
|
44
|
+
@config.command_processors.each do |command_processor|
|
45
|
+
command_processor.process_command command
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def store(command)
|
50
|
+
@config.storage_backend.store_command command
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Aggregates
|
4
|
+
# Applies filters to commands to decouple filtering logic from the CommandProcessor.
|
5
|
+
class CommandFilter
|
6
|
+
include MessageProcessor
|
7
|
+
include WithAggregateHelpers
|
8
|
+
|
9
|
+
def allow?(command)
|
10
|
+
handle_message(command).all?
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Aggregates
|
4
|
+
# A command processor is a type that correlates commands to operations on an aggregate root.
|
5
|
+
class CommandProcessor
|
6
|
+
include MessageProcessor
|
7
|
+
include WithAggregateHelpers
|
8
|
+
|
9
|
+
def process_command(command)
|
10
|
+
handle_message command
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Aggregates
|
4
|
+
# Wraps a hash of errors when validating a command as an Exception.
|
5
|
+
class CommandValidationError < StandardError
|
6
|
+
attr_reader :errors
|
7
|
+
|
8
|
+
def initialize(errors, msg = nil)
|
9
|
+
super(msg)
|
10
|
+
|
11
|
+
@errors = errors
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'singleton'
|
4
|
+
|
5
|
+
module Aggregates
|
6
|
+
# Stores all of the items needed to dictate the exact behavior needed by
|
7
|
+
# the application consuming the Aggregates gem.
|
8
|
+
class Configuration
|
9
|
+
include Singleton
|
10
|
+
|
11
|
+
attr_reader :command_processors, :event_processors,
|
12
|
+
:storage_backend, :command_filters
|
13
|
+
|
14
|
+
def initialize
|
15
|
+
@command_processors = []
|
16
|
+
@event_processors = []
|
17
|
+
@command_filters = []
|
18
|
+
@storage_backend = InMemoryStorageBackend.new
|
19
|
+
end
|
20
|
+
|
21
|
+
def filter_commands_with(command_filter)
|
22
|
+
@command_filters << command_filter
|
23
|
+
end
|
24
|
+
|
25
|
+
def store_with(storage_backend)
|
26
|
+
@storage_backend = storage_backend
|
27
|
+
end
|
28
|
+
|
29
|
+
def process_events_with(event_processor)
|
30
|
+
@event_processors << event_processor
|
31
|
+
end
|
32
|
+
|
33
|
+
def process_commands_with(command_processor)
|
34
|
+
@command_processors << command_processor
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'dry-struct'
|
4
|
+
|
5
|
+
module Aggregates
|
6
|
+
# The DomainMessage is not a class that should generally be interacted with unless
|
7
|
+
# extending Aggregates itself. It provides some core functionality that message types
|
8
|
+
# (Event and Command) both require.
|
9
|
+
class DomainMessage < Dry::Struct
|
10
|
+
attribute :aggregate_id, Types::String
|
11
|
+
attribute :message_id, Types::String.default(proc { Aggregates.new_message_id })
|
12
|
+
attribute :created_at, Types::Strict::DateTime.default(proc { Time.now })
|
13
|
+
|
14
|
+
def to_json(*args)
|
15
|
+
json_data = attributes.merge({ JSON.create_id => self.class.name })
|
16
|
+
json_data.to_json(args)
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.json_create(arguments)
|
20
|
+
new arguments
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# rubocop:disable Style/Documentation
|
4
|
+
|
5
|
+
module Aggregates
|
6
|
+
# rubocop:enable Style/Documentation
|
7
|
+
begin
|
8
|
+
require 'dynamoid'
|
9
|
+
|
10
|
+
# Extensions to the Aggregates gem that provide message storage on DynamoDB.
|
11
|
+
module Dynamoid
|
12
|
+
# Stores events in DynamoDB using `Dynamoid`
|
13
|
+
class DynamoEventStore
|
14
|
+
include ::Dynamoid::Document
|
15
|
+
|
16
|
+
field :aggregate_id
|
17
|
+
field :sequence_number, :integer
|
18
|
+
field :data
|
19
|
+
|
20
|
+
table name: :events, hash_key: :aggregate_id, range_key: :sequence_number, timestamps: true
|
21
|
+
|
22
|
+
def self.store!(event, data)
|
23
|
+
args = { aggregate_id: event.aggregate_id, sequence_number: event.sequence_number, data: data }
|
24
|
+
event = new args
|
25
|
+
event.save!
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
# Stores commands in DynamoDB using `Dynamoid`
|
30
|
+
class DynamoCommandStore
|
31
|
+
include ::Dynamoid::Document
|
32
|
+
|
33
|
+
field :aggregate_id
|
34
|
+
field :data
|
35
|
+
|
36
|
+
table name: :commands, hash_key: :aggregate_id, range_key: :created_at, timestamps: true
|
37
|
+
|
38
|
+
def self.store!(command, data)
|
39
|
+
args = { aggregate_id: command.aggregate_id, data: data }
|
40
|
+
command = new args
|
41
|
+
command.save!
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
# Stores messages on DynamoDB using the dynamoid gem.
|
46
|
+
class DynamoidStorageBackend < StorageBackend
|
47
|
+
def store_event(event)
|
48
|
+
data = message_to_json_string(event)
|
49
|
+
DynamoEventStore.store! event, data
|
50
|
+
end
|
51
|
+
|
52
|
+
def store_command(command)
|
53
|
+
data = message_to_json_string(command)
|
54
|
+
DynamoCommandStore.store! command, data
|
55
|
+
end
|
56
|
+
|
57
|
+
def load_events_by_aggregate_id(aggregate_id)
|
58
|
+
DynamoEventStore.where(aggregate_id: aggregate_id).all.map do |stored_event|
|
59
|
+
json_string_to_message stored_event.data
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def load_commands_by_aggregate_id(aggregate_id)
|
64
|
+
DynamoCommandStore.where(aggregate_id: aggregate_id).all.map do |stored_command|
|
65
|
+
json_string_to_message stored_command.data
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
rescue LoadError
|
71
|
+
# This is intentional to no do anything if it is not loadable.
|
72
|
+
end
|
73
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Aggregates
|
4
|
+
# The event class defines the entry point that all Events in your domain should
|
5
|
+
# subclass from. An Event describes something that happened. They are named in passed tense.
|
6
|
+
# For instance, if the user's email has changed, then you might create an event type called
|
7
|
+
# UserEmailChanged.
|
8
|
+
class Event < DomainMessage
|
9
|
+
attribute :sequence_number, Types::Integer
|
10
|
+
end
|
11
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Aggregates
|
4
|
+
# EventProcessors respond to events that have occurred from Aggregates after.
|
5
|
+
# EventProcessors take on different roles depending on the application. The biggest
|
6
|
+
# role to to project aggregates as they are created and updated into a readable form
|
7
|
+
# for your application.
|
8
|
+
class EventProcessor
|
9
|
+
include MessageProcessor
|
10
|
+
|
11
|
+
def process_event(event)
|
12
|
+
handle_message event
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Aggregates
|
4
|
+
# An EventStream is a sequence, append only sequence of events that are read when reconstructing
|
5
|
+
# aggregates and written to when a command is processed by the aggregate.
|
6
|
+
#
|
7
|
+
# There is likely no need to interact with this class directly.
|
8
|
+
class EventStream
|
9
|
+
def initialize(aggregate_id)
|
10
|
+
@aggregate_id = aggregate_id
|
11
|
+
@config = Configuration.instance
|
12
|
+
end
|
13
|
+
|
14
|
+
def load_events
|
15
|
+
@config.storage_backend.load_events_by_aggregate_id(@aggregate_id)
|
16
|
+
end
|
17
|
+
|
18
|
+
def publish(event)
|
19
|
+
@config.event_processors.each do |event_processor|
|
20
|
+
event_processor.process_event event
|
21
|
+
end
|
22
|
+
@config.storage_backend.store_event event
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Aggregates
|
4
|
+
# This is an extremely simple storage backend that retains all events and commands in process
|
5
|
+
# memory. This method does not persist beyond application restarts and should generally only be
|
6
|
+
# used in testing.
|
7
|
+
class InMemoryStorageBackend < StorageBackend
|
8
|
+
def initialize
|
9
|
+
super()
|
10
|
+
|
11
|
+
@events = {}
|
12
|
+
@commands = {}
|
13
|
+
end
|
14
|
+
|
15
|
+
def store_command(command)
|
16
|
+
commands_for_aggregate_id = load_commands_by_aggregate_id(command.aggregate_id)
|
17
|
+
commands_for_aggregate_id << command
|
18
|
+
end
|
19
|
+
|
20
|
+
def store_event(event)
|
21
|
+
event_for_aggregate_id = load_events_by_aggregate_id(event.aggregate_id)
|
22
|
+
event_for_aggregate_id << event
|
23
|
+
end
|
24
|
+
|
25
|
+
def load_events_by_aggregate_id(aggregate_id)
|
26
|
+
@events[aggregate_id] ||= []
|
27
|
+
end
|
28
|
+
|
29
|
+
def load_commands_by_aggregate_id(aggregate_id)
|
30
|
+
@commands[aggregate_id] ||= []
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Aggregates
|
4
|
+
# MessageProcessor is a set of helper methods for routing messages
|
5
|
+
# to handlers defined at the class level for DomainMessages.
|
6
|
+
module MessageProcessor
|
7
|
+
# Provides a single mapping of Message Classes to a list of handler
|
8
|
+
# blocks that should be executed when that type of message is received.
|
9
|
+
module ClassMethods
|
10
|
+
def on(*message_classes, &block)
|
11
|
+
message_classes.each do |message_class|
|
12
|
+
handlers = message_mapping[message_class] ||= []
|
13
|
+
handlers.append block
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def message_mapping
|
18
|
+
@message_mapping ||= {}
|
19
|
+
end
|
20
|
+
|
21
|
+
def handles_message?(message)
|
22
|
+
message_mapping.key?(message.class)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def self.included(host_class)
|
27
|
+
host_class.extend(ClassMethods)
|
28
|
+
end
|
29
|
+
|
30
|
+
def find_message_handlers(message, &block)
|
31
|
+
search_class = message.class
|
32
|
+
while search_class != DomainMessage
|
33
|
+
handlers = self.class.message_mapping[search_class]
|
34
|
+
handlers&.each(&block)
|
35
|
+
search_class = search_class.superclass
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def handle_message(message)
|
40
|
+
find_message_handlers.map do |handler|
|
41
|
+
instance_exec(message, &handler)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
|
5
|
+
module Aggregates
|
6
|
+
# The StorageBackend class is responsible for providing an interface for storing Domain messages
|
7
|
+
# such as events and commands.
|
8
|
+
class StorageBackend
|
9
|
+
def store_event(_event)
|
10
|
+
raise NotImplementedError
|
11
|
+
end
|
12
|
+
|
13
|
+
def store_command(_command)
|
14
|
+
raise NotImplementedError
|
15
|
+
end
|
16
|
+
|
17
|
+
def load_events_by_aggregate_id(_aggregate_id)
|
18
|
+
raise NotImplementedError
|
19
|
+
end
|
20
|
+
|
21
|
+
def load_commands_by_aggregate_id(_aggregate_id)
|
22
|
+
raise NotImplementedError
|
23
|
+
end
|
24
|
+
|
25
|
+
protected
|
26
|
+
|
27
|
+
def message_to_json_string(message)
|
28
|
+
JSON.dump message.to_json
|
29
|
+
end
|
30
|
+
|
31
|
+
def json_string_to_message(json_string)
|
32
|
+
JSON.parse json_string, create_additions: true
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Aggregates
|
4
|
+
# Helper functions for running blocks with a specified aggregate.
|
5
|
+
module WithAggregateHelpers
|
6
|
+
# Class Methods to extend onto the host class.
|
7
|
+
module ClassMethods
|
8
|
+
def with_aggregate(type, command, &block)
|
9
|
+
aggregate_id = command.aggregate_id
|
10
|
+
with_aggregate_by_id(type, aggregate_id, &block)
|
11
|
+
end
|
12
|
+
|
13
|
+
def with_aggregate_by_id(type, aggregate_id)
|
14
|
+
yield type.get_by_id aggregate_id
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.included(host_class)
|
19
|
+
host_class.extend(ClassMethods)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
metadata
ADDED
@@ -0,0 +1,113 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: aggregates
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.2.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Zach Probst
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2021-07-09 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: dry-struct
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '1.0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '1.0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: dry-validation
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '1.6'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '1.6'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: zeitwerk
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '2.4'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '2.4'
|
55
|
+
description:
|
56
|
+
email:
|
57
|
+
- zprobst@resilientvitality.com
|
58
|
+
executables: []
|
59
|
+
extensions: []
|
60
|
+
extra_rdoc_files:
|
61
|
+
- README.md
|
62
|
+
- LICENSE.md
|
63
|
+
files:
|
64
|
+
- LICENSE.md
|
65
|
+
- README.md
|
66
|
+
- lib/aggregates.rb
|
67
|
+
- lib/aggregates/aggregate_root.rb
|
68
|
+
- lib/aggregates/auditor.rb
|
69
|
+
- lib/aggregates/command.rb
|
70
|
+
- lib/aggregates/command_dispatcher.rb
|
71
|
+
- lib/aggregates/command_filter.rb
|
72
|
+
- lib/aggregates/command_processor.rb
|
73
|
+
- lib/aggregates/command_validation_error.rb
|
74
|
+
- lib/aggregates/configuration.rb
|
75
|
+
- lib/aggregates/domain_message.rb
|
76
|
+
- lib/aggregates/dynamoid/dynamoid_storage_backend.rb
|
77
|
+
- lib/aggregates/event.rb
|
78
|
+
- lib/aggregates/event_processor.rb
|
79
|
+
- lib/aggregates/event_stream.rb
|
80
|
+
- lib/aggregates/identity.rb
|
81
|
+
- lib/aggregates/in_memory_storage_backend.rb
|
82
|
+
- lib/aggregates/message_processor.rb
|
83
|
+
- lib/aggregates/storage_backend.rb
|
84
|
+
- lib/aggregates/types.rb
|
85
|
+
- lib/aggregates/with_aggregate_helpers.rb
|
86
|
+
homepage: https://github.com/resilient-vitality/aggregates
|
87
|
+
licenses:
|
88
|
+
- MIT
|
89
|
+
metadata:
|
90
|
+
bug_tracker_uri: https://github.com/resilient-vitality/aggregates/issues
|
91
|
+
changelog_uri: https://github.com/resilient-vitality/aggregates/blob/master/CHANGES.md
|
92
|
+
documentation_uri: https://github.com/resilient-vitality/aggregates
|
93
|
+
source_code_uri: https://github.com/resilient-vitality/aggregates
|
94
|
+
post_install_message:
|
95
|
+
rdoc_options: []
|
96
|
+
require_paths:
|
97
|
+
- lib
|
98
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
99
|
+
requirements:
|
100
|
+
- - "~>"
|
101
|
+
- !ruby/object:Gem::Version
|
102
|
+
version: '3.0'
|
103
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
104
|
+
requirements:
|
105
|
+
- - ">="
|
106
|
+
- !ruby/object:Gem::Version
|
107
|
+
version: '0'
|
108
|
+
requirements: []
|
109
|
+
rubygems_version: 3.2.15
|
110
|
+
signing_key:
|
111
|
+
specification_version: 4
|
112
|
+
summary: A ruby gem for writing CQRS applications
|
113
|
+
test_files: []
|