aggregates 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
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,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aggregates
4
+ # Gem identity information.
5
+ module Identity
6
+ NAME = 'aggregates'
7
+ LABEL = 'Aggregates'
8
+ VERSION = '0.2.0'
9
+ VERSION_LABEL = "#{LABEL} #{VERSION}"
10
+ end
11
+ 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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry-struct'
4
+
5
+ module Aggregates
6
+ module Types
7
+ include Dry.Types
8
+ end
9
+ 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: []