yes 0.0.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/README.md ADDED
@@ -0,0 +1,2256 @@
1
+ # Yes
2
+
3
+ Yes is a framework for building event-sourced systems, originally developed to power Switzerland's leading apprenticeship platform [yousty.ch](https://www.yousty.ch/de-CH) and its younger sibling [professional.ch](https://www.professional.ch/). It is designed to be used within Rails applications and relies on [PgEventstore](https://github.com/yousty/pg_eventstore) for event storage, which provides a robust PostgreSQL-based event store implementation.
4
+
5
+ ## Table of Contents
6
+
7
+ - [Quick Start](#quick-start)
8
+ - [Naming Conventions](#naming-conventions)
9
+ - [Aggregate DSL](#aggregate-dsl)
10
+ - [attribute](#attribute)
11
+ - [command](#command)
12
+ - [Attribute Details](#attribute-details)
13
+ - [Command Details](#command-details)
14
+ - [Guards](#guards)
15
+ - [Command Groups](#command-groups)
16
+ - [Read Models](#read-models)
17
+ - [Parent Aggregates](#parent-aggregates)
18
+ - [Primary Context](#primary-context)
19
+ - [Removable](#removable)
20
+ - [Draftable](#draftable)
21
+ - [Authorization](#authorization)
22
+ - [Auth Adapter](#auth-adapter)
23
+ - [Aggregate Authorization](#aggregate-authorization)
24
+ - [Command Authorization](#command-authorization)
25
+ - [Cerbos Authorization](#cerbos-authorization)
26
+ - [Command API](#command-api)
27
+ - [Command API Installation](#command-api-installation)
28
+ - [Request Format](#request-format)
29
+ - [Command Class Resolution](#command-class-resolution)
30
+ - [Processing Pipeline](#processing-pipeline)
31
+ - [Using Commands Without the DSL](#using-commands-without-the-dsl)
32
+ - [Real-Time Command Notifications](#real-time-command-notifications)
33
+ - [Read API](#read-api)
34
+ - [Read API Installation](#read-api-installation)
35
+ - [Basic Queries](#basic-queries)
36
+ - [Advanced Queries](#advanced-queries)
37
+ - [Filters](#filters)
38
+ - [Read API Authorization](#read-api-authorization)
39
+ - [Serializers](#serializers)
40
+ - [Event Processing](#event-processing)
41
+ - [Subscriptions](#subscriptions)
42
+ - [Process Managers](#process-managers)
43
+ - [Configuration Reference](#configuration-reference)
44
+ - [Testing](#testing)
45
+ - [Aggregate Test DSL](#aggregate-test-dsl)
46
+ - [DSL Methods](#dsl-methods)
47
+ - [Command Group Test DSL](#command-group-test-dsl)
48
+ - [Event Helpers](#event-helpers)
49
+ - [Aggregate Matchers](#aggregate-matchers)
50
+ - [Development](#development)
51
+ - [Example Usage](#example-usage)
52
+ - [Testing the APIs](#testing-the-apis)
53
+ - [Running Specs](#running-specs)
54
+ - [Gem Installation and Release](#gem-installation-and-release)
55
+ - [Contributing](#contributing)
56
+
57
+ ## Quick Start
58
+
59
+ ### Installation
60
+
61
+ Add this line to your application's Gemfile to pull in the whole framework (`yes-core`, `yes-auth`, `yes-command-api`, `yes-read-api`):
62
+
63
+ ```ruby
64
+ gem 'yes'
65
+ ```
66
+
67
+ Or depend on individual sub-gems if you only need parts of the framework:
68
+
69
+ ```ruby
70
+ gem 'yes-core' # aggregate DSL, events, read models
71
+ gem 'yes-auth' # authorization principals + Cerbos integration
72
+ gem 'yes-command-api' # HTTP command endpoint
73
+ gem 'yes-read-api' # HTTP read endpoints
74
+ ```
75
+
76
+ Then execute:
77
+ ```bash
78
+ bundle install
79
+ ```
80
+
81
+ > **Note on the gem name:** versions `0.0.x` of `yes` on RubyGems were an unrelated project (a small CLI). Starting with `1.x` the gem name belongs to this framework. If you previously had `gem 'yes'` in a Gemfile and want the old project, pin to `'< 1.0'`.
82
+
83
+ ### Basic Usage
84
+
85
+ At the core of Yes is the `Yes::Core::Aggregate` class, which provides a DSL for defining event-sourced aggregates:
86
+
87
+ ```ruby
88
+ module Users
89
+ module User
90
+ class Aggregate < Yes::Core::Aggregate
91
+ # Link to a parent aggregate — generates an `assign_company` command
92
+ # and a `company_id` attribute automatically
93
+ parent :company
94
+
95
+ # `change` commands are the most common — use the `:change` shortcut
96
+ command :change, :name # the type defaults to :string
97
+ command :change, :email, :email
98
+
99
+ attribute :email_confirmed, :boolean
100
+
101
+ # A custom command: its own payload, guard, and state update
102
+ command :confirm_email do
103
+ payload token: :string
104
+
105
+ guard :token_valid do
106
+ EmailConfirmationService.valid?(payload.token, id)
107
+ end
108
+
109
+ update_state do
110
+ email_confirmed { true }
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
116
+
117
+ # Usage
118
+ user = Users::User::Aggregate.new
119
+ user.assign_company(company_id: "123e4567-e89b-12d3-a456-426614174000")
120
+ user.change_name("John Doe")
121
+ user.confirm_email(token: "abc123")
122
+ ```
123
+
124
+ See [Command shortcuts](#command-shortcuts) for the full list of shortcut forms (`:change`, `:enable`/`:disable`, `:activate`, `:publish`, …).
125
+
126
+ ## Naming Conventions
127
+
128
+ When defining an aggregate, use the following namespacing pattern:
129
+ `<Context>::<AggregateName>::Aggregate`
130
+
131
+ For example: `Users::User::Aggregate` or `Companies::Company::Aggregate`
132
+
133
+ ## Aggregate DSL
134
+
135
+ ### `attribute`
136
+
137
+ The `attribute` method defines properties of your aggregate:
138
+
139
+ ```ruby
140
+ module Users
141
+ module User
142
+ class Aggregate < Yes::Core::Aggregate
143
+ # Plain attributes — accessors only, no change command
144
+ attribute :name, :string
145
+ attribute :email, :email
146
+ attribute :company_id, :uuid
147
+ end
148
+ end
149
+ end
150
+ ```
151
+
152
+ Plain `attribute` declarations define accessors on the aggregate and columns on the read model. They do **not** generate a change command.
153
+
154
+ To generate a change command along with the attribute, use the [`command :change` shortcut](#change-command-with-attribute):
155
+
156
+ ```ruby
157
+ command :change, :age, :integer
158
+ command :change, :bio, :string
159
+ ```
160
+
161
+ ### `command`
162
+
163
+ The `command` method defines custom operations on your aggregate:
164
+
165
+ ```ruby
166
+ module Companies
167
+ module Company
168
+ class Aggregate < Yes::Core::Aggregate
169
+ # Define attributes that will be updated by the command
170
+ attribute :user_ids, :uuids
171
+
172
+ command :assign_user do
173
+ # Define payload attributes
174
+ payload user_id: :uuid
175
+
176
+ guard :user_not_already_assigned do
177
+ !user_ids.include?(payload.user_id)
178
+ end
179
+
180
+ # Custom state update logic
181
+ update_state do
182
+ user_ids { (user_ids || []) + [payload.user_id] }
183
+ end
184
+ end
185
+ end
186
+ end
187
+ end
188
+ ```
189
+
190
+ ### Attribute Details
191
+
192
+ Attributes are the core properties of your aggregates.
193
+
194
+ #### Available Types
195
+
196
+ The attribute system supports various types:
197
+ - `:string` - Text values
198
+ - `:email` - Email addresses with validation
199
+ - `:uuid` - UUID values
200
+ - `:integer` - Numeric values
201
+ - `:boolean` - True/false values
202
+ - `:date` - Date values
203
+ - `:uuids` - Arrays of UUIDs
204
+
205
+ For the complete list, see [yes-core/lib/yes/core/type_lookup.rb](yes-core/lib/yes/core/type_lookup.rb)
206
+
207
+ #### Custom Types
208
+
209
+ You can register application-specific types using the type registry:
210
+
211
+ ```ruby
212
+ # config/initializers/yes_types.rb
213
+ Yes::Core::Types.register(:subscription_type, Yes::Core::Types::String.enum('premium', 'basic'))
214
+ Yes::Core::Types.register(:team_role, Yes::Core::Types::String.enum('lead', 'member'))
215
+ Yes::Core::Types.register(:training_year, Yes::Core::Types::Coercible::Integer.constrained(gteq: 1, lteq: 4))
216
+ ```
217
+
218
+ Registered types can then be used in aggregate definitions:
219
+
220
+ ```ruby
221
+ attribute :role, :team_role
222
+ # or, to also generate a change command:
223
+ command :change, :role, :team_role
224
+ ```
225
+
226
+ #### Attribute Commands
227
+
228
+ When you generate a change command for an attribute (via the [`command :change` shortcut](#change-command-with-attribute), or the legacy `attribute ..., command: true` option), Yes generates:
229
+
230
+ ##### `change_<attribute>` Method
231
+
232
+ Changes the attribute's value through an event:
233
+
234
+ ```ruby
235
+ user.change_age(30)
236
+ user.change_bio("Software developer")
237
+ ```
238
+
239
+ You can also pass parameters as a hash:
240
+
241
+ ```ruby
242
+ user.change_age(age: 30)
243
+ ```
244
+
245
+ ##### `can_change_<attribute>?` Method
246
+
247
+ Validates a potential change without applying it:
248
+
249
+ ```ruby
250
+ # Valid change
251
+ if user.can_change_email?("user@example.com")
252
+ user.change_email("user@example.com")
253
+ end
254
+
255
+ # Invalid change
256
+ user.can_change_email?("invalid-email") # => false
257
+ user.email_change_error # Contains the error message
258
+ ```
259
+
260
+ ### Command Details
261
+
262
+ Commands define operations that can be performed on your aggregate.
263
+
264
+ #### Command Configuration Options
265
+
266
+ ##### Payload
267
+
268
+ Define the input data for your command:
269
+
270
+ ```ruby
271
+ command :register_apprenticeship do
272
+ payload title: :string,
273
+ start_date: :date,
274
+ location_id: :uuid
275
+ end
276
+ ```
277
+
278
+ Make sure the payload keys are all defined as attributes on the aggregate if you don't supply an `update_state` block.
279
+
280
+ **Optional and Nullable Attributes**
281
+
282
+ You can mark payload attributes as optional (key can be omitted) or nullable (value can be nil) using hash syntax:
283
+
284
+ ```ruby
285
+ command :update_profile do
286
+ # Optional key - attribute can be omitted from payload
287
+ payload phone: { type: :string, optional: true },
288
+ # Nullable value - attribute must be present but can be nil
289
+ max_travel_time: { type: :integer, nullable: true },
290
+ # Both optional key and nullable value
291
+ email: { type: :email, optional: true, nullable: true }
292
+ end
293
+ ```
294
+
295
+ - `optional: true` - The key can be omitted from the command payload (for commands) or event data (for events)
296
+ - `nullable: true` - The value can be `nil` (wraps the type with `.maybe` for commands, uses `.maybe()` for events)
297
+
298
+ **Note**: For commands, nullable attributes are automatically unwrapped from `Dry::Monads::Maybe::Some/None` when accessing `command.payload` to ensure compatibility with event creation.
299
+
300
+ ##### Guards
301
+
302
+ Add validation rules with guards:
303
+
304
+ ```ruby
305
+ command :publish do
306
+ guard :all_required_fields_present do
307
+ title.present? && description.present?
308
+ end
309
+
310
+ guard :not_already_published do
311
+ !published
312
+ end
313
+ end
314
+ ```
315
+
316
+ ##### Custom Event Names
317
+
318
+ Customize the generated event name:
319
+
320
+ ```ruby
321
+ command :publish do
322
+ event :apprenticeship_published
323
+ end
324
+ ```
325
+
326
+ When no custom event name is provided, *Yes* automatically generates an event name based on the command name. Currently, only standard command prefixes are supported. If you use a command that doesn't start with a supported prefix, you must specify the event name explicitly. For a list of supported prefixes, see [lib/yes/core/utils/event_name_resolver.rb](yes-core/lib/yes/core/utils/event_name_resolver.rb).
327
+
328
+ ##### Encrypting Event Payload Attributes
329
+
330
+ Yes supports encrypting sensitive data in events. You can mark payload attributes for encryption using three approaches:
331
+
332
+ **1. Inline Encryption Declaration (Recommended for mixed payloads)**
333
+
334
+ ```ruby
335
+ command :update_contact_info do
336
+ payload email: { type: :email, encrypt: true },
337
+ phone: { type: :phone, encrypt: true },
338
+ address: :string # not encrypted
339
+ end
340
+ ```
341
+
342
+ **2. Separate `encrypt` Method (Recommended for multiple encrypted fields)**
343
+
344
+ ```ruby
345
+ command :update_sensitive_data do
346
+ payload ssn: :string, email: :email, phone: :phone
347
+ encrypt :ssn, :email, :phone
348
+ end
349
+ ```
350
+
351
+ **3. Command Shortcut with `encrypt` Option**
352
+
353
+ ```ruby
354
+ # For simple attribute commands
355
+ command :change, :ssn, :string, encrypt: true
356
+ ```
357
+
358
+ **Important Notes:**
359
+ - Encryption applies to the event payload stored in the event store, not to the aggregate state or read models
360
+ - Encrypted attributes are tracked in the generated event class via an `encryption_schema` class method
361
+ - You can combine inline and separate encryption declarations in the same command
362
+ - The encryption key is automatically derived from the aggregate ID
363
+
364
+ ###### Required Setup: Key Repository
365
+
366
+ Encryption is performed by a PgEventstore middleware that delegates the actual key management and cryptography to a `key_repository` object you provide. *Yes* does not ship a concrete implementation — you plug in any object that satisfies the interface below.
367
+
368
+ Register the middleware:
369
+
370
+ ```ruby
371
+ PgEventstore.configure do |config|
372
+ config.middlewares[:encryptor] = Yes::Core::Middlewares::Encryptor.new(key_repository)
373
+ end
374
+ ```
375
+
376
+ The `key_repository` must respond to the following methods, each returning a [`Dry::Monads::Result`](https://dry-rb.org/gems/dry-monads/) (or any object responding to `success?`, `failure?`, and `value!`):
377
+
378
+ | Method | Purpose | Returns (on success) |
379
+ | --- | --- | --- |
380
+ | `find(key_id)` | Look up an existing key by its identifier (the aggregate ID). | A key object responding to `attributes[:iv]`. |
381
+ | `create(key_id)` | Create a new key for the given identifier. Called when `find` returns a failure. | A key object responding to `attributes[:iv]`. |
382
+ | `encrypt(key:, message:)` | Encrypt the serialized JSON of the attributes marked for encryption. | An object responding to `attributes[:message]` containing the ciphertext. |
383
+ | `decrypt(key:, message:)` | Decrypt a previously encrypted payload. | An object responding to `attributes[:message]` containing the plaintext JSON. |
384
+
385
+ This interface intentionally decouples *Yes* from any specific key management or crypto backend. You can back it with AWS KMS, HashiCorp Vault, libsodium, ActiveRecord::Encryption, or any other solution that fits your deployment.
386
+
387
+ For a minimal reference implementation used in the test suite (Base64 + in-memory store, not production-ready), see [`yes-core/spec/support/dummy_repository.rb`](yes-core/spec/support/dummy_repository.rb).
388
+
389
+ ##### Custom State Updates
390
+
391
+ Define exactly how state should change:
392
+
393
+ ```ruby
394
+ command :add_tag do
395
+ payload tag: :string
396
+
397
+ update_state do
398
+ tags { (tags || []) + [payload.tag] }
399
+ end
400
+ end
401
+ ```
402
+
403
+ You can also use the `update_state` method to update multiple attributes at once:
404
+
405
+ ```ruby
406
+ update_state do
407
+ name { payload.name }
408
+ email { payload.email }
409
+ end
410
+ ```
411
+
412
+ Make sure the attributes updated in the `update_state` block are all defined on the aggregate.
413
+
414
+ For commands whose work is side-effect-only (e.g. writing to a related ActiveRecord model) and does not assign to any aggregate attribute, use `update_state custom: true` — see [Side-Effect State Updates](#3-side-effect-state-updates-update_state-custom-true).
415
+
416
+ #### State Update Behavior
417
+
418
+ Commands update the aggregate state in one of two ways:
419
+
420
+ ##### 1. Automatic State Updates (Without `update_state` Block)
421
+
422
+ If you don't define an `update_state` block, the command will automatically update the aggregate's attributes based on the payload:
423
+
424
+ ```ruby
425
+ module Companies
426
+ module Company
427
+ class Aggregate < Yes::Core::Aggregate
428
+ # Define attributes that match the payload keys
429
+ attribute :name, :string
430
+ attribute :description, :string
431
+
432
+ command :update_details do
433
+ # Payload keys must match attribute names
434
+ payload name: :string,
435
+ description: :string
436
+ # No update_state block needed - automatic update
437
+ end
438
+ end
439
+ end
440
+ end
441
+
442
+ company = Companies::Company::Aggregate.new
443
+ company.update_details(name: "Acme Inc", description: "Manufacturing company")
444
+ # Both name and description attributes will be updated automatically
445
+ ```
446
+
447
+ **Important**: When not using an `update_state` block:
448
+ - All payload keys must be defined as attributes on the aggregate
449
+ - The system will validate this and raise an error if there's a mismatch
450
+ - The attribute values will be updated directly from the payload values
451
+
452
+ ##### 2. Custom State Updates (With `update_state` Block)
453
+
454
+ When you define an `update_state` block, you have complete control over how attributes are updated:
455
+
456
+ ```ruby
457
+ module Articles
458
+ module Article
459
+ class Aggregate < Yes::Core::Aggregate
460
+ attribute :title, :string
461
+ attribute :tags, :array
462
+ attribute :status, :string
463
+
464
+ command :publish do
465
+ payload title: :string
466
+
467
+ update_state do
468
+ # You can reference payload values
469
+ title { payload.title }
470
+ # Or set static values
471
+ status { "published" }
472
+ # Or combine existing data with payload
473
+ tags { (tags || []) + ["published"] }
474
+ end
475
+ end
476
+ end
477
+ end
478
+ end
479
+ ```
480
+
481
+ **Important**: When using an `update_state` block:
482
+ - Payload keys don't need to match attribute names
483
+ - However, all attributes updated in the block must be defined on the aggregate
484
+ - The system will validate this and raise an error if an undefined attribute is updated
485
+ - You have full control over transformation logic
486
+
487
+ ##### 3. Side-Effect State Updates (`update_state custom: true`)
488
+
489
+ Sometimes a command needs to perform work that cannot be expressed as attribute assignments on the aggregate — for example, creating or updating related ActiveRecord records, writing to an associated read model, or otherwise producing side effects outside the aggregate itself. In those cases, pass `custom: true` to `update_state`:
490
+
491
+ ```ruby
492
+ command :assign_ambassador do
493
+ payload team_member_id: :uuid
494
+
495
+ update_state custom: true do
496
+ attrs = { team_member_id: payload.team_member_id, apprenticeship_id: id }
497
+ ApprenticeshipTeamMemberAssociation.find_or_create_by(attrs).update(removed_at: nil)
498
+ end
499
+ end
500
+ ```
501
+
502
+ The `custom: true` flag changes how Yes treats the block:
503
+
504
+ - The block analyzer is skipped, so Yes does not scan it for attribute assignments like `name { payload.name }`.
505
+ - No aggregate attributes are recorded as updated by this command.
506
+ - You are responsible for performing whatever side effects the command needs — Yes will not validate or track what happens inside.
507
+
508
+ This also works with the [`command :change` shortcut](#change-command-with-attribute) when you want to override the default attribute assignment with custom side-effect logic:
509
+
510
+ ```ruby
511
+ command :change, :status do # `:string` is the default type, so it can be omitted
512
+ update_state custom: true do
513
+ StatusRecord.find_or_create_by(aggregate_id: id).update(status: payload.status)
514
+ end
515
+ end
516
+ ```
517
+
518
+ Prefer regular `update_state` blocks whenever the change can be expressed as attribute updates on the aggregate — reach for `custom: true` only when side effects outside the aggregate are genuinely required.
519
+
520
+ #### Generated Command Methods
521
+
522
+ For each command, Yes generates:
523
+
524
+ ##### Command Method
525
+
526
+ Executes the command:
527
+
528
+ ```ruby
529
+ company.assign_user(user_id: "123e4567-e89b-12d3-a456-426614174000")
530
+ ```
531
+
532
+ ##### Can Command Method
533
+
534
+ Validates if the command would succeed:
535
+
536
+ ```ruby
537
+ if company.can_assign_user?(user_id: "123e4567-e89b-12d3-a456-426614174000")
538
+ company.assign_user(user_id: "123e4567-e89b-12d3-a456-426614174000")
539
+ else
540
+ puts company.assign_user_error
541
+ end
542
+ ```
543
+
544
+ #### Command shortcuts
545
+
546
+ For the most frequently used cases *Yes* DSL allows to use shortcuts in `command` definitions.
547
+
548
+ ##### Change command with attribute
549
+
550
+ ```ruby
551
+ command :change, :age, :integer, localized: true
552
+ ```
553
+
554
+ is expanded to
555
+
556
+ ```ruby
557
+ attribute :age, :integer, localized: true
558
+ command :change_age do
559
+ payload age: :integer, locale: :locale
560
+ guard(:no_change) { value_changed?(send(attribute_name), payload.send(attribute_name)) }
561
+ end
562
+ ```
563
+
564
+ The type defaults to `:string`, so for string attributes it can be omitted:
565
+
566
+ ```ruby
567
+ command :change, :name # equivalent to `command :change, :name, :string`
568
+ ```
569
+
570
+ You can overwrite the default no change guard by providing a custom one:
571
+
572
+ ```ruby
573
+ command :change, :age, :integer do
574
+ payload fantastic_new_age: :integer
575
+ guard(:no_change) { age != payload.fantastic_new_age }
576
+ end
577
+ ```
578
+
579
+ ##### Boolean attribute command
580
+
581
+ `:enable` and `:activate` command names are triggering this shortcut.
582
+
583
+ ```ruby
584
+ command :activate, :dropout, attribute: :dropout_enabled
585
+ ```
586
+
587
+ is expanded to
588
+
589
+ ```ruby
590
+ attribute :dropout_enabled, :boolean
591
+ command :activate_dropout do
592
+ guard(:no_change) { !dropout_enabled }
593
+ update_state { dropout_enabled { true } }
594
+ end
595
+ ```
596
+
597
+ ##### Toggle commands
598
+
599
+ ```ruby
600
+ command [:enable, :disable], :dropout
601
+ ```
602
+
603
+ is expanded to
604
+
605
+ ```ruby
606
+ attribute :dropout, :boolean
607
+ command :enable_dropout do
608
+ guard(:no_change) { !dropout }
609
+ update_state { dropout { true } }
610
+ end
611
+
612
+ command :disable_dropout do
613
+ guard(:no_change) { dropout }
614
+ update_state { dropout { false } }
615
+ end
616
+ ```
617
+
618
+ ##### Publish command
619
+
620
+ ```ruby
621
+ command :publish
622
+ ```
623
+
624
+ is expanded to
625
+
626
+ ```ruby
627
+ attribute :published, :boolean
628
+ command :publish do
629
+ guard(:no_change) { !published }
630
+ update_state { published { true } }
631
+ end
632
+ ```
633
+
634
+ ### Guards
635
+
636
+ Guards are powerful validation mechanisms that enforce business rules by controlling when commands and attribute changes are permitted to execute. They act as gatekeepers that ensure all operations maintain the integrity of your domain logic.
637
+
638
+ #### Default Guards
639
+
640
+ Both commands and attributes automatically include a `:no_change` guard that ensures the aggregate's state would actually change when applying the command. For commands, this default guard is only active when there is no `update_state` block present in the command definition.
641
+
642
+ #### Adding Guards to Attribute Change Commands
643
+
644
+ When defining an attribute with a change command, you can add guards to implement validation by passing a block to the `command :change` shortcut:
645
+
646
+ ```ruby
647
+ command :change, :email, :email do
648
+ guard :check_email_domain do
649
+ payload.email.end_with?('@example.com')
650
+ end
651
+ end
652
+ ```
653
+
654
+ #### Adding Guards to Commands
655
+
656
+ Similarly, you can add guards to commands to control when they can execute:
657
+
658
+ ```ruby
659
+ command :publish do
660
+ guard :all_required_fields_present do
661
+ title.present? && description.present?
662
+ end
663
+
664
+ guard :not_already_published do
665
+ !published
666
+ end
667
+ end
668
+ ```
669
+
670
+ Inside any guard block you can access:
671
+ - `payload` - The command payload with access to both data and metadata
672
+ - Any aggregate attribute directly by name
673
+
674
+ ##### Accessing Metadata in Guards
675
+
676
+ The payload object in guards provides access to command metadata alongside the regular payload data. This metadata can contain useful contextual information like user information, or tracking data.
677
+
678
+ You can access metadata in two ways:
679
+
680
+ ```ruby
681
+ command :update_status do
682
+ payload status: :string
683
+
684
+ guard :valid_response do
685
+ # Method-style access
686
+ payload.metadata.response_id.present?
687
+
688
+ # Hash-style access
689
+ payload.metadata[:response_id].present?
690
+ end
691
+
692
+ guard :authorized_user do
693
+ # If a metadata key doesn't exist, nil is returned
694
+ payload.metadata.user_role == 'admin' # returns nil if user_role is not in metadata
695
+ end
696
+ end
697
+ ```
698
+
699
+ This allows guards to make decisions based on both the command's data payload and any additional contextual metadata that was provided when the command was issued.
700
+
701
+ #### Guard Error Types
702
+
703
+ Guards have two distinct behaviors based on their name:
704
+
705
+ - Guards named `:no_change` trigger a **no-change transition** error when they fail. This indicates that the operation would not modify the aggregate's state.
706
+ - All other guard names trigger an **invalid transition** error when they fail. This indicates that the operation is not allowed in the current state.
707
+
708
+ ```ruby
709
+ command :update_profile do
710
+ payload bio: :string
711
+
712
+ # Will trigger a no-change transition error if bio hasn't changed
713
+ guard :no_change do
714
+ payload.bio != bio
715
+ end
716
+
717
+ # Will trigger an invalid transition error if bio contains prohibited words
718
+ guard :appropriate_content do
719
+ !payload.bio.include?("prohibited content")
720
+ end
721
+ end
722
+ ```
723
+
724
+ #### Custom Error Messages
725
+
726
+ You can provide custom localized error messages for guards using I18n translation files:
727
+
728
+ ```yaml
729
+ # config/locales/en.yml
730
+ en:
731
+ aggregates:
732
+ test: # context
733
+ apprenticeship: # aggregate
734
+ commands:
735
+ change_location: # command
736
+ guards:
737
+ location_published: # guard
738
+ error: "Location is not published"
739
+ company_matches:
740
+ error: "Location company does not match apprenticeship company"
741
+ ```
742
+
743
+ This allows you to define human-readable error messages that can be easily translated to different languages. These messages will be used instead of the default error messages when a guard fails.
744
+
745
+ ### Command Groups
746
+
747
+ A `command_group` is a compound action that runs several existing aggregate commands as a single atomic unit. It's useful when multiple commands are always executed together and the per-command guards would be redundant or too restrictive for the compound flow.
748
+
749
+ ```ruby
750
+ module Companies
751
+ module Apprenticeship
752
+ class Aggregate < Yes::Core::Aggregate
753
+ attribute :name, :string, command: true
754
+ attribute :description, :string, command: true
755
+ parent :company
756
+ parent :user
757
+ draftable
758
+ command :publish
759
+
760
+ command_group :create_apprenticeship do
761
+ command :assign_company
762
+ command :assign_user
763
+ command :change_name
764
+ command :change_description
765
+ command :publish
766
+
767
+ guard(:company_assigned) { payload.company_id.present? }
768
+ guard(:user_assigned) { payload.user_id.present? }
769
+ end
770
+ end
771
+ end
772
+ end
773
+
774
+ aggregate.create_apprenticeship(
775
+ company_id:, user_id:, name:, description:
776
+ )
777
+ # => Yes::Core::Commands::CommandGroupResponse(cmd:, events: [...], error: nil)
778
+ ```
779
+
780
+ **How it works:**
781
+
782
+ - `command :sub_name` inside the block lists existing aggregate commands by symbol. Order is preserved as execution order.
783
+ - `guard(:name) { … }` declares group-level guards using the same DSL as per-command guards. They run against the aggregate's current state at invocation time.
784
+ - Sub-command symbols are resolved lazily — declare the group before or after the individual commands, the framework checks consistency at the end of the class body.
785
+ - When invoked, the group:
786
+ 1. Evaluates only the group's guards (sub-command guards are fully skipped).
787
+ 2. Publishes one event per sub-command, in declaration order, inside a single `PgEventstore.client.multiple` transaction at serializable isolation — either all events commit or none do.
788
+ 3. Updates the read model after the eventstore commit, in declaration order, so each sub-command's state-updater sees the cumulative state from the previous ones.
789
+ - The first sub-event uses `expected_revision` + external-aggregate revision verification (same optimistic-concurrency machinery as the per-command flow), so a concurrent writer that committed between guard evaluation and publish raises `WrongExpectedRevisionError` and the executor retries with fresh guard evaluation.
790
+ - Subsequent sub-events within the transaction use `expected_revision: :any` — atomicity and sequencing are guaranteed by the surrounding `multiple` block.
791
+
792
+ **Payload model:**
793
+
794
+ `command_group` accepts a flat hash (most common, single-aggregate case), subject-nested form, or context-nested form — same three-form normalization as the legacy `Yes::Core::Commands::Group`. The flat form distributes attributes to each sub-command by name match:
795
+
796
+ ```ruby
797
+ # Flat — recommended for single-aggregate groups
798
+ aggregate.create_apprenticeship(
799
+ company_id: '...', user_id: '...', name: 'Acme', description: 'Best'
800
+ )
801
+ ```
802
+
803
+ Each sub-command receives the subset of keys it declares as payload attributes. The aggregate's `<aggregate>_id` is injected automatically.
804
+
805
+ **`can_<group_name>?`:**
806
+
807
+ For every `command_group`, the aggregate also gets a predicate that runs the group's guards without publishing events:
808
+
809
+ ```ruby
810
+ aggregate.can_create_apprenticeship?(company_id:, user_id:, name:, description:)
811
+ # => true / false
812
+ ```
813
+
814
+ **Response shape:**
815
+
816
+ ```ruby
817
+ response = aggregate.create_apprenticeship(payload)
818
+ response.success? # => true / false
819
+ response.events # => Array<PgEventstore::Event> in declaration order
820
+ response.error # => the GuardEvaluator::TransitionError if any (nil on success)
821
+ response.cmd # => the CommandGroup instance
822
+ ```
823
+
824
+ **Generated artifacts:**
825
+
826
+ A `command_group :foo` macro on `Context::Aggregate` generates:
827
+
828
+ - `Context::Aggregate::CommandGroups::Foo::Command` — a `Yes::Core::Commands::CommandGroup` subclass
829
+ - `Context::Aggregate::CommandGroups::Foo::GuardEvaluator` — a `Yes::Core::CommandHandling::GuardEvaluator` subclass holding the group's guards
830
+ - `Aggregate#foo(payload, guards:, metadata:)` — the invocation method
831
+ - `Aggregate#can_foo?(payload)` — the predicate
832
+ - `Aggregate#foo_error` accessor — mirrors the per-command error accessor pattern
833
+
834
+ The legacy stateless `Yes::Core::Commands::Group` / `Yes::Core::Commands::Stateless::GroupHandler` are untouched and continue to serve cross-aggregate use cases declared outside the aggregate DSL.
835
+
836
+ ### Read Models
837
+
838
+ Each aggregate automatically gets a corresponding read model (ActiveRecord model) that persists its current state. This is how you access attribute values from an aggregate.
839
+
840
+ ```ruby
841
+ user = Users::User::Aggregate.new
842
+ user.change_name("Jane Doe")
843
+ user.name # => "Jane Doe" (reads from the read model)
844
+ ```
845
+
846
+ #### Default Naming
847
+
848
+ By default, the read model's name is derived from the aggregate's context and name:
849
+
850
+ ```ruby
851
+ # For Users::User::Aggregate
852
+ # The read model class will be UsersUser
853
+ # And the database table will be users_users
854
+ ```
855
+
856
+ #### Customizing Read Models
857
+
858
+ You can customize the read model name and visibility using the `read_model` method:
859
+
860
+ ```ruby
861
+ module Users
862
+ module User
863
+ class Aggregate < Yes::Core::Aggregate
864
+ # Use a custom read model name
865
+ read_model 'custom_user', public: false
866
+
867
+ command :change, :email, :email
868
+ attribute :name, :string
869
+ end
870
+ end
871
+ end
872
+ ```
873
+
874
+ In this example:
875
+ - The read model class will be `CustomUser` instead of `UsersUser`
876
+ - The database table will be `custom_users`
877
+ - `public: false` means this read model won't be accessible via the read API
878
+
879
+ #### Read Model Schema Generator
880
+
881
+ When you add or remove aggregates or attributes, you need to update your database schema. Yes provides a Rails generator for this:
882
+
883
+ ```shell
884
+ rails generate yes:core:read_models:update
885
+ ```
886
+
887
+ This will:
888
+ 1. Find all aggregates in your application
889
+ 2. Create migration files that update read model tables to match your aggregate definitions
890
+ 3. Add, modify, or remove columns as needed
891
+
892
+ Example generated migration:
893
+
894
+ ```ruby
895
+ class UpdateReadModels < ActiveRecord::Migration[7.1]
896
+ def change
897
+ create_table :users do |t|
898
+ t.string :name
899
+ t.string :email
900
+ t.integer :age
901
+ t.integer :revision, null: false, default: -1
902
+ t.timestamps
903
+ end
904
+
905
+ add_column :companies, :name, :string
906
+ remove_column :companies, :old_field
907
+ end
908
+ end
909
+ ```
910
+
911
+ ##### Type Mapping
912
+
913
+ Attribute types are mapped to database column types as follows:
914
+ - `:string`, `:email`, `:url` → `:string`
915
+ - `:integer` → `:integer`
916
+ - `:uuid` → `:uuid`
917
+ - `:boolean` → `:boolean`
918
+ - `:hash` → `:jsonb`
919
+ - `:aggregate` → `:uuid` (stored as `<attribute_name>_id`)
920
+
921
+ #### Pending Update Tracking Generator
922
+
923
+ To ensure read model consistency and enable recovery from failures during event processing, Yes provides a generator that adds pending update tracking to your read models:
924
+
925
+ ```shell
926
+ rails generate yes:core:read_models:add_pending_update_tracking
927
+ ```
928
+
929
+ This generator creates a migration that:
930
+ 1. Adds a `pending_update_since` column to all read model tables
931
+ 2. Creates indexes to efficiently track and recover stale pending updates
932
+ 3. Automatically handles PostgreSQL's 63-character index name limit by truncating long names
933
+
934
+ ##### What It Does
935
+
936
+ The pending update tracking system helps prevent read models from getting stuck in an inconsistent state by:
937
+ - Marking read models as "pending" before event publication
938
+ - Clearing the pending state after successful updates
939
+ - Allowing automatic recovery of stale pending states (default timeout: 5 minutes)
940
+
941
+ ##### Generated Migration Example
942
+
943
+ ```ruby
944
+ class AddPendingUpdateTrackingToReadModels < ActiveRecord::Migration[7.1]
945
+ def up
946
+ read_model_tables = Yes::Core.configuration.all_read_model_table_names
947
+
948
+ read_model_tables.each do |table_name|
949
+ next unless ActiveRecord::Base.connection.table_exists?(table_name)
950
+
951
+ add_column table_name, :pending_update_since, :datetime
952
+
953
+ # Unique index to prevent concurrent updates to same aggregate
954
+ add_index table_name, :id,
955
+ unique: true,
956
+ where: 'pending_update_since IS NOT NULL',
957
+ name: truncate_index_name("idx_#{table_name}_one_pending_per_aggregate")
958
+
959
+ # Index for efficient recovery queries
960
+ add_index table_name, :pending_update_since,
961
+ where: 'pending_update_since IS NOT NULL',
962
+ name: truncate_index_name("idx_#{table_name}_pending_recovery")
963
+ end
964
+ end
965
+ end
966
+ ```
967
+
968
+ ##### Recovery Job
969
+
970
+ You can schedule a background job to automatically recover stale pending updates:
971
+
972
+ ```ruby
973
+ # app/jobs/read_model_recovery_job.rb
974
+ class ReadModelRecoveryJob < ApplicationJob
975
+ def perform
976
+ Yes::Core::Jobs::ReadModelRecoveryJob.new.perform
977
+ end
978
+ end
979
+
980
+ # Schedule it to run periodically (e.g., every 5 minutes)
981
+ # In your scheduler (whenever, sidekiq-cron, etc.):
982
+ ReadModelRecoveryJob.perform_later
983
+ ```
984
+
985
+ ##### Manual Recovery
986
+
987
+ You can also manually trigger recovery for specific read models:
988
+
989
+ ```ruby
990
+ # Recover a specific read model instance
991
+ read_model = UserReadModel.find(id)
992
+ Yes::Core::CommandHandling::ReadModelRecoveryService.recover(read_model)
993
+
994
+ # Recover all stale pending updates (older than 5 minutes by default)
995
+ Yes::Core::CommandHandling::ReadModelRecoveryService.recover_all_stale
996
+ ```
997
+
998
+ ### Parent Aggregates
999
+
1000
+ Link aggregates in a hierarchy:
1001
+
1002
+ ```ruby
1003
+ module Companies
1004
+ module Location
1005
+ class Aggregate < Yes::Core::Aggregate
1006
+ parent :company
1007
+
1008
+ command :change, :name, :string
1009
+ command :change, :address, :string
1010
+ end
1011
+ end
1012
+ end
1013
+ ```
1014
+
1015
+ The parent method defines an assign command with its attribute by default.
1016
+ For the above example it will be `assign_company` with `company_id` attribute.
1017
+
1018
+ #### command option
1019
+
1020
+ Set parent command option to false to skip defining assign command:
1021
+
1022
+ ```ruby
1023
+ parent :company, command: false
1024
+ ```
1025
+
1026
+ ### Primary Context
1027
+
1028
+ Specify the main context:
1029
+
1030
+ ```ruby
1031
+ module Users
1032
+ module User
1033
+ class Aggregate < Yes::Core::Aggregate
1034
+ primary_context :users
1035
+
1036
+ command :change, :name, :string
1037
+ end
1038
+ end
1039
+ end
1040
+ ```
1041
+
1042
+ ### Removable
1043
+
1044
+ Define a default removal behavior for an aggregate:
1045
+
1046
+ ```ruby
1047
+ module Users
1048
+ module User
1049
+ class Aggregate < Yes::Core::Aggregate
1050
+ removable
1051
+ end
1052
+ end
1053
+ end
1054
+ ```
1055
+
1056
+ It defines a `remove` command which works with the `removed_at` attribute by default and
1057
+ applies a default removal behavior.
1058
+
1059
+ The `removable` method accepts a custom name for an attribute which will also be used for
1060
+ the removal behavior. You can see an example below.
1061
+
1062
+ ```ruby
1063
+ module Users
1064
+ module User
1065
+ class Aggregate < Yes::Core::Aggregate
1066
+ removable(attr_name: :deleted_at)
1067
+ end
1068
+ end
1069
+ end
1070
+ ```
1071
+
1072
+ You can also define additional guards or custom behavior:
1073
+
1074
+ ```ruby
1075
+ module Users
1076
+ module User
1077
+ class Aggregate < Yes::Core::Aggregate
1078
+ removable do
1079
+ guard(:published) { published? }
1080
+ end
1081
+ end
1082
+ end
1083
+ end
1084
+ ```
1085
+
1086
+ #### Auto-injected `:not_removed` guard
1087
+
1088
+ Calling `removable` does more than define the `remove` command: by default it also auto-blocks
1089
+ every other command on the aggregate while the removal attribute is set. The check fires
1090
+ *before* any registered guard (including the auto-injected `:no_change`), so post-remove
1091
+ mutations consistently raise `Yes::Core::CommandHandling::GuardEvaluator::InvalidTransition`
1092
+ with the i18n message under
1093
+ `aggregates.<context>.<aggregate>.commands.<command>.guards.not_removed.error`.
1094
+
1095
+ ```ruby
1096
+ module Users
1097
+ module User
1098
+ class Aggregate < Yes::Core::Aggregate
1099
+ removable
1100
+
1101
+ command :change, :name, :string
1102
+ end
1103
+ end
1104
+ end
1105
+
1106
+ agg = Users::User::Aggregate.new
1107
+ agg.change_name(name: 'Alice') # => success
1108
+ agg.remove
1109
+ agg.change_name(name: 'Bob') # => blocked: InvalidTransition (:not_removed)
1110
+ ```
1111
+
1112
+ The `:remove` command itself is exempt — it remains gated only by its existing `:no_change`
1113
+ guard, so calling `remove` twice still raises `NoChangeTransition` as before.
1114
+
1115
+ The check is order-independent: `removable` may be declared before or after the other
1116
+ commands on the aggregate.
1117
+
1118
+ ##### Opting out at the aggregate level
1119
+
1120
+ Pass `not_removed_guards: false` to disable the auto-block for the entire aggregate (commands
1121
+ will continue to fire normally after `remove`):
1122
+
1123
+ ```ruby
1124
+ module Users
1125
+ module User
1126
+ class Aggregate < Yes::Core::Aggregate
1127
+ removable(not_removed_guards: false)
1128
+
1129
+ command :change, :name, :string
1130
+ end
1131
+ end
1132
+ end
1133
+ ```
1134
+
1135
+ ##### Opting out per command
1136
+
1137
+ Pass `skip_default_guards: %i[not_removed]` to a single `command` or `parent` to exempt just
1138
+ that command:
1139
+
1140
+ ```ruby
1141
+ module Users
1142
+ module User
1143
+ class Aggregate < Yes::Core::Aggregate
1144
+ removable
1145
+
1146
+ # Bypass the auto-block for this one command.
1147
+ command :restore, skip_default_guards: %i[not_removed] do
1148
+ guard(:no_change) { removed_at.present? }
1149
+ update_state { removed_at { nil } }
1150
+ end
1151
+
1152
+ parent :tenant, skip_default_guards: %i[not_removed]
1153
+ end
1154
+ end
1155
+ end
1156
+ ```
1157
+
1158
+ ### Draftable
1159
+
1160
+ The `draftable` feature allows aggregates to be created and modified in a draft state before being published. This is useful when you want to prepare changes without immediately making them live.
1161
+
1162
+ ```ruby
1163
+ module Articles
1164
+ module Article
1165
+ class Aggregate < Yes::Core::Aggregate
1166
+ # Makes aggregate draftable by connecting it to a draft aggregate for managing the draft state.
1167
+ # The draft aggregate has to exist already. The default draft aggregate is <CurrentAggregateContext>::<CurrentAggregateName>Draft.
1168
+ # Also configures a changes read model (defaults to "<read_model>_change")
1169
+ draftable
1170
+
1171
+ # Draftable with custom parameters
1172
+ # draftable draft_aggregate: { context: 'ArticleDrafts', aggregate: 'ArticleDraft' }, changes_read_model: :article_change
1173
+
1174
+ command :change, :title, :string
1175
+ command :change, :content, :string
1176
+ end
1177
+ end
1178
+ end
1179
+ ```
1180
+
1181
+ #### Method Parameters
1182
+
1183
+ The `draftable` method accepts two optional parameters:
1184
+
1185
+ - `draft_aggregate`: A hash containing the draft aggregate configuration
1186
+ - `context`: The context name for the draft version (defaults to the same context as the main aggregate)
1187
+ - `aggregate`: The aggregate name for the draft version (defaults to the main aggregate name with "Draft" suffix)
1188
+ - `changes_read_model`: The name for the changes read model (defaults to the main read model name with "_change" appended)
1189
+
1190
+ #### Example Usage
1191
+
1192
+ ```ruby
1193
+ # Use all defaults
1194
+ draftable
1195
+
1196
+ # Custom context only
1197
+ draftable draft_aggregate: { context: 'DraftContext' }
1198
+
1199
+ # Custom aggregate name only
1200
+ draftable draft_aggregate: { aggregate: 'MyDraft' }
1201
+
1202
+ # Both context and aggregate
1203
+ draftable draft_aggregate: { context: 'DraftContext', aggregate: 'MyDraft' }
1204
+
1205
+ # Custom changes read model only
1206
+ draftable changes_read_model: :custom_changes
1207
+
1208
+ # All custom parameters
1209
+ draftable draft_aggregate: { context: 'DraftContext', aggregate: 'MyDraft' }, changes_read_model: :my_changes
1210
+ ```
1211
+
1212
+ When `changes_read_model` is not specified, it defaults to using the main read model name with "_change" appended (e.g., if the read model is "article", the changes read model becomes "article_change").
1213
+
1214
+ ## Authorization
1215
+
1216
+ ### Auth Adapter
1217
+
1218
+ Both the [Command API](#command-api) and [Read API](#read-api) delegate authentication to a configurable adapter. Configure it in an initializer:
1219
+
1220
+ ```ruby
1221
+ # config/initializers/yes.rb
1222
+ Yes::Core.configure do |config|
1223
+ config.auth_adapter = MyAuthAdapter.new
1224
+ end
1225
+ ```
1226
+
1227
+ The adapter must implement three methods:
1228
+
1229
+ | Method | Purpose | Called by |
1230
+ |--------|---------|----------|
1231
+ | `authenticate(request)` | Verify the JWT token and return an auth data hash. Raise a `Yes::Core::AuthenticationError` subclass on failure. | Both API controllers (before every request) |
1232
+ | `verify_token(token)` | Decode a raw JWT token string. Return an object responding to `.token` that returns `[decoded_payload_hash]`. | MessageBus user identification |
1233
+ | `error_classes` | Return an array of exception classes that represent authentication failures. | Command API controller (to rescue and render 401) |
1234
+
1235
+ #### How It Works
1236
+
1237
+ 1. On every request, the controller calls `adapter.authenticate(request)`.
1238
+ 2. The returned hash is stored as `auth_data` and passed to command authorizers, read request authorizers, and read model authorizers throughout the request lifecycle.
1239
+ 3. The hash must include at minimum an `:identity_id` key, which is used for command metadata, MessageBus channel defaults, and authorization.
1240
+
1241
+ #### Example Implementation
1242
+
1243
+ ```ruby
1244
+ class MyAuthAdapter
1245
+ AuthError = Class.new(Yes::Core::AuthenticationError)
1246
+
1247
+ # @param request [ActionDispatch::Request]
1248
+ # @raise [AuthError] if the token is missing or invalid
1249
+ # @return [Hash] auth data passed to authorizers as auth_data
1250
+ def authenticate(request)
1251
+ token = request.headers['Authorization']&.delete_prefix('Bearer ')
1252
+ raise AuthError, 'Token missing' unless token
1253
+
1254
+ payload = JWT.decode(token, public_key, true, algorithm: 'RS256').first
1255
+ { identity_id: payload['sub'], host: request.host }.merge(payload.symbolize_keys)
1256
+ end
1257
+
1258
+ # @param token [String] raw JWT token (extracted from Authorization header)
1259
+ # @return [OpenStruct] object with .token returning [decoded_payload_hash]
1260
+ def verify_token(token)
1261
+ decoded = JWT.decode(token, public_key, true, algorithm: 'RS256')
1262
+ OpenStruct.new(token: decoded)
1263
+ end
1264
+
1265
+ # @return [Array<Class>] exception classes the controller rescues as 401
1266
+ def error_classes
1267
+ [AuthError, JWT::DecodeError]
1268
+ end
1269
+
1270
+ private
1271
+
1272
+ def public_key
1273
+ OpenSSL::PKey::RSA.new(ENV.fetch('JWT_PUBLIC_KEY'))
1274
+ end
1275
+ end
1276
+ ```
1277
+
1278
+ ### Aggregate Authorization
1279
+
1280
+ To make aggregates available via the command API, you must define an authorization scheme at the aggregate level. This controls who can execute commands on the aggregate.
1281
+
1282
+ #### Simple Authorization
1283
+
1284
+ The simplest authorization simply allows all commands to be executed:
1285
+
1286
+ ```ruby
1287
+ module Users
1288
+ module User
1289
+ class Aggregate < Yes::Core::Aggregate
1290
+ # Allow all commands
1291
+ authorize do
1292
+ true
1293
+ end
1294
+
1295
+ command :change, :name, :string
1296
+ end
1297
+ end
1298
+ end
1299
+ ```
1300
+
1301
+ Inside the `authorize` block, you can access:
1302
+ - `command` - The command being executed
1303
+ - `auth_data` - The decoded data from the JWT authentication token
1304
+
1305
+ This allows for custom authorization logic:
1306
+
1307
+ ```ruby
1308
+ authorize do
1309
+ # Only allow commands if the authenticated identity matches the user
1310
+ command.user_id == auth_data[:identity_id]
1311
+ end
1312
+ ```
1313
+
1314
+ ### Command Authorization
1315
+
1316
+ Commands can define per-command authorization that extends or overrides the [aggregate-level authorizer](#aggregate-authorization).
1317
+
1318
+ ```ruby
1319
+ # First define an aggregate level authorizer
1320
+ class Aggregate < Yes::Core::Aggregate
1321
+ authorize do
1322
+ # Base level authorization logic
1323
+ auth_data[:identity_id].present?
1324
+ end
1325
+
1326
+ # Then add command-specific refinements
1327
+ command :publish do
1328
+ payload user_id: :uuid
1329
+
1330
+ # Command-specific authorization logic
1331
+ authorize do
1332
+ # Has access to the command and auth_data
1333
+ command.user_id == auth_data[:user_id]
1334
+ end
1335
+ end
1336
+ end
1337
+ ```
1338
+
1339
+ When an aggregate has declared `authorize` at the class level, commands can define their own
1340
+ authorization logic that inherits from the aggregate-level authorizer. Each command with an
1341
+ `authorize` block automatically receives its own `Authorizer` subclass that inherits from
1342
+ the aggregate-level authorizer.
1343
+
1344
+ Command authorizers are registered in the configuration and can be retrieved with:
1345
+
1346
+ ```ruby
1347
+ Yes::Core.configuration.aggregate_class('Context', 'Aggregate', :publish, :authorizer)
1348
+ ```
1349
+
1350
+ ### Cerbos Authorization
1351
+
1352
+ For more complex authorization needs, Yes integrates with [Cerbos](https://www.cerbos.dev/), a powerful authorization engine:
1353
+
1354
+ ```ruby
1355
+ module Users
1356
+ module User
1357
+ class Aggregate < Yes::Core::Aggregate
1358
+ authorize cerbos: true
1359
+
1360
+ command :change, :name, :string
1361
+ end
1362
+ end
1363
+ end
1364
+ ```
1365
+
1366
+ When using Cerbos, you can specify additional parameters:
1367
+
1368
+ - `read_model_class` - The class used to load the read model for authorization checks (defaults to the aggregate's read model)
1369
+ - `resource_name` - The resource name used in Cerbos policies (defaults to the underscored aggregate name)
1370
+
1371
+ ```ruby
1372
+ module Companies
1373
+ module CompanySettings
1374
+ class Aggregate < Yes::Core::Aggregate
1375
+ # Custom read model and resource name
1376
+ authorize cerbos: true,
1377
+ read_model_class: CustomCompanySettings,
1378
+ resource_name: 'company_settings'
1379
+
1380
+ command :change, :name, :string
1381
+ end
1382
+ end
1383
+ end
1384
+ ```
1385
+
1386
+ When using custom read models with Cerbos, the model must implement an `auth_attributes` method that returns a hash of attributes for authorization:
1387
+
1388
+ ```ruby
1389
+ class CustomCompanySettings < ApplicationRecord
1390
+ def auth_attributes
1391
+ { company_id: company_id || '' }
1392
+ end
1393
+ end
1394
+ ```
1395
+
1396
+ These attributes are passed to Cerbos for making authorization decisions based on your policies.
1397
+
1398
+ #### Customizing Cerbos Integration
1399
+
1400
+ For advanced use cases, you can customize how Yes interacts with Cerbos by overriding the `resource_attributes` and `cerbos_payload` methods in your authorization block. Currently, this customization is only available within command-level authorization blocks, not at the aggregate level:
1401
+
1402
+ ```ruby
1403
+ module Universe
1404
+ module Star
1405
+ class Aggregate < Yes::Core::Aggregate
1406
+ # Base aggregate-level Cerbos authorization
1407
+ authorize cerbos: true
1408
+
1409
+ command :change, :name, :string
1410
+
1411
+ # Command with customized Cerbos integration
1412
+ command :update_details do
1413
+ payload details: :string
1414
+
1415
+ # Command-level authorization with custom Cerbos integration
1416
+ authorize do
1417
+ # Override resource attributes sent to Cerbos
1418
+ resource_attributes { { owner_id: 'test-user-id' } }
1419
+
1420
+ # Override the entire Cerbos payload
1421
+ cerbos_payload { { principal: auth_data, resource_id: 'test-id' } }
1422
+ end
1423
+ end
1424
+ end
1425
+ end
1426
+ end
1427
+ ```
1428
+
1429
+ Inside the `resource_attributes` block, you can access:
1430
+ - `command` - The command being executed
1431
+ - `resource` - The read model instance for the aggregate
1432
+
1433
+ Inside the `cerbos_payload` block, you can access:
1434
+ - `command` - The command being executed
1435
+ - `resource` - The read model instance for the aggregate
1436
+ - `auth_data` - The decoded data from the JWT authentication token
1437
+
1438
+ These blocks allow you to precisely control what data is sent to Cerbos for authorization decisions on a per-command basis.
1439
+
1440
+ ## Command API
1441
+
1442
+ The Command API (`yes-command-api`) provides an HTTP endpoint for executing commands as JSON batches. It is a standalone Rails engine that does **not** depend on the aggregate DSL — it works with any command class that follows one of the supported naming conventions.
1443
+
1444
+ ### Command API Installation
1445
+
1446
+ Add the gem and mount the engine:
1447
+
1448
+ ```ruby
1449
+ # Gemfile
1450
+ gem 'yes-command-api'
1451
+ ```
1452
+
1453
+ ```ruby
1454
+ # config/routes.rb
1455
+ mount Yes::Command::Api::Engine => '/v1/commands'
1456
+ ```
1457
+
1458
+ ### Request Format
1459
+
1460
+ Send a `POST` request with a JSON body containing a `commands` array:
1461
+
1462
+ ```json
1463
+ {
1464
+ "commands": [
1465
+ {
1466
+ "context": "Users",
1467
+ "subject": "User",
1468
+ "command": "ChangeName",
1469
+ "data": {
1470
+ "user_id": "47330036-7246-40b4-a3c7-7038df508774",
1471
+ "name": "Jane Doe"
1472
+ },
1473
+ "metadata": {}
1474
+ }
1475
+ ],
1476
+ "channel": "my-notifications"
1477
+ }
1478
+ ```
1479
+
1480
+ Each command requires `context`, `subject`, `command`, and `data`. The optional `channel` parameter controls which MessageBus channel receives notifications (defaults to the authenticated user's `identity_id`).
1481
+
1482
+ Set `async=true` or `async=false` as a query parameter to override the default processing mode (`Yes::Core.configuration.process_commands_inline`).
1483
+
1484
+ ### Command Class Resolution
1485
+
1486
+ The deserializer resolves command classes by trying three naming conventions in order:
1487
+
1488
+ | Priority | Convention | Class pattern | Typical use |
1489
+ |----------|-----------|---------------|-------------|
1490
+ | 1 | Command Group | `CommandGroups::<Command>::Command` | Composed commands |
1491
+ | 2 | V2 | `<Context>::<Subject>::Commands::<Command>::Command` | DSL-generated commands |
1492
+ | 3 | V1 | `<Context>::Commands::<Subject>::<Command>` | Manually created commands |
1493
+
1494
+ The first matching constant wins. This means you can use the API with DSL-generated commands, manually created commands, or both.
1495
+
1496
+ ### Processing Pipeline
1497
+
1498
+ When a request arrives, it passes through these stages:
1499
+
1500
+ 1. **Authentication** — the [auth adapter](#auth-adapter) verifies the JWT token
1501
+ 2. **Params validation** — checks that each command hash contains `context`, `subject`, `command`, and `data`
1502
+ 3. **Deserialization** — resolves class names and instantiates command objects
1503
+ 4. **Expansion** — flattens command groups into individual commands
1504
+ 5. **Authorization** — each command's authorizer is looked up and called with `auth_data`
1505
+ 6. **Validation** — optional per-command validators are called
1506
+ 7. **Command bus** — commands are dispatched (inline or via ActiveJob)
1507
+
1508
+ ### Using Commands Without the DSL
1509
+
1510
+ You can create command classes manually and use them with the Command API. A complete command requires four parts: a **command**, a **handler**, an **event**, and an **authorizer**. The file structure follows a convention:
1511
+
1512
+ ```
1513
+ app/contexts/
1514
+ billing/
1515
+ invoice/
1516
+ commands/
1517
+ authorizer.rb # shared base authorizer (optional)
1518
+ create/
1519
+ command.rb # command definition
1520
+ handler.rb # command handler
1521
+ authorizer.rb # per-command authorizer
1522
+ events/
1523
+ created.rb # event definition
1524
+ ```
1525
+
1526
+ #### Command
1527
+
1528
+ Defines the payload attributes and identifies the aggregate:
1529
+
1530
+ ```ruby
1531
+ # app/contexts/billing/invoice/commands/create/command.rb
1532
+ module Billing
1533
+ module Invoice
1534
+ module Commands
1535
+ module Create
1536
+ class Command < Yes::Core::Command
1537
+ attribute :invoice_id, Yes::Core::Types::UUID
1538
+ attribute :amount, Yes::Core::Types::Integer
1539
+ attribute :currency, Yes::Core::Types::String
1540
+
1541
+ alias aggregate_id invoice_id
1542
+ end
1543
+ end
1544
+ end
1545
+ end
1546
+ end
1547
+ ```
1548
+
1549
+ #### Handler
1550
+
1551
+ Processes the command and publishes the event. The handler inherits from `Yes::Core::Commands::Stateless::Handler` and declares which event to emit:
1552
+
1553
+ ```ruby
1554
+ # app/contexts/billing/invoice/commands/create/handler.rb
1555
+ module Billing
1556
+ module Invoice
1557
+ module Commands
1558
+ module Create
1559
+ class Handler < Yes::Core::Commands::Stateless::Handler
1560
+ self.event_name = 'Created'
1561
+
1562
+ def call
1563
+ # Add guard logic here, e.g.:
1564
+ # no_change_transition('Already exists') if already_exists?
1565
+
1566
+ super # publishes the event
1567
+ end
1568
+ end
1569
+ end
1570
+ end
1571
+ end
1572
+ end
1573
+ ```
1574
+
1575
+ #### Event
1576
+
1577
+ Defines the event schema for validation when writing to the event store:
1578
+
1579
+ ```ruby
1580
+ # app/contexts/billing/invoice/events/created.rb
1581
+ module Billing
1582
+ module Invoice
1583
+ module Events
1584
+ class Created < Yes::Core::Event
1585
+ def schema
1586
+ Dry::Schema.Params do
1587
+ required(:invoice_id).value(Yes::Core::Types::UUID)
1588
+ required(:amount).value(:integer)
1589
+ required(:currency).value(:string)
1590
+ end
1591
+ end
1592
+ end
1593
+ end
1594
+ end
1595
+ end
1596
+ ```
1597
+
1598
+ #### Authorizer
1599
+
1600
+ Controls who can execute the command. You can define a shared base authorizer for the aggregate and inherit from it:
1601
+
1602
+ ```ruby
1603
+ # app/contexts/billing/invoice/commands/authorizer.rb
1604
+ module Billing
1605
+ module Invoice
1606
+ module Commands
1607
+ class Authorizer < Yes::Core::Authorization::CommandAuthorizer
1608
+ def self.call(_command, auth_data)
1609
+ raise CommandNotAuthorized, 'Not allowed' unless auth_data[:identity_id].present?
1610
+ end
1611
+ end
1612
+ end
1613
+ end
1614
+ end
1615
+
1616
+ # app/contexts/billing/invoice/commands/create/authorizer.rb
1617
+ module Billing
1618
+ module Invoice
1619
+ module Commands
1620
+ module Create
1621
+ class Authorizer < Billing::Invoice::Commands::Authorizer
1622
+ # Inherits base authorization; add command-specific checks here
1623
+ end
1624
+ end
1625
+ end
1626
+ end
1627
+ end
1628
+ ```
1629
+
1630
+ This command can then be executed via the API:
1631
+
1632
+ ```json
1633
+ {
1634
+ "context": "Billing",
1635
+ "subject": "Invoice",
1636
+ "command": "Create",
1637
+ "data": {
1638
+ "invoice_id": "550e8400-e29b-41d4-a716-446655440000",
1639
+ "amount": 10000,
1640
+ "currency": "CHF"
1641
+ }
1642
+ }
1643
+ ```
1644
+
1645
+ ### Real-Time Command Notifications
1646
+
1647
+ For performance and reliability, WebSocket-based notifications are the preferred way to inform frontends about command execution status. The Command API ships with two built-in notifiers and supports custom implementations.
1648
+
1649
+ Notifiers are configured globally and broadcast three event types per command batch:
1650
+
1651
+ | Event | When | Payload includes |
1652
+ |-------|------|-----------------|
1653
+ | `batch_started` | Before processing begins | `batch_id`, commands list |
1654
+ | Per-command response | After each command completes | Command result or error |
1655
+ | `batch_finished` | After all commands complete | `batch_id`, failed commands (if any) |
1656
+
1657
+ #### Configuration
1658
+
1659
+ Register one or more notifier classes in the initializer:
1660
+
1661
+ ```ruby
1662
+ Yes::Core.configure do |config|
1663
+ config.command_notifier_classes = [
1664
+ Yes::Command::Api::Commands::Notifiers::ActionCable,
1665
+ Yes::Command::Api::Commands::Notifiers::MessageBus
1666
+ ]
1667
+ end
1668
+ ```
1669
+
1670
+ The `channel` parameter from the API request (or the authenticated user's `identity_id` as fallback) is passed to each notifier, so clients only receive notifications for their own commands.
1671
+
1672
+ #### ActionCable Notifier
1673
+
1674
+ Broadcasts notifications via `ActionCable.server.broadcast`. This is well suited for use with a dedicated WebSocket gateway service that connects to the same Redis backend:
1675
+
1676
+ ```ruby
1677
+ config.command_notifier_classes = [Yes::Command::Api::Commands::Notifiers::ActionCable]
1678
+ ```
1679
+
1680
+ The frontend subscribes to the channel and receives JSON messages:
1681
+
1682
+ ```json
1683
+ { "type": "batch_started", "batch_id": "abc-123", "published_at": 1711540800, "commands": [...] }
1684
+ { "type": "batch_finished", "batch_id": "abc-123", "published_at": 1711540801, "failed_commands": [] }
1685
+ ```
1686
+
1687
+ #### MessageBus Notifier
1688
+
1689
+ Uses the [MessageBus](https://github.com/discourse/message_bus) gem for long-polling or WebSocket delivery. Messages are scoped to the authenticated user via `user_ids`:
1690
+
1691
+ ```ruby
1692
+ config.command_notifier_classes = [Yes::Command::Api::Commands::Notifiers::MessageBus]
1693
+ ```
1694
+
1695
+ The auth adapter's `verify_token` method is used by MessageBus to identify subscribers by their `identity_id`.
1696
+
1697
+ #### Custom Notifiers
1698
+
1699
+ You can implement your own notifier by subclassing `Yes::Core::Commands::Notifier`:
1700
+
1701
+ ```ruby
1702
+ class SlackNotifier < Yes::Core::Commands::Notifier
1703
+ def notify_batch_started(batch_id, transaction = nil, commands = nil)
1704
+ # ...
1705
+ end
1706
+
1707
+ def notify_batch_finished(batch_id, transaction = nil, responses = nil)
1708
+ # ...
1709
+ end
1710
+
1711
+ def notify_command_response(cmd_response)
1712
+ # ...
1713
+ end
1714
+ end
1715
+ ```
1716
+
1717
+ ## Read API
1718
+
1719
+ The Read API (`yes-read-api`) provides an HTTP endpoint for querying read models with filtering, pagination, and authorization. Like the Command API, it is a standalone Rails engine that does **not** depend on the aggregate DSL — it works with any ActiveRecord model that has a matching serializer.
1720
+
1721
+ ### Read API Installation
1722
+
1723
+ Add the gem and mount the engine:
1724
+
1725
+ ```ruby
1726
+ # Gemfile
1727
+ gem 'yes-read-api'
1728
+ ```
1729
+
1730
+ ```ruby
1731
+ # config/routes.rb
1732
+ mount Yes::Read::Api::Engine => '/queries'
1733
+ ```
1734
+
1735
+ ### Basic Queries
1736
+
1737
+ Send a `GET` request with the read model name as the path and optional query parameters:
1738
+
1739
+ ```
1740
+ GET /queries/users?filters[ids]=1,2,3&order[name]=asc&page[number]=1&page[size]=20&include=company
1741
+ ```
1742
+
1743
+ - `filters[<key>]` — filter by attribute (handled by the model's filter class)
1744
+ - `order[<key>]` — sort direction (`asc` or `desc`)
1745
+ - `page[number]` and `page[size]` — pagination
1746
+ - `include` — comma-separated list of associations to include in the response
1747
+
1748
+ ### Advanced Queries
1749
+
1750
+ Send a `POST` request for complex filtering with AND/OR logic:
1751
+
1752
+ ```json
1753
+ {
1754
+ "model": "users",
1755
+ "filter_definition": {
1756
+ "type": "filter_set",
1757
+ "logical_operator": "and",
1758
+ "filters": [
1759
+ {
1760
+ "type": "filter",
1761
+ "attribute": "name",
1762
+ "operator": "is",
1763
+ "value": "Jane"
1764
+ },
1765
+ {
1766
+ "type": "filter",
1767
+ "attribute": "status",
1768
+ "operator": "is_not",
1769
+ "value": "archived"
1770
+ }
1771
+ ]
1772
+ },
1773
+ "order": { "name": "asc" },
1774
+ "page": { "number": 1, "size": 20 }
1775
+ }
1776
+ ```
1777
+
1778
+ ### Filters
1779
+
1780
+ Filters are optional per-model classes that define available filter scopes. If no custom filter exists, the base `Yes::Core::ReadModel::Filter` is used.
1781
+
1782
+ ```ruby
1783
+ module ReadModels
1784
+ module User
1785
+ class Filter < Yes::Core::ReadModel::Filter
1786
+ has_scope :name do |_controller, scope, value|
1787
+ scope.where(name: value)
1788
+ end
1789
+
1790
+ has_scope :ids do |_controller, scope, value|
1791
+ scope.where(id: value.split(','))
1792
+ end
1793
+
1794
+ private
1795
+
1796
+ def read_model_class
1797
+ ::UserReadModel
1798
+ end
1799
+ end
1800
+ end
1801
+ end
1802
+ ```
1803
+
1804
+ ### Read API Authorization
1805
+
1806
+ The Read API enforces two levels of authorization:
1807
+
1808
+ 1. **Request authorizer** — controls whether a user can query a given model at all. Looked up as `ReadModels::<Model>::RequestAuthorizer`.
1809
+
1810
+ ```ruby
1811
+ module ReadModels
1812
+ module User
1813
+ class RequestAuthorizer
1814
+ def self.call(filter_options, auth_data)
1815
+ unless auth_data[:identity_id].present?
1816
+ raise Yes::Core::Authorization::ReadRequestAuthorizer::NotAuthorized, 'Not allowed'
1817
+ end
1818
+ end
1819
+ end
1820
+ end
1821
+ end
1822
+ ```
1823
+
1824
+ 2. **Read model authorizer** — filters returned records based on what the user can access. Configured via `Yes::Core::Authorization::ReadModelsAuthorizer`.
1825
+
1826
+ ### Serializers
1827
+
1828
+ Each read model requires a serializer class following the convention `ReadModels::<Model>::Serializers::<Model>`. The serializer receives `auth_data` and filter options, allowing it to customize the response based on the authenticated user.
1829
+
1830
+ ## Event Processing
1831
+
1832
+ ### Subscriptions
1833
+
1834
+ Yes wraps [PgEventstore](https://github.com/yousty/pg_eventstore) subscriptions for processing events in real-time.
1835
+
1836
+ #### Setting Up Subscriptions
1837
+
1838
+ ```ruby
1839
+ # lib/tasks/eventstore.rb
1840
+ subscriptions = Yes::Core::Subscriptions.new
1841
+
1842
+ subscriptions.subscribe_to_all(
1843
+ MyReadModel::Builder.new,
1844
+ { event_types: ['MyContext::SomethingHappened', 'MyContext::SomethingElseHappened'] }
1845
+ )
1846
+
1847
+ subscriptions.start
1848
+ ```
1849
+
1850
+ Start subscriptions via the PgEventstore CLI:
1851
+
1852
+ ```shell
1853
+ bundle exec pg-eventstore subscriptions start -r ./lib/tasks/eventstore.rb
1854
+ ```
1855
+
1856
+ #### Heartbeat
1857
+
1858
+ Configure a heartbeat URL for monitoring subscription health:
1859
+
1860
+ ```ruby
1861
+ Yes::Core.configure do |config|
1862
+ config.subscriptions_heartbeat_url = ENV['SUBSCRIPTIONS_HEARTBEAT_URL']
1863
+ config.subscriptions_heartbeat_interval = 30 # seconds
1864
+ end
1865
+ ```
1866
+
1867
+ ### Process Managers
1868
+
1869
+ Process managers coordinate commands across services via HTTP.
1870
+
1871
+ #### ServiceClient
1872
+
1873
+ Sends commands to another service's command API:
1874
+
1875
+ ```ruby
1876
+ client = Yes::Core::ProcessManagers::ServiceClient.new('media')
1877
+ # Resolves to MEDIA_SERVICE_URL env var or http://media-cluster-ip-service:3000
1878
+
1879
+ client.call(access_token: token, commands_data: [...], channel: '/notifications')
1880
+ ```
1881
+
1882
+ #### CommandRunner
1883
+
1884
+ Base class for process managers that publish commands to external services:
1885
+
1886
+ ```ruby
1887
+ class MyProcessManager < Yes::Core::ProcessManagers::CommandRunner
1888
+ def call(event)
1889
+ publish(
1890
+ client_id: ENV['MY_CLIENT_ID'],
1891
+ client_secret: ENV['MY_CLIENT_SECRET'],
1892
+ commands_data: build_commands(event)
1893
+ )
1894
+ end
1895
+ end
1896
+ ```
1897
+
1898
+ #### State
1899
+
1900
+ Reconstructs entity state from events for use in process managers:
1901
+
1902
+ ```ruby
1903
+ class UserState < Yes::Core::ProcessManagers::State
1904
+ RELEVANT_EVENTS = ['Auth::UserCreated', 'Auth::UserNameChanged'].freeze
1905
+
1906
+ attr_reader :name
1907
+
1908
+ private
1909
+
1910
+ def stream
1911
+ PgEventstore::Stream.new(context: 'Auth', stream_name: 'User', stream_id: @id)
1912
+ end
1913
+
1914
+ def required_attributes
1915
+ [:name]
1916
+ end
1917
+
1918
+ def apply_user_name_changed(event)
1919
+ @name = event.data['name']
1920
+ end
1921
+ end
1922
+
1923
+ state = UserState.load(user_id)
1924
+ state.valid? # true if all required_attributes are present
1925
+ ```
1926
+
1927
+ ## Configuration Reference
1928
+
1929
+ ```ruby
1930
+ Yes::Core.configure do |config|
1931
+ # Command processing
1932
+ config.process_commands_inline = true # Process commands synchronously (default: true)
1933
+ config.command_notifier_classes = [] # Array of notifier classes for command batch notifications
1934
+
1935
+ # Authentication
1936
+ config.auth_adapter = nil # Auth adapter instance (required for command/read APIs)
1937
+
1938
+ # Cerbos Authorization
1939
+ config.cerbos_url = ENV['CERBOS_URL'] # Cerbos server URL (default from env var)
1940
+ config.cerbos_principal_data_builder = -> {} # Lambda to build Cerbos principal data for commands
1941
+ config.cerbos_read_principal_data_builder = nil # Lambda for read requests (falls back to above)
1942
+ config.cerbos_commands_authorizer_include_metadata = false
1943
+ config.cerbos_read_authorizer_include_metadata = false
1944
+ config.cerbos_read_authorizer_actions = %w[read]
1945
+ config.cerbos_read_authorizer_resource_id_prefix = 'read-'
1946
+ config.cerbos_read_authorizer_principal_anonymous_id = 'anonymous'
1947
+ config.super_admin_check = ->(_auth_data) { false }
1948
+
1949
+ # Subscriptions
1950
+ config.subscriptions_heartbeat_url = nil # URL to ping for subscription health monitoring
1951
+ config.subscriptions_heartbeat_interval = 30 # Heartbeat interval in seconds
1952
+
1953
+ # Observability
1954
+ config.otl_tracer = nil # OpenTelemetry tracer instance
1955
+ config.logger = Rails.logger # Logger instance
1956
+
1957
+ # Error reporting
1958
+ config.error_reporter = nil # Callable for error reporting (e.g. Sentry)
1959
+ end
1960
+ ```
1961
+
1962
+ ## Testing
1963
+
1964
+ yes-core ships with a test DSL for writing concise aggregate command specs. Add to your `spec_helper.rb` or `rails_helper.rb`:
1965
+
1966
+ ```ruby
1967
+ require 'yes/core/test_support'
1968
+
1969
+ RSpec.configure do |config|
1970
+ config.include Yes::Core::TestSupport::EventHelpers
1971
+ end
1972
+ ```
1973
+
1974
+ ### Aggregate Test DSL
1975
+
1976
+ Specs with `type: :aggregate` automatically get the command test DSL:
1977
+
1978
+ ```ruby
1979
+ RSpec.describe MyContext::Order::Aggregate, type: :aggregate do
1980
+ it { is_expected.to have_cerbos_authorizer.with_read_model_class(Order) }
1981
+ it { is_expected.to have_read_model_class(Order) }
1982
+ it { is_expected.to have_parent('customer').with_context('CustomerManagement') }
1983
+
1984
+ command 'confirm' do
1985
+ let(:command_data) { { confirmed_at: Time.current } }
1986
+ let(:success_attributes) { { confirmed: true } }
1987
+
1988
+ # Tests successful execution, state change, and event publishing
1989
+ success
1990
+
1991
+ # Tests with custom setup
1992
+ success 'when order was previously cancelled' do
1993
+ setup do
1994
+ aggregate.confirm
1995
+ aggregate.cancel
1996
+ end
1997
+ end
1998
+
1999
+ # Tests that guard raises InvalidTransition
2000
+ invalid 'order has been removed' do
2001
+ setup { aggregate.remove }
2002
+ end
2003
+
2004
+ # Executes command twice — second time should raise NoChangeTransition
2005
+ no_change
2006
+ end
2007
+ end
2008
+ ```
2009
+
2010
+ The `command` block automatically defines:
2011
+ - `aggregate` — a new instance of the described class
2012
+ - `subject` — executes the command with `command_data`
2013
+ - `expected_event_type` — derived from context, aggregate, and command name
2014
+ - `success_attributes` — defaults to `command_data` (override as needed)
2015
+
2016
+ ### DSL Methods
2017
+
2018
+ | Method | Description |
2019
+ |--------|-------------|
2020
+ | `command 'name'` | Defines a command test block with aggregate, subject, and default lets |
2021
+ | `success` | Asserts command changes state and publishes expected event |
2022
+ | `invalid 'reason'` | Asserts command raises `InvalidTransition` error |
2023
+ | `no_change` | Asserts duplicate command raises `NoChangeTransition` error |
2024
+ | `setup { ... }` | Alias for `before` — sets up aggregate state before assertions |
2025
+
2026
+ For draft commands, pass `draft: true`:
2027
+
2028
+ ```ruby
2029
+ command 'change_name', draft: true do
2030
+ let(:command_data) { { name: 'New Name' } }
2031
+ success
2032
+ end
2033
+ ```
2034
+
2035
+ ### Command Group Test DSL
2036
+
2037
+ Command groups have a parallel set of helpers — `command_group`, `success_group`, `invalid_group`, `no_change_group` — that mirror the per-command DSL but produce assertions about the `CommandGroupResponse` (multiple events, cumulative read-model state).
2038
+
2039
+ ```ruby
2040
+ RSpec.describe Companies::Apprenticeship::Aggregate, type: :aggregate do
2041
+ command_group 'create_apprenticeship' do
2042
+ let(:command_data) do
2043
+ {
2044
+ company_id: SecureRandom.uuid,
2045
+ user_id: SecureRandom.uuid,
2046
+ name: 'Acme Apprenticeship',
2047
+ description: 'Software dev role'
2048
+ }
2049
+ end
2050
+
2051
+ let(:success_attributes) do
2052
+ { name: 'Acme Apprenticeship', description: 'Software dev role' }
2053
+ end
2054
+
2055
+ # Asserts: response is success, all sub-events publish in declaration order,
2056
+ # read model reflects the cumulative state.
2057
+ success_group
2058
+
2059
+ # Asserts: response is failure, error is InvalidTransition, events array is empty.
2060
+ invalid_group 'company_id is missing' do
2061
+ let(:command_data) { super().merge(company_id: nil) }
2062
+ end
2063
+
2064
+ # Asserts: NoChangeTransition when running the group twice.
2065
+ no_change_group
2066
+ end
2067
+ end
2068
+ ```
2069
+
2070
+ The `command_group` block automatically defines:
2071
+ - `aggregate` — a new instance of the described class
2072
+ - `subject` — executes the group with `command_data`
2073
+ - `expected_event_types` — array of expected event types in declaration order, derived from each sub-command's `event_name` and the aggregate's context/name (with draft prefix handling)
2074
+ - `success_attributes` — defaults to `command_data` (override as needed)
2075
+
2076
+ | Method | Description |
2077
+ |--------|-------------|
2078
+ | `command_group 'name'` | Defines a command_group test block with aggregate, subject, and default lets |
2079
+ | `success_group` | Asserts the group publishes all expected events and read model reflects cumulative state |
2080
+ | `invalid_group 'reason'` | Asserts the group's guard fails with `InvalidTransition` and no events publish |
2081
+ | `no_change_group` | Asserts a duplicate group invocation raises `NoChangeTransition` |
2082
+ | `setup { ... }` | Alias for `before` — works inside `command_group` too |
2083
+
2084
+ For draftable aggregates, pass `draft: true` exactly like the per-command form:
2085
+
2086
+ ```ruby
2087
+ command_group 'create_apprenticeship', draft: true do
2088
+ let(:command_data) { { ... } }
2089
+ success_group
2090
+ end
2091
+ ```
2092
+
2093
+ ### Event Helpers
2094
+
2095
+ Available via `Yes::Core::TestSupport::EventHelpers`:
2096
+
2097
+ ```ruby
2098
+ # Append events from other contexts for cross-aggregate setup
2099
+ given_events do
2100
+ [{ context: 'Shipping', aggregate: 'Shipment', event: 'Dispatched', data: { shipment_id: id } }]
2101
+ end
2102
+
2103
+ # Low-level event operations
2104
+ append_event(stream, event)
2105
+ append_and_reload_event(stream, event)
2106
+ read_events(stream) # returns [] if stream not found
2107
+ ```
2108
+
2109
+ ### Aggregate Matchers
2110
+
2111
+ ```ruby
2112
+ # Check authorizer configuration
2113
+ it { is_expected.to have_authorizer }
2114
+ it { is_expected.to have_cerbos_authorizer }
2115
+ it { is_expected.to have_cerbos_authorizer.with_read_model_class(Order) }
2116
+ it { is_expected.to have_cerbos_authorizer.with_resource_name('order') }
2117
+
2118
+ # Check read model
2119
+ it { is_expected.to have_read_model_class(Order) }
2120
+
2121
+ # Check parent aggregates
2122
+ it { is_expected.to have_parent('company') }
2123
+ it { is_expected.to have_parent('company').with_context('CompanyManagement') }
2124
+ ```
2125
+
2126
+ ## Development
2127
+
2128
+ After checking out the repo, run `bin/setup` to install dependencies.
2129
+
2130
+ Start PG EventStore using Docker:
2131
+
2132
+ ```shell
2133
+ docker compose up
2134
+ ```
2135
+
2136
+ Setup databases:
2137
+
2138
+ ```shell
2139
+ ./bin/setup_db
2140
+ ```
2141
+
2142
+ Enter a development console (from a gem's `spec/dummy` directory):
2143
+
2144
+ ```shell
2145
+ bundle exec rails c
2146
+ ```
2147
+
2148
+ ### Example Usage
2149
+
2150
+ ```ruby
2151
+ user = Test::User::Aggregate.new
2152
+ user.change_name(name: "John Doe")
2153
+ user.name # => "John Doe"
2154
+ TestUser.last.name # => "John Doe"
2155
+ ```
2156
+
2157
+ ### Testing the APIs
2158
+
2159
+ The dummy app includes mounted command and read APIs for testing. Start the server from one of the gem dummy apps:
2160
+
2161
+ ```shell
2162
+ cd yes-core/spec/dummy
2163
+ bundle exec rails s
2164
+ ```
2165
+
2166
+ #### Authentication
2167
+
2168
+ The dummy app uses a simple Base64-encoded auth adapter for development. Generate a token:
2169
+
2170
+ ```ruby
2171
+ require 'base64'
2172
+ user_id = "47330036-7246-40b4-a3c7-7038df508774"
2173
+ token = Base64.strict_encode64({ identity_id: user_id, user_id: user_id }.to_json)
2174
+ ```
2175
+
2176
+ Or from the command line:
2177
+
2178
+ ```shell
2179
+ TOKEN=$(echo -n '{"identity_id":"47330036-7246-40b4-a3c7-7038df508774","user_id":"47330036-7246-40b4-a3c7-7038df508774"}' | base64)
2180
+ ```
2181
+
2182
+ #### Testing Command API
2183
+
2184
+ Execute a command with curl:
2185
+
2186
+ ```shell
2187
+ curl --location 'http://127.0.0.1:3000/commands' \
2188
+ --header 'Content-Type: application/json' \
2189
+ --header "Authorization: Bearer $TOKEN" \
2190
+ --data '{
2191
+ "commands": [{
2192
+ "subject": "User",
2193
+ "context": "Test",
2194
+ "command": "ChangeName",
2195
+ "data": {
2196
+ "user_id": "47330036-7246-40b4-a3c7-7038df508774",
2197
+ "name": "Judydoody Doodle"
2198
+ }
2199
+ }],
2200
+ "channel": "test-notifications"
2201
+ }'
2202
+ ```
2203
+
2204
+ #### Testing Read API
2205
+
2206
+ Query the read models:
2207
+
2208
+ ```shell
2209
+ curl --location 'http://127.0.0.1:3000/queries/test_users' \
2210
+ --header 'Content-Type: application/json' \
2211
+ --header "Authorization: Bearer $TOKEN"
2212
+ ```
2213
+
2214
+ ### Running Specs
2215
+
2216
+ Each gem has its own test suite that runs in isolation with its own bundle context.
2217
+
2218
+ Run specs for a single gem:
2219
+
2220
+ ```shell
2221
+ rake yes_core:spec
2222
+ rake yes_command_api:spec
2223
+ rake yes_read_api:spec
2224
+ ```
2225
+
2226
+ Run specs for all gems:
2227
+
2228
+ ```shell
2229
+ rake spec
2230
+ ```
2231
+
2232
+ You can also run specs directly from within a gem directory:
2233
+
2234
+ ```shell
2235
+ cd yes-core && bundle exec rspec spec
2236
+ ```
2237
+
2238
+ ### Gem Installation
2239
+
2240
+ Install the gem locally:
2241
+
2242
+ ```shell
2243
+ bundle exec rake install
2244
+ ```
2245
+
2246
+ ## Contributing
2247
+
2248
+ Bug reports and pull requests are welcome on GitHub at https://github.com/yousty/yes. See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines.
2249
+
2250
+ ## Changelog
2251
+
2252
+ See [CHANGELOG.md](CHANGELOG.md) for a list of changes.
2253
+
2254
+ ## License
2255
+
2256
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).