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