aggregates 0.2.0 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 00b1d88fe05dec5dcc22db0e1349c2dd91645b4e33755f8fbf9e3d3ea9085fab
4
- data.tar.gz: ad8bd4c5cd4c1e781776ea4890f59e59d952a6cc30e4509c41e92da649b691ba
3
+ metadata.gz: a8ea9821a6f5fa681750a5d64885972c899285705e255c519336ceba98dc4afd
4
+ data.tar.gz: 875d8fa0127fd8b708e656c9761a5852baa54cccee2885f27a95f6cc40c3a5bb
5
5
  SHA512:
6
- metadata.gz: dbd3330da62e2783c4911116e266511ff5decc8f629eefbc7e671a8128fb7082f62aff6a1edac6018243ed6d1856a823dd84e3cef879edb741376f9a273ddaf8
7
- data.tar.gz: c5ff7486bda8e1412cc6624db657c7deeb8f94f5d823cbdc799e9bf42c862bbc0735fa3ba589fc10c64a30275f8aa514f33ab33ff4f345807a2da244dc5438d5
6
+ metadata.gz: b42b9939913cf8854cd33c35b9fa56b85a84b287a5dea4fd402ec7263f20aec612bbe1b35ca49e1fe8b0ae39c4a7cd8e28d55afd01b0239acf5da3ccfebf8945
7
+ data.tar.gz: 74e3e52d34228e9aba39d161dc237a756d8f343281944653cd08020d1ce9f4b154302d2d0afb95374ea8d964fa9b751a2bf0f67ef0fa94a3e35a85197ec4071b
data/README.md CHANGED
@@ -23,16 +23,14 @@ _Warning:_ This Gem is in active development and probably doesn't work correctly
23
23
  - [Creating Commands](#creating-commands)
24
24
  - [Creating Events](#creating-events)
25
25
  - [Processing Commands](#processing-commands)
26
+ - [Value Objects](#value-objects)
26
27
  - [Filtering Commands](#filtering-commands)
27
28
  - [Processing Events](#processing-events)
28
- - [Executing Commands](#executing-commands)
29
- - [Auditing Aggregates](#auditing-aggregates)
30
- - [Configuring](#configuring)
29
+ - [Building The Domain](#building-the-domain)
30
+ - [Executing Your Domain](#executing-your-domain)
31
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)
32
+ - [Executing Commands](#executing-commands)
33
+ - [Auditing Aggregates](#auditing-aggregates)
36
34
  - [Development](#development)
37
35
  - [Tests](#tests)
38
36
  - [Versioning](#versioning)
@@ -48,7 +46,7 @@ _Warning:_ This Gem is in active development and probably doesn't work correctly
48
46
 
49
47
  - Pluggable Event / Command Storage Backends
50
48
  - Tools for Command Validation, Filtering, and Execution.
51
- - Opinioned structure for CQRS, Domain-Driven Design, and Event Sourcing.
49
+ - Opinionated structure for CQRS, Domain-Driven Design, and Event Sourcing.
52
50
 
53
51
  ## Requirements
54
52
 
@@ -71,7 +69,7 @@ Or Add the following to your Gemfile:
71
69
  An AggregateRoot is a grouping of domain object(s) that work to encapsulate
72
70
  a single part of your Domain or Business Logic. The general design of aggregate roots should be as follows:
73
71
 
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.
72
+ - Create functions that encapsulate different operations on your Aggregate Roots. These functions should enforce business logic constraints and then capture state changes by creating events.
75
73
  - Create event handlers that actually perform the state changes captured by those events.
76
74
 
77
75
  A simple example is below:
@@ -80,11 +78,11 @@ A simple example is below:
80
78
  class Post < Aggregates::AggregateRoot
81
79
  # Write functions that encapsulate business logic.
82
80
  def publish(command)
83
- apply EventPublished, body: command.body, category: command.category
81
+ apply PostPublished, body: command.body, category: command.category
84
82
  end
85
83
 
86
84
  # Modify the state of the aggregate from the emitted events.
87
- on EventPublished do |event|
85
+ on PostPublished do |event|
88
86
  @body = event.body
89
87
  @category = event.category
90
88
  end
@@ -96,23 +94,23 @@ as well. Every `on` block that applies to the event will be called in order from
96
94
 
97
95
  ### Creating Commands
98
96
 
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`.
97
+ Commands are a type of domain message that define the shape and contract of data needed to perform an action.
98
+ Essentially, they provide the api for interacting with your domain.
99
+ Commands should have descriptive names capturing the change they are intended to make.
100
+ For instance, `ChangeUserEmail` or `AddComment`.
100
101
 
101
102
  ```ruby
102
103
  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
104
+ interacts_with Post
105
+
106
+ attribute :body
107
+ attribute :category
108
+ validates_length_of :body, minimum: 10
113
109
  end
114
110
  ```
115
111
 
112
+ You can specify them via attr accessors and use `ActiveModel::Validations` to enforce data constraints.
113
+
116
114
  ### Creating Events
117
115
 
118
116
  An Event describes something that happened. They are named in passed tense.
@@ -120,9 +118,9 @@ For instance, if the user's email has changed, then you might create an event ty
120
118
  `UserEmailChanged`.
121
119
 
122
120
  ```ruby
123
- class PublishPost < Aggregates::Command
124
- attribute :body, Types::String
125
- attribute :category, Types::String
121
+ class PostPublished < Aggregates::Event
122
+ attribute :body
123
+ attribute :category
126
124
  end
127
125
  ```
128
126
 
@@ -131,48 +129,60 @@ end
131
129
  The goal of a `CommandProcessor` is to route commands that have passed validation and
132
130
  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
131
 
134
- A helper function, `with_aggregate`, is provided to help retrieve the appropriate aggregate
135
- for a given command.
136
-
137
132
  ```ruby
138
133
  class PostCommandProcessor < Aggregates::CommandProcessor
139
- on PublishPost do |command|
140
- with_aggregate(Post, command) do |post|
141
- post.publsh(command)
142
- end
134
+ # Instead of `process`, you may use `on`
135
+ process PublishPost do |command, post|
136
+ post.publish(command)
143
137
  end
144
138
  end
145
139
  ```
140
+ _Note:_ the message-handling DSL (`process`) supports passing a super class of any given event
141
+ as well. Every `process` block that applies to the event will be called in order from most specific to least specific.
142
+
143
+ ### Value Objects
144
+
145
+ Often times you will find that you will have data clumps that are similar pieces of data that have the same rules, and schema. Typically these values represent a valid type in your domain and should be combined as a single value. That is where `ValueObject` comes in. The api is the same as commands and events.
146
+
147
+ ```ruby
148
+ class Name < Aggregates::ValueObject
149
+ attribute :first_name
150
+ attribute :last_name
151
+ validates_presence_of :first_name, :last_name
152
+ end
153
+ ```
154
+
155
+ When you have a command, validation logic will automatically include validating nested value objects to an arbitrary depth.
146
156
 
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
157
 
150
158
  ### Filtering Commands
151
159
 
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.
160
+ 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 responsibility principal.
153
161
 
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.
162
+ 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.
163
+ 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 `filter` or `on` DSL.
164
+ If any one of the filters rejects the command then the command will not be processed.
155
165
 
156
166
  ```ruby
157
167
  class UpdatePostCommand < Aggregates::Command
158
- attribute :commanding_user_id, Types::String
168
+ interacts_with Post
169
+ attribute :commanding_user_id
159
170
  end
160
171
 
161
172
  class UpdatePostBody < UpdatePostCommand
162
- attribute :body, Types::String
173
+ attribute :body
163
174
  end
164
175
 
165
176
  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
177
+ # Instead of `filter`, you may use `on`
178
+ filter UpdatePostCommand do |command, post|
179
+ post.owner_id == command.commanding_user_id
170
180
  end
171
181
  end
172
182
  ```
173
183
 
174
184
  In this example, we are using a super class of `UpdatePostBody`.
175
- As with all MessageProcessors, calling `on` with a super class
185
+ As with all MessageProcessors, calling `filter` with a super class
176
186
  will be called when any child class is being processed. In other words,
177
187
  `on UpdatePostCommand` will be called when you call `Aggregates.execute_command`
178
188
  with an instance of `UpdatePostBody`.
@@ -191,33 +201,55 @@ class RssUpdateProcessor < Aggregates::EventProcessor
191
201
  # ...
192
202
  end
193
203
 
194
- on EventPublished do |event|
204
+ on PostPublished do |event|
195
205
  update_feed_for_new_post(event)
196
206
  end
197
207
  end
198
208
  ```
199
209
 
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.
210
+ ### Building The Domain
211
+
212
+ ```ruby
213
+ domain = Aggregates.create_domain do
214
+ # Adding Command Processors
215
+ process_commands_with PostCommandProcessor.new
216
+ # Adding Event Processors
217
+ process_events_with RssUpdateProcessor.new
218
+ # Adding Command Filters
219
+ filter_commands_with MyCommandFilter.new
220
+ end
221
+ ```
202
222
 
203
- ### Executing Commands
223
+ ### Executing Your Domain
224
+
225
+ #### Storage Backends
226
+
227
+ Storage Backends are the method by which events and commands are stored in
228
+ the system. You need to specify one in order to execute your domain.
229
+
230
+ ```ruby
231
+ executor = domain.execute_with MyAwesomeStorageBackend.new
232
+ ```
233
+
234
+ #### Executing Commands
204
235
 
205
236
  ```ruby
206
237
  aggregate_id = Aggregates.new_aggregate_id
207
238
  command = CreateThing.new(foo: 1, bar: false, aggregate_id: aggregate_id)
208
- Aggregates.execute_command command
239
+ executor.execute_command(command)
209
240
 
210
241
  increment = IncrementFooThing.new(aggregate_id: aggregate_id)
211
242
  toggle = ToggleBarThing.new(aggregate_id: aggregate_id)
212
- Aggregates.execute_commands increment, toggle
243
+ executor.execute_command(command)
244
+ executor.execute_command(command)
213
245
  ```
214
246
 
215
- ### Auditing Aggregates
247
+ #### Auditing Aggregates
216
248
 
217
249
  ```ruby
218
250
  aggregate_id = Aggregates.new_aggregate_id
219
251
  # ... Commands and stuff happened.
220
- auditor = Aggregates.audit MyAggregateType aggregate_id
252
+ auditor = executor.audit MyAggregateType aggregate_id
221
253
 
222
254
  # Each of these returns a list to investigate using.
223
255
  events = auditor.events # Or events_processed_by(time) or events_processed_after(time)
@@ -228,49 +260,6 @@ commands = auditor.commands # Or commands_processed_by(time) or commands_process
228
260
  aggregate_at_time = auditor.inspect_state_at(Time.now - 1.hour)
229
261
  ```
230
262
 
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
263
 
275
264
  ## Development
276
265
 
data/lib/aggregates.rb CHANGED
@@ -1,33 +1,44 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'zeitwerk'
4
-
5
- loader = Zeitwerk::Loader.for_gem
6
- loader.setup
3
+ require 'securerandom'
4
+
5
+ require_relative './aggregates/domain_object'
6
+ require_relative './aggregates/domain_message'
7
+ require_relative './aggregates/message_processor'
8
+
9
+ require_relative './aggregates/aggregate_root'
10
+ require_relative './aggregates/auditor'
11
+ require_relative './aggregates/command'
12
+ require_relative './aggregates/command_processor'
13
+ require_relative './aggregates/command_filter'
14
+ require_relative './aggregates/command_validation_error'
15
+ require_relative './aggregates/domain'
16
+ require_relative './aggregates/domain_executor'
17
+ require_relative './aggregates/event'
18
+ require_relative './aggregates/event_processor'
19
+ require_relative './aggregates/event_stream'
20
+ require_relative './aggregates/value_object'
21
+
22
+ require_relative './aggregates/storage_backend'
23
+ require_relative './aggregates/in_memory_storage_backend'
7
24
 
8
25
  # A helpful library for building CQRS and Event Sourced Applications.
9
26
  module Aggregates
10
- def self.configure
11
- yield Configuration.instance
12
- end
13
-
14
27
  def self.new_aggregate_id
15
- SecureRandom.uuid.to_s
28
+ new_uuid
16
29
  end
17
30
 
18
31
  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
32
+ new_uuid
24
33
  end
25
34
 
26
- def self.execute_commands(*commands)
27
- CommandDispatcher.instance.execute_commands(*commands)
35
+ def self.create_domain(&block)
36
+ domain = Domain.new
37
+ domain.instance_exec(&block)
38
+ domain
28
39
  end
29
40
 
30
- def self.audit(type, aggregate_id)
31
- Auditor.new type, aggregate_id
41
+ def self.new_uuid
42
+ SecureRandom.uuid.to_s
32
43
  end
33
44
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative './event_stream'
4
+
5
+ module Aggregates
6
+ # Uses the storage backend to store load aggregates.
7
+ class AggregateRepository
8
+ def initialize(storage_backend)
9
+ @storage_backend = storage_backend
10
+ end
11
+
12
+ def load_aggregate(type, id, at: nil)
13
+ event_stream = create_aggregate_event_stream(type, id)
14
+ aggregate = type.new(id, event_stream)
15
+ replay_events_on_aggregate(aggregate, event_stream, at)
16
+ aggregate
17
+ end
18
+
19
+ private
20
+
21
+ def create_aggregate_event_stream(type, id)
22
+ EventStream.new(@storage_backend, type, id)
23
+ end
24
+
25
+ def replay_events_on_aggregate(aggregate, event_stream, at)
26
+ events = event_stream.load_events ending_at: at
27
+ events.each do |event|
28
+ aggregate.process_event(event)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative './message_processor'
4
+
3
5
  module Aggregates
4
6
  # An AggregateRoot is a central grouping of domain object(s) that work to encapsulate
5
7
  # parts of our Domain or Business Logic.
@@ -10,31 +12,23 @@ module Aggregates
10
12
  #
11
13
  # - Create event handlers that actually performed the state changes captured by the events
12
14
  # made by processing commands using the above functions.
13
- class AggregateRoot < EventProcessor
14
- attr_reader :id
15
+ class AggregateRoot
16
+ include MessageProcessor
15
17
 
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
18
+ attr_reader :id
22
19
 
23
20
  # Creates a new instance of an aggregate root. This should not be called directly. Instead, it should
24
21
  # be called by calling AggregateRoot.get_by_id.
25
22
  # :reek:BooleanParameter
26
- def initialize(id, mutable: true)
27
- super()
28
-
23
+ def initialize(id, event_stream)
29
24
  @id = id
30
- @mutable = mutable
31
25
  @sequence_number = 1
32
- @event_stream = EventStream.new id
26
+ @event_stream = event_stream
33
27
  end
34
28
 
35
29
  def process_event(event)
36
- super
37
30
  @sequence_number += 1
31
+ handle_message event
38
32
  end
39
33
 
40
34
  # Takes an event type and some parameters with which to create it. Then performs the following actions
@@ -43,21 +37,10 @@ module Aggregates
43
37
  # 3.) Produces the event on the event stream so that is saved by the storage backend and processed
44
38
  # by the configured processors of the given type.
45
39
  def apply(event, params = {})
46
- raise FrozenError unless @mutable
47
-
48
40
  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
41
+ results = process_event(event)
42
+ @event_stream.publish(event)
43
+ results
61
44
  end
62
45
 
63
46
  private
@@ -6,7 +6,8 @@ module Aggregates
6
6
  class Auditor
7
7
  attr_reader :type, :aggregate_id
8
8
 
9
- def initialize(type, aggregate_id)
9
+ def initialize(storage_backend, type, aggregate_id)
10
+ @storage_backend = storage_backend
10
11
  @type = type
11
12
  @aggregate_id = aggregate_id
12
13
  end
@@ -15,19 +16,18 @@ module Aggregates
15
16
  # on the aggregate alone. Only events that happened prior to the time specified are
16
17
  # processed.
17
18
  def inspect_state_at(time)
18
- aggregate = @type.new @aggregate_id, mutable: false
19
- aggregate.replay_history up_to: time
20
- aggregate
19
+ aggregate_repository = AggregateRepository.new(@storage_backend)
20
+ aggregate_repository.load_aggregate(@type, @aggregate_id, at: time)
21
21
  end
22
22
 
23
23
  # Returns all stored events for a given aggregate.
24
24
  def events
25
- @events ||= Configuration.storage_backend.load_events_by_aggregate_id(@aggregate_id)
25
+ @events ||= @storage_backend.load_events_by_aggregate_id(@aggregate_id)
26
26
  end
27
27
 
28
28
  # Returns all commands for a given aggregate.
29
29
  def commands
30
- @commands ||= Configuration.storage_backend.load_commands_by_aggregate_id(@aggregate_id)
30
+ @commands ||= @storage_backend.load_commands_by_aggregate_id(@aggregate_id)
31
31
  end
32
32
 
33
33
  def events_processed_by(time)
@@ -1,25 +1,30 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'dry/monads'
4
- require 'dry-validation'
3
+ require 'active_model/errors'
5
4
 
6
5
  module Aggregates
7
6
  # Commands are a type of message that define the shape and contract data that is accepted for an attempt
8
7
  # at performing a state change on a given aggregate. Essentially, they provide the api for interacting with
9
8
  # your domain. Commands should have descriptive names capturing the change they are intended to make to the domain.
10
9
  # For instance, `ChangeUserEmail` or `AddComment`.
10
+ # :reek:MissingSafeMethod { exclude: [ validate! ] }
11
11
  class Command < DomainMessage
12
- # Provides a default contract for data validation on the command itself.
13
- class Contract < Dry::Validation::Contract
14
- end
12
+ class << self
13
+ attr_reader :aggregate_type
15
14
 
16
- def validate
17
- Contract.new.call(attributes).errors.to_h
15
+ def interacts_with(aggregate_type)
16
+ @aggregate_type = aggregate_type
17
+ end
18
18
  end
19
19
 
20
20
  def validate!
21
- errors = validate
22
- raise CommandValidationError, errors unless errors.length.zero?
21
+ super
22
+ rescue ActiveModel::ValidationError
23
+ raise Aggregates::CommandValidationError, errors.as_json
24
+ end
25
+
26
+ def load_related_aggregate(aggregate_repo)
27
+ aggregate_repo.load_aggregate(self.class.aggregate_type, aggregate_id)
23
28
  end
24
29
  end
25
30
  end
@@ -1,53 +1,38 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'singleton'
4
-
5
3
  module Aggregates
6
4
  # The CommandDispatcher is effectively a router of incoming commands to CommandProcessors that are responsible
7
5
  # for handling them appropriately. By convention, you likely will not need to interact with it directly, instead
8
6
  # simply call Aggregates.process_command or Aggregates.process_commands.
9
7
  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
8
+ def initialize(command_processors, command_filters)
9
+ @command_processors = command_processors
10
+ @command_filters = command_filters
21
11
  end
22
12
 
23
13
  # Takes a single command and processes it. The command will be validated through it's contract, sent to command
24
14
  # 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
15
+ def execute_command(execution)
16
+ return false unless should_process? execution
28
17
 
29
- send_to_processors command
30
- store command
18
+ send_to_processors(execution)
19
+ true
31
20
  end
32
21
 
33
22
  private
34
23
 
35
- def should_process?(command)
24
+ def should_process?(execution)
36
25
  # Each command processor is going to give a true/false value for itself.
37
26
  # 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
27
+ @command_filters.all? do |command_filter|
28
+ command_filter.allow?(execution)
40
29
  end
41
30
  end
42
31
 
43
- def send_to_processors(command)
44
- @config.command_processors.each do |command_processor|
45
- command_processor.process_command command
32
+ def send_to_processors(execution)
33
+ @command_processors.each do |command_processor|
34
+ command_processor.process(execution)
46
35
  end
47
36
  end
48
-
49
- def store(command)
50
- @config.storage_backend.store_command command
51
- end
52
37
  end
53
38
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aggregates
4
+ # Captures the execution of a command with the aggregate at its current state.
5
+ class CommandExecution
6
+ attr_reader :command
7
+
8
+ def initialize(aggregate_repo, command)
9
+ @aggregate_repo = aggregate_repo
10
+ @command = command
11
+ end
12
+
13
+ def execute_with(handler)
14
+ handler.invoke_handlers(command, aggregate)
15
+ end
16
+
17
+ def aggregate
18
+ command.load_related_aggregate(@aggregate_repo)
19
+ end
20
+ end
21
+ end
@@ -2,12 +2,13 @@
2
2
 
3
3
  module Aggregates
4
4
  # Applies filters to commands to decouple filtering logic from the CommandProcessor.
5
- class CommandFilter
6
- include MessageProcessor
7
- include WithAggregateHelpers
5
+ class CommandFilter < CommandProcessor
6
+ class << self
7
+ alias filter on
8
+ end
8
9
 
9
- def allow?(command)
10
- handle_message(command).all?
10
+ def allow?(execution)
11
+ process(execution).all?
11
12
  end
12
13
  end
13
14
  end
@@ -4,10 +4,13 @@ module Aggregates
4
4
  # A command processor is a type that correlates commands to operations on an aggregate root.
5
5
  class CommandProcessor
6
6
  include MessageProcessor
7
- include WithAggregateHelpers
8
7
 
9
- def process_command(command)
10
- handle_message command
8
+ class << self
9
+ alias process on
10
+ end
11
+
12
+ def process(execution)
13
+ execution.execute_with(self)
11
14
  end
12
15
  end
13
16
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative './command_dispatcher'
4
+ require_relative './domain_executor'
5
+
6
+ module Aggregates
7
+ # Defines the collection of command processors, event processors, and command filters
8
+ # that are executed together.
9
+ class Domain
10
+ attr_reader :command_processors, :event_processors, :command_filters
11
+
12
+ def initialize
13
+ @command_processors = []
14
+ @event_processors = []
15
+ @command_filters = []
16
+ end
17
+
18
+ def process_events_with(*event_processors)
19
+ event_processors.each do |event_processor|
20
+ @event_processors << event_processor
21
+ end
22
+ end
23
+
24
+ def process_commands_with(*command_processors)
25
+ command_processors.each do |command_processor|
26
+ @command_processors << command_processor
27
+ end
28
+ end
29
+
30
+ def filter_commands_with(*command_filters)
31
+ command_filters.each do |command_filter|
32
+ @command_filters << command_filter
33
+ end
34
+ end
35
+
36
+ def execute_with(storage_backend)
37
+ DomainExecutor.new(storage_backend, self)
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative './aggregate_repository'
4
+ require_relative './command_execution'
5
+
6
+ module Aggregates
7
+ # Combines a storage backend and a domain in order to execute that domain.
8
+ class DomainExecutor
9
+ attr_reader :storage_backend, :domain
10
+
11
+ def initialize(storage_backend, domain)
12
+ @aggregate_repository = AggregateRepository.new(storage_backend)
13
+ @dispatcher = CommandDispatcher.new(domain.command_processors, domain.command_filters)
14
+ @storage_backend = storage_backend
15
+ end
16
+
17
+ def execute_command(command)
18
+ command.validate!
19
+ command_execution = CommandExecution.new(@aggregate_repository, command)
20
+ dispatch(command_execution)
21
+ end
22
+
23
+ def audit(type, aggregate_id)
24
+ Auditor.new(@storage_backend, type, aggregate_id)
25
+ end
26
+
27
+ private
28
+
29
+ def store_command(command)
30
+ @storage_backend.store_command(command)
31
+ end
32
+
33
+ def dispatch(command_execution)
34
+ result = @dispatcher.execute_command(command_execution)
35
+ store_command(command_execution.command) if result
36
+ result
37
+ end
38
+ end
39
+ end
@@ -1,23 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'dry-struct'
4
-
5
3
  module Aggregates
6
4
  # The DomainMessage is not a class that should generally be interacted with unless
7
5
  # extending Aggregates itself. It provides some core functionality that message types
8
6
  # (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)
7
+ class DomainMessage < DomainObject
8
+ def initialize(attributes = {})
9
+ super(attributes.merge({ message_id: Aggregates.new_message_id, created_at: Time.now }))
17
10
  end
18
11
 
19
- def self.json_create(arguments)
20
- new arguments
21
- end
12
+ attribute :aggregate_id
13
+ attribute :message_id
14
+ attribute :created_at
15
+ validates_presence_of :aggregate_id, :message_id, :created_at
22
16
  end
23
17
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_model'
4
+
5
+ module Aggregates
6
+ # Defines an object that is an element of the domain.
7
+ class DomainObject
8
+ include ActiveModel::Model
9
+ include ActiveModel::Validations
10
+ include ActiveModel::Attributes
11
+
12
+ validate :validate_nested_fields
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
+
23
+ protected
24
+
25
+ def add_nested_errors_for(attribute, other_validator)
26
+ nested_errors = other_validator.errors
27
+ errors.messages[attribute] = nested_errors.messages
28
+ errors.details[attribute] = nested_errors.details
29
+ end
30
+
31
+ def validate_nested_fields
32
+ attributes.each do |key, value|
33
+ add_nested_errors_for(key.to_sym, value) if value.is_a?(DomainObject) && !value.valid?
34
+ end
35
+ end
36
+ end
37
+ end
@@ -6,6 +6,7 @@ module Aggregates
6
6
  # For instance, if the user's email has changed, then you might create an event type called
7
7
  # UserEmailChanged.
8
8
  class Event < DomainMessage
9
- attribute :sequence_number, Types::Integer
9
+ attribute :sequence_number
10
+ validates_presence_of :sequence_number
10
11
  end
11
12
  end
@@ -7,9 +7,6 @@ module Aggregates
7
7
  # for your application.
8
8
  class EventProcessor
9
9
  include MessageProcessor
10
-
11
- def process_event(event)
12
- handle_message event
13
- end
10
+ alias process_event handle_message
14
11
  end
15
12
  end
@@ -6,20 +6,33 @@ module Aggregates
6
6
  #
7
7
  # There is likely no need to interact with this class directly.
8
8
  class EventStream
9
- def initialize(aggregate_id)
9
+ def initialize(storage_backend, event_processors, aggregate_id)
10
+ @storage_backend = storage_backend
11
+ @event_processors = event_processors
10
12
  @aggregate_id = aggregate_id
11
- @config = Configuration.instance
12
13
  end
13
14
 
14
- def load_events
15
- @config.storage_backend.load_events_by_aggregate_id(@aggregate_id)
15
+ def load_events(ending_at: nil)
16
+ events = @storage_backend.load_events_by_aggregate_id(@aggregate_id)
17
+ events = events.select { |event| event.created_at <= ending_at } if ending_at.present?
18
+ events
16
19
  end
17
20
 
18
21
  def publish(event)
19
- @config.event_processors.each do |event_processor|
20
- event_processor.process_event event
22
+ send_to_event_processors(event)
23
+ store_event(event)
24
+ end
25
+
26
+ private
27
+
28
+ def send_to_event_processors(event)
29
+ @event_processors.each do |event_processor|
30
+ event_processor.process_event(event)
21
31
  end
22
- @config.storage_backend.store_event event
32
+ end
33
+
34
+ def store_event(event)
35
+ @storage_backend.store_event(event)
23
36
  end
24
37
  end
25
38
  end
@@ -5,7 +5,7 @@ module Aggregates
5
5
  module Identity
6
6
  NAME = 'aggregates'
7
7
  LABEL = 'Aggregates'
8
- VERSION = '0.2.0'
8
+ VERSION = '0.3.0'
9
9
  VERSION_LABEL = "#{LABEL} #{VERSION}"
10
10
  end
11
11
  end
@@ -13,13 +13,11 @@ module Aggregates
13
13
  end
14
14
 
15
15
  def store_command(command)
16
- commands_for_aggregate_id = load_commands_by_aggregate_id(command.aggregate_id)
17
- commands_for_aggregate_id << command
16
+ load_commands_by_aggregate_id(command.aggregate_id) << command
18
17
  end
19
18
 
20
19
  def store_event(event)
21
- event_for_aggregate_id = load_events_by_aggregate_id(event.aggregate_id)
22
- event_for_aggregate_id << event
20
+ load_events_by_aggregate_id(event.aggregate_id) << event
23
21
  end
24
22
 
25
23
  def load_events_by_aggregate_id(aggregate_id)
@@ -27,7 +27,7 @@ module Aggregates
27
27
  host_class.extend(ClassMethods)
28
28
  end
29
29
 
30
- def find_message_handlers(message, &block)
30
+ def with_message_handlers(message, &block)
31
31
  search_class = message.class
32
32
  while search_class != DomainMessage
33
33
  handlers = self.class.message_mapping[search_class]
@@ -36,10 +36,16 @@ module Aggregates
36
36
  end
37
37
  end
38
38
 
39
- def handle_message(message)
40
- find_message_handlers.map do |handler|
41
- instance_exec(message, &handler)
39
+ def invoke_handlers(message, *additional_args)
40
+ results = []
41
+ with_message_handlers(message) do |handler|
42
+ results << instance_exec(message, *additional_args, &handler)
42
43
  end
44
+ results
45
+ end
46
+
47
+ def handle_message(message)
48
+ invoke_handlers(message)
43
49
  end
44
50
  end
45
51
  end
@@ -1,9 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'dry-struct'
4
-
5
3
  module Aggregates
6
- module Types
7
- include Dry.Types
4
+ class ValueObject < DomainObject
8
5
  end
9
6
  end
metadata CHANGED
@@ -1,57 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: aggregates
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Zach Probst
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-07-09 00:00:00.000000000 Z
11
+ date: 2021-07-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
- name: dry-struct
14
+ name: activemodel
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '1.0'
19
+ version: '6.1'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
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'
26
+ version: '6.1'
55
27
  description:
56
28
  email:
57
29
  - zprobst@resilientvitality.com
@@ -64,16 +36,19 @@ files:
64
36
  - LICENSE.md
65
37
  - README.md
66
38
  - lib/aggregates.rb
39
+ - lib/aggregates/aggregate_repository.rb
67
40
  - lib/aggregates/aggregate_root.rb
68
41
  - lib/aggregates/auditor.rb
69
42
  - lib/aggregates/command.rb
70
43
  - lib/aggregates/command_dispatcher.rb
44
+ - lib/aggregates/command_execution.rb
71
45
  - lib/aggregates/command_filter.rb
72
46
  - lib/aggregates/command_processor.rb
73
47
  - lib/aggregates/command_validation_error.rb
74
- - lib/aggregates/configuration.rb
48
+ - lib/aggregates/domain.rb
49
+ - lib/aggregates/domain_executor.rb
75
50
  - lib/aggregates/domain_message.rb
76
- - lib/aggregates/dynamoid/dynamoid_storage_backend.rb
51
+ - lib/aggregates/domain_object.rb
77
52
  - lib/aggregates/event.rb
78
53
  - lib/aggregates/event_processor.rb
79
54
  - lib/aggregates/event_stream.rb
@@ -81,8 +56,7 @@ files:
81
56
  - lib/aggregates/in_memory_storage_backend.rb
82
57
  - lib/aggregates/message_processor.rb
83
58
  - lib/aggregates/storage_backend.rb
84
- - lib/aggregates/types.rb
85
- - lib/aggregates/with_aggregate_helpers.rb
59
+ - lib/aggregates/value_object.rb
86
60
  homepage: https://github.com/resilient-vitality/aggregates
87
61
  licenses:
88
62
  - MIT
@@ -1,37 +0,0 @@
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
@@ -1,73 +0,0 @@
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
@@ -1,22 +0,0 @@
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