Class: Yes::Core::Aggregate

Inherits:
Object
  • Object
show all
Includes:
Draftable, HasAuthorizer, HasReadModel
Defined in:
lib/yes/core/aggregate.rb,
lib/yes/core/aggregate/draftable.rb,
lib/yes/core/aggregate/has_authorizer.rb,
lib/yes/core/aggregate/has_read_model.rb,
lib/yes/core/aggregate/dsl/command_data.rb,
lib/yes/core/aggregate/dsl/attribute_data.rb,
lib/yes/core/aggregate/dsl/command_definer.rb,
lib/yes/core/aggregate/read_model_rebuilder.rb,
lib/yes/core/aggregate/dsl/attribute_definer.rb,
lib/yes/core/aggregate/dsl/constant_resolver.rb,
lib/yes/core/aggregate/dsl/class_resolvers/base.rb,
lib/yes/core/aggregate/dsl/class_name_convention.rb,
lib/yes/core/aggregate/shared_read_model_rebuilder.rb,
lib/yes/core/aggregate/dsl/command_shortcut_expander.rb,
lib/yes/core/aggregate/dsl/class_resolvers/authorizer.rb,
lib/yes/core/aggregate/dsl/class_resolvers/read_model.rb,
lib/yes/core/aggregate/dsl/attribute_definers/standard.rb,
lib/yes/core/aggregate/dsl/attribute_definers/aggregate.rb,
lib/yes/core/aggregate/dsl/class_resolvers/command/base.rb,
lib/yes/core/aggregate/dsl/method_definers/command/base.rb,
lib/yes/core/aggregate/dsl/class_resolvers/command/event.rb,
lib/yes/core/aggregate/dsl/method_definers/attribute/base.rb,
lib/yes/core/aggregate/dsl/class_resolvers/command/command.rb,
lib/yes/core/aggregate/dsl/method_definers/command/command.rb,
lib/yes/core/aggregate/dsl/class_resolvers/read_model_filter.rb,
lib/yes/core/aggregate/dsl/class_resolvers/command/authorizer.rb,
lib/yes/core/aggregate/dsl/method_definers/attribute/accessor.rb,
lib/yes/core/aggregate/dsl/method_definers/command/can_command.rb,
lib/yes/core/aggregate/dsl/class_resolvers/command/state_updater.rb,
lib/yes/core/aggregate/dsl/class_resolvers/read_model_serializer.rb,
lib/yes/core/aggregate/dsl/class_resolvers/command/guard_evaluator.rb,
lib/yes/core/aggregate/dsl/class_resolvers/command/cerbos_authorizer.rb,
lib/yes/core/aggregate/dsl/class_resolvers/command/simple_authorizer.rb,
lib/yes/core/aggregate/dsl/class_resolvers/command/authorizer_factory.rb,
lib/yes/core/aggregate/dsl/method_definers/attribute/aggregate_accessor.rb

Overview

The Aggregate class represents a core entity in the eventsourcing system. It provides functionality for managing event sourcing patterns including:

  • Attribute management with automatic command and event generation

  • Parent-child aggregate relationships

  • Read model associations

  • Context management

Examples:

Define an aggregate with attributes

class UserAggregate < Yes::Core::Aggregate
  primary_context 'Users'
  attribute :email, :email
  attribute :name, :string
end

Define an aggregate with a parent

class ProfileAggregate < Yes::Core::Aggregate
  parent :user do
    guard(:user_exists) { payload.user.present? }
    guard(:not_removed) { trashed_at.blank? }
  end
  attribute :bio, :string
end

Define an aggregate with a command

class CompanyAggregate < Yes::Core::Aggregate
  primary_context 'Companies'

  command :assign_user do
    payload user_id: :uuid

    guard :user_already_assigned do
      user_id.present?
    end
  end
end

Author:

  • Nico Ritsche

Since:

  • 0.1.0

Defined Under Namespace

Modules: Draftable, Dsl, HasAuthorizer, HasReadModel Classes: ReadModelRebuilder, SharedReadModelRebuilder

Class Attribute Summary collapse

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Draftable

#draft?, #read_model, #update_read_model

Methods included from HasReadModel

#init_revision_from_stream, #read_model, #rebuild_read_model, #remove_read_model, #revision, #revision_column, #update_read_model

Constructor Details

#initialize(id = SecureRandom.uuid, draft: false) ⇒ Yes::Core::Aggregate

Initializes a new aggregate instance

Examples:

Backwards compatibility - single ID parameter

Aggregate.new(some_id)

With draft as keyword argument

Aggregate.new(draft: true)

With positional id and draft keyword

Aggregate.new(some_id, draft: true)

Parameters:

  • id (String) (defaults to: SecureRandom.uuid)

    The aggregate ID (optional, defaults to SecureRandom.uuid)

  • draft (Boolean) (defaults to: false)

    Whether this instance is being edited as a draft (default: false)

Since:

  • 0.1.0



381
382
383
384
385
386
387
388
389
390
391
392
# File 'lib/yes/core/aggregate.rb', line 381

def initialize(id = SecureRandom.uuid, draft: false)
  validate_draft_initialization(draft)

  @id = id
  @draft = draft

  @command_utilities = Utils::CommandUtils.new(
    context: self.class.context,
    aggregate: self.class.aggregate,
    aggregate_id: @id
  )
end

Class Attribute Details

._primary_contextString? (readonly)

Returns The primary context name for this aggregate.

Returns:

  • (String, nil)

    The primary context name for this aggregate

Since:

  • 0.1.0



54
55
56
# File 'lib/yes/core/aggregate.rb', line 54

def _primary_context
  @_primary_context
end

.removable_configHash{Symbol => Object}? (readonly)

Returns the removable configuration for the aggregate, or nil if removable was never called.

Returns:

  • (Hash{Symbol => Object}, nil)

    hash with two keys when set:

    • ‘:attr_name` [Symbol] — the attribute that marks removal (default `:removed_at`).

    • ‘:not_removed_guards` [Boolean] — whether the auto-block is enabled.

Since:

  • 0.1.0



177
178
179
# File 'lib/yes/core/aggregate.rb', line 177

def removable_config
  @removable_config
end

Instance Attribute Details

#idObject (readonly)

Since:

  • 0.1.0



44
45
46
# File 'lib/yes/core/aggregate.rb', line 44

def id
  @id
end

Class Method Details

.aggregateString

Returns the aggregate name without namespace and “Aggregate” suffix

Examples:

Users::User::Aggregate.aggregate #=> "User"

Returns:

  • (String)

    The aggregate name

Since:

  • 0.1.0



321
322
323
# File 'lib/yes/core/aggregate.rb', line 321

def aggregate
  name.to_s.split('::')[-2]
end

.attribute(name, type, **options) { ... } ⇒ Object

Defines an attribute on the aggregate which creates corresponding command, event and handler

Examples:

Define a string attribute (without command)

attribute :name, :string

Define an aggregate attribute (without command)

attribute :location, :aggregate

Define an email attribute with command

attribute :email, :email, command: true

Define an attribute with command and guards

attribute :first_name, :string, command: true do
  guard :something do
    first_name == 'John'
  end
end

Define a localized attribute

attribute :description, :string, command: true, localized: true

Parameters:

  • name (Symbol)

    name of the attribute

  • type (Symbol)

    type of the attribute (e.g., :string, :email, :uuid)

  • options (Hash)

    additional options for the attribute

Yields:

  • Block for defining guards and other attribute configurations

Yield Returns:

  • (void)

Since:

  • 0.1.0



213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
# File 'lib/yes/core/aggregate.rb', line 213

def attribute(name, type, **options, &)
  raise 'Aggregate attribute definition with command: true is not allowed' if type == :aggregate && options[:command]

  @attributes ||= {}
  @attributes[name] = type

  @attribute_options ||= {}
  @attribute_options[name] = options.slice(:localized)

  options = options.merge(context:, aggregate:)
  Dsl::AttributeDefiner.new(
    Dsl::AttributeData.new(name, type, self, options)
  ).call

  command(:change, name, type, &) if options[:command]
end

.attribute_optionsHash

Returns The attribute options (localized, encrypted, etc.).

Returns:

  • (Hash)

    The attribute options (localized, encrypted, etc.)

Since:

  • 0.1.0



331
332
333
# File 'lib/yes/core/aggregate.rb', line 331

def attribute_options
  @attribute_options ||= {}
end

.attributesHash

Returns The attributes defined on this aggregate.

Returns:

  • (Hash)

    The attributes defined on this aggregate

Since:

  • 0.1.0



326
327
328
# File 'lib/yes/core/aggregate.rb', line 326

def attributes
  @attributes ||= {}
end

.command(name) { ... } ⇒ Object .command(publish) ⇒ void .command(change, attribute, **options) ⇒ void .command(enable, attribute, **options) ⇒ void .command(toggle_names, attribute) ⇒ void

Defines a command on the aggregate which creates corresponding command and event classes

All overloads accept a ‘skip_default_guards:` keyword argument carrying an array of default-guard symbols (currently only `:not_removed` — see removable) that should not be auto-applied to the command. Defaults to `[]`.

Examples:

Define a basic command

command :assign_user

Define a command with custom payload and guards

command :assign_user do
  payload user_id: :uuid

  guard :user_already_assigned do
    user_id.present?
  end

  event :user_assigned
end

Define change command and an attribute

command :change, :age, :integer, localized: true

Define set flag to true command an an attribute

command :activate, :dropout, attribute: :dropout_enabled

Define set of toggle commands an an attribute

command [:enable, :disable], :dropout

Define publish command an published attribute

command :publish

Skip the auto-injected :not_removed guard for a single command

command :restore, skip_default_guards: %i[not_removed] do
  guard(:no_change) { removed_at.present? }
  update_state { removed_at { nil } }
end

Overloads:

  • .command(name) { ... } ⇒ Object

    Parameters:

    • name (Symbol)

      name of the command

    Yields:

    • Block for defining payload, guards, and other command configurations

    Yield Returns:

    • (void)
  • .command(publish) ⇒ void

    This method returns an undefined value.

    Parameters:

    • publish (Symbol)

      passing :publish as a name will generate published attribute and publish command

  • .command(change, attribute, **options) ⇒ void

    This method returns an undefined value.

    Parameters:

    • change (Symbol)

      passing :change as a name will generate a change command and an attribute

    • attribute (Symbol)

      attribute name

    • options (Hash)

      additional options for the attribute

  • .command(enable, attribute, **options) ⇒ void

    This method returns an undefined value.

    Parameters:

    • enable (Symbol)

      passing :enable or :activate as a name will generate a flag set to true command and an attribute

    • attribute (Symbol)

      attribute name

    • options (Hash)

      additional options for the attribute

  • .command(toggle_names, attribute) ⇒ void

    This method returns an undefined value.

    Parameters:

    • toggle_names (Array<Symbol>)

      toggle command names to be generated

    • attribute (Symbol)

      attribute name

Since:

  • 0.1.0



294
295
296
297
298
299
300
301
302
303
304
305
# File 'lib/yes/core/aggregate.rb', line 294

def command(*args, **kwargs, &)
  skip_default_guards = kwargs.delete(:skip_default_guards) || []
  base_case = Dsl::CommandShortcutExpander.base_case?(*args, **kwargs, &)
  return handle_command_shortcut(*args, skip_default_guards:, **kwargs, &) unless base_case

  name = args.first
  @commands ||= {}
  command_data = Dsl::CommandData.new(name, self, { context:, aggregate:, skip_default_guards: })
  @commands[name] = command_data

  Dsl::CommandDefiner.new(command_data).call(&)
end

.commandsHash

Returns The commands defined on this aggregate.

Returns:

  • (Hash)

    The commands defined on this aggregate

Since:

  • 0.1.0



336
337
338
# File 'lib/yes/core/aggregate.rb', line 336

def commands
  @commands ||= {}
end

.contextString

Returns the context namespace for the aggregate

Examples:

Users::User::Aggregate.context #=> "Users"

Returns:

  • (String)

    The context namespace

Since:

  • 0.1.0



312
313
314
# File 'lib/yes/core/aggregate.rb', line 312

def context
  name.to_s.split('::').first
end

.inherited(subclass) ⇒ void

This method returns an undefined value.

Hook that runs when a class inherits from Aggregate

Parameters:

  • subclass (Class)

    The class inheriting from Aggregate

Since:

  • 0.1.0



59
60
61
62
63
64
65
66
67
68
69
70
71
72
# File 'lib/yes/core/aggregate.rb', line 59

def inherited(subclass)
  super

  # Add an "end of definition" hook using at_exit
  # Setting up read model classes is done here, because it needs to be done after
  # the class definition is complete.
  TracePoint.new(:end) do |tp|
    if tp.self == subclass
      subclass.setup_read_model_classes if subclass.read_model_enabled?
      subclass.setup_authorizer_classes
      tp.disable
    end
  end.enable
end

.parent(name, **options) { ... } ⇒ void

This method returns an undefined value.

Defines a parent aggregate and automatically registers a corresponding Assign command together with a corresponding attribute.

Examples:

Skip the auto-injected :not_removed guard on a parent’s assign command

parent :tenant, skip_default_guards: %i[not_removed]

Parameters:

  • name (Symbol)

    The name of the parent.

  • options (Hash)

    Options for configuring the parent.

Options Hash (**options):

  • :command (Boolean) — default: true

    When false, skips defining the ‘assign_<name>` command.

  • :skip_default_guards (Array<Symbol>) — default: []

    Default guards (e.g. ‘:not_removed`) that should not be auto-applied to the generated `assign_<name>` command. See removable for context on the `:not_removed` auto-block.

Yields:

  • Block for defining guards and other attribute configurations.

Since:

  • 0.1.0



88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
# File 'lib/yes/core/aggregate.rb', line 88

def parent(name, **options, &)
  parent_aggregates[name] = options

  attribute name, :aggregate

  return unless options.fetch(:command, true)

  skip_default_guards = options[:skip_default_guards] || []

  command :"assign_#{name}", skip_default_guards: do
    payload "#{name}_id": :uuid

    guard(:no_change) { public_send(:"#{name}_id") != payload.public_send(:"#{name}_id") }

    instance_eval(&) if block_given?
  end
end

.parent_aggregatesHash<Symbol, Hash>

Retrieves or initializes the parent_aggregates hash.

Returns:

  • (Hash<Symbol, Hash>)

    A hash containing parent aggregates and their configuration options

Since:

  • 0.1.0



109
110
111
# File 'lib/yes/core/aggregate.rb', line 109

def parent_aggregates
  @parent_aggregates ||= {}
end

.primary_context(context) ⇒ void

This method returns an undefined value.

Sets the primary context for the aggregate.

Parameters:

  • context (String)

    The primary context to set.

Since:

  • 0.1.0



183
184
185
# File 'lib/yes/core/aggregate.rb', line 183

def primary_context(context)
  @_primary_context = context
end

.removable(attr_name: :removed_at, not_removed_guards: true) { ... } ⇒ void

This method returns an undefined value.

Defines a default removal behavior for the aggregate.

In addition to defining the ‘:remove` command, `removable` records aggregate-level configuration that the CommandHandling::GuardEvaluator reads at runtime to **auto-block every other command on the aggregate** while the removal attribute is set. The auto-block fires before any registered guard (including the auto-injected `:no_change`), so post-remove mutations consistently raise `GuardEvaluator::InvalidTransition` with the i18n message under `aggregates.<context>.<aggregate>.commands.<command>.guards.not_removed.error`. The `:remove` command itself is exempt and remains gated only by `:no_change`.

The auto-block is order-independent: ‘removable` may be declared before or after the other commands on the aggregate.

‘attr_name` must correspond to an attribute readable on the aggregate (the macro auto-defines it as `:datetime` when missing).

Examples:

Define a default removal behavior

class UserAggregate < Yes::Core::Aggregate
  removable
end

Define a removal behavior with additional custom guards

class UserAggregate < Yes::Core::Aggregate
  removable do
    guard(:exists) { read_model.name.present? }
  end
end

Define a removal behavior with a custom attribute name

class UserAggregate < Yes::Core::Aggregate
  removable(attr_name: :deleted_at)
end

Disable the :not_removed auto-block aggregate-wide

class UserAggregate < Yes::Core::Aggregate
  removable(not_removed_guards: false)
end

Parameters:

  • attr_name (Symbol) (defaults to: :removed_at)

    the attribute name to use for marking removal

  • not_removed_guards (Boolean) (defaults to: true)

    when true (default), every non-‘:remove` command on the aggregate auto-blocks while `attr_name` is set. Pass `false` to disable the auto-block aggregate-wide; individual commands can still opt in by defining their own `guard(:not_removed)`.

Yields:

  • Block for defining additional guards and other removal configurations

Since:

  • 0.1.0



160
161
162
163
164
165
166
167
168
169
# File 'lib/yes/core/aggregate.rb', line 160

def removable(attr_name: :removed_at, not_removed_guards: true, &)
  attribute attr_name, :datetime unless attributes.key?(attr_name)
  @removable_config = { attr_name:, not_removed_guards: }

  command :remove, skip_default_guards: %i[not_removed] do
    guard(:no_change) { !public_send(attr_name) }
    update_state { method(attr_name).call { Time.current } }
    instance_eval(&) if block_given?
  end
end

Instance Method Details

#commandsHash<Symbol, Array<Symbol>>

Returns a list of commands that can be executed on this aggregate with their associated events

Examples:

user_aggregate.commands
# => {
#   approve_documents: [:documents_approved],
#   change_age: [:age_changed],
#   change_email: [:email_changed],
#   change_name: [:name_changed]
# }

Returns:

  • (Hash<Symbol, Array<Symbol>>)

    A hash of command names to their event names, sorted alphabetically

Since:

  • 0.1.0



435
436
437
438
439
440
441
442
# File 'lib/yes/core/aggregate.rb', line 435

def commands
  mappings = Yes::Core.configuration.command_event_mappings(
    self.class.context,
    self.class.aggregate
  )

  mappings.sort.to_h
end

#event_revisionInteger

Returns the stream revision number of the latest event

Returns:

  • (Integer)

    The revision number of the latest event in the stream

Raises:

  • (NoMethodError)

    If no events exist for this aggregate

Since:

  • 0.1.0



421
422
423
# File 'lib/yes/core/aggregate.rb', line 421

def event_revision
  latest_event.stream_revision
end

#eventsEnumerator<PgEventstore::Event>

Returns the events for the aggregate

Returns:

  • (Enumerator<PgEventstore::Event>)

    The events for the aggregate

Since:

  • 0.1.0



404
405
406
407
408
# File 'lib/yes/core/aggregate.rb', line 404

def events
  PgEventstore.client.read_paginated(
    command_utilities.build_stream(metadata: { draft: draft? }), options: { direction: 'Forwards' }
  )
end

#latest_eventPgEventstore::Event?

Retrieves the most recent event from the aggregate’s event stream

Returns:

  • (PgEventstore::Event, nil)

    The latest event or nil if no events exist

Since:

  • 0.1.0



412
413
414
415
416
# File 'lib/yes/core/aggregate.rb', line 412

def latest_event
  PgEventstore.client.read(
    command_utilities.build_stream(metadata: { draft: draft? }), options: { max_count: 1, direction: :desc }
  ).first
end

#reloadYes::Core::Aggregate

Reloads the aggregate and its read model

Returns:

Since:

  • 0.1.0



396
397
398
399
400
# File 'lib/yes/core/aggregate.rb', line 396

def reload
  read_model&.reload

  self
end