Skip to content

feat(yes-core): add command_group DSL to aggregate#28

Merged
ncri merged 4 commits into
mainfrom
feat/command-group-aggregate-dsl
May 18, 2026
Merged

feat(yes-core): add command_group DSL to aggregate#28
ncri merged 4 commits into
mainfrom
feat/command-group-aggregate-dsl

Conversation

@ncri
Copy link
Copy Markdown
Contributor

@ncri ncri commented May 16, 2026

Summary

Adds a command_group :name do … end macro to Yes::Core::Aggregate, mirroring how command works. The macro produces a single callable on the aggregate that runs several existing aggregate commands as one atomic, transactionally-published unit — with its own, leaner guard set and the sub-command guards fully bypassed.

command_group :create_apprenticeship do
  command :assign_company
  command :assign_user
  command :change_name
  command :change_description
  command :publish

  guard(:company_assigned) { payload.company_id.present? }
end

aggregate.create_apprenticeship(company_id:, user_id:, name:, description:)
# => Yes::Core::Commands::CommandGroupResponse(cmd:, events: [...], error: nil)

How it runs

  • All sub-events publish inside a single PgEventstore.client.multiple block at SERIALIZABLE isolation — atomic by transaction, with pg_eventstore retrying SSI conflicts transparently.
  • First sub-event publishes via EventPublisher, getting both:
    • own-stream expected_revision = read_model.revision optimistic locking, and
    • external-aggregate revision verification via the AggregateTracker populated by the group's GuardEvaluator (covers attribute :foo, :aggregate access inside guards and payload-resolved aggregates via PayloadProxy).
      Drift on either surfaces as WrongExpectedRevisionError → the executor's outer rescue retries → guards re-evaluate against fresh state.
  • Subsequent sub-events use expected_revision: :any; revision sequencing comes for free inside the same multiple transaction.
  • Read-model updates run after the eventstore commit, in declaration order, so each sub-command's state-updater sees the cumulative state produced by the previous ones.
  • set_pending_update_state provides the read-model-side mutex (same mechanism as per-command commands).

Scope

  • Single-aggregate groups only. Cross-aggregate groups continue to use the legacy stateless Yes::Core::Commands::Group / GroupHandler, which are untouched. The shared infrastructure (CommandGroup, CommandGroupResponse, executor patterns) was designed to keep the cross-aggregate top-level form (planned future addition in app/command_groups/) an incremental layer.
  • Sub-command guards (including the auto-injected :no_change and the :not_removed aggregate auto-block) are fully bypassed when running the group.
  • No envelope event is emitted for the group itself — only the sub-commands' existing events.

Generated artifacts (per command_group :foo)

Class / Method Namespace
Command Context::Aggregate::CommandGroups::Foo::Command (subclass of Yes::Core::Commands::CommandGroup)
GuardEvaluator Context::Aggregate::CommandGroups::Foo::GuardEvaluator (subclass of Yes::Core::CommandHandling::GuardEvaluator)
Aggregate#foo(payload, guards:, metadata:) invocation method
Aggregate#can_foo?(payload) guards-only predicate
Aggregate#foo_error error accessor mirroring per-command pattern

Sub-command symbols are resolved lazily, so the group can be declared before or after the individual commands. A TracePoint(:end) hook on the subclass validates all referenced sub-commands exist at end of class body.

Test DSL

command_group 'name' do … end block with success_group, invalid_group, no_change_group helpers, plus three new shared examples mirroring the per-command DSL. Draft mode supported via draft: true.

Commits

  1. refactor — Extract GroupPayloadNormalizer so the legacy stateless Group and the new CommandGroup share the three-form payload normalization (flat / subject-nested / context-nested).
  2. featcommand_group DSL: data + definer, class resolvers, method definers, configuration + command_utils helpers, Aggregate.command_group macro, CommandGroupHandler + CommandGroupExecutor, test DSL extension, integration + unit specs.
  3. docs — Add Command Groups section and Command Group Test DSL section to the main README, with ToC updates.

Test plan

  • bundle exec rspec from yes-core/ — full suite green: 1159 examples, 0 failures (44 new examples across 6 new spec files).
  • bundle exec rubocop from the umbrella repo on all touched files — 0 offenses.
  • Concurrency hardening tests in command_group_executor_spec.rb cover:
    • first sub-event routed through EventPublisher with accessed_external_aggregates,
    • subsequent events sequence as stream revisions 0, 1, 2,
    • simulated stale snapshot triggers retry and eventually succeeds,
    • persistent failure exhausts MAX_RETRIES and re-raises,
    • GuardEvaluator is reconstructed on each retry (proves guards re-evaluate).
  • Integration spec (command_group_handler_spec.rb) hits real PG eventstore + AR read model: happy path, group-guard failure, sub-command guard bypass, read-model cumulative state, can_<group_name>? predicate.

Known follow-ups (not part of this PR)

  • Outer ActiveRecord::Base.transaction around the read-model update loop, to rollback partial application on a per-sub failure.
  • Top-level app/command_groups/ form for cross-aggregate groups.
  • Group-level authorize do … end block.
  • Dispatcher-side entry point so groups can be enqueued.
  • AggregateTracker coverage gaps for direct AR queries and manually-constructed aggregates inside guard blocks — these exist for the per-command flow too and warrant a separate workstream.

ncri and others added 4 commits May 16, 2026 18:19
Pulls the three-form payload normalization (flat / subject-nested /
context-nested) out of `Yes::Core::Commands::Group#normalized_payloads`
into a standalone module `Yes::Core::Commands::GroupPayloadNormalizer`.

The legacy stateless `Group` now delegates to it. Behavior is preserved
(existing Group + GroupHandler specs unchanged). The upcoming
`Yes::Core::Commands::CommandGroup` reuses the same normalizer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a `command_group :name do … end` macro to `Yes::Core::Aggregate`,
mirroring the per-command `command` macro. The macro produces a single
callable on the aggregate that runs several existing aggregate commands
as one atomic, transactionally-published unit.

Behavior:

- `command :sub_name` inside the block lists existing aggregate commands
  by symbol; declaration order is execution order. Sub-command guards
  are fully bypassed.
- `guard(:name) { … }` declares group-level guards using the same DSL
  as per-command guards. Only these run.
- All sub-events publish inside a single `PgEventstore.client.multiple`
  block at serializable isolation — atomic by transaction, with
  pg_eventstore handling SSI conflicts transparently.
- The first sub-event publishes via `EventPublisher`, getting:
    * own-stream `expected_revision = read_model.revision` optimistic
      locking;
    * external-aggregate revision verification via the
      `AggregateTracker` populated by the group's GuardEvaluator
      (covers `attribute :foo, :aggregate` access in guards and
      payload-resolved aggregates via `PayloadProxy`).
  Drift on either surfaces as `WrongExpectedRevisionError` → outer
  rescue retries → guards re-evaluate against fresh state.
- Subsequent sub-events use `expected_revision: :any`; stream-revision
  sequencing comes for free inside the same `multiple` transaction.
- Read-model updates run after the eventstore commit, in declaration
  order, so each sub-command's state-updater sees the cumulative state
  produced by the previous ones.
- `set_pending_update_state` provides the read-model-side mutex (same
  mechanism as per-command).

Generated per `command_group :foo`:

- `Context::Aggregate::CommandGroups::Foo::Command` (Yes::Core::Commands::CommandGroup subclass)
- `Context::Aggregate::CommandGroups::Foo::GuardEvaluator` (Yes::Core::CommandHandling::GuardEvaluator subclass)
- `Aggregate#foo(payload, guards:, metadata:)` and `Aggregate#can_foo?(payload)`
- `Aggregate#foo_error` accessor

Sub-command symbols are resolved lazily so the group can be declared
before or after the individual commands. A `TracePoint(:end)` hook
validates all referenced sub-commands exist at end of class body.

Payload uses the same three-form normalization as the legacy stateless
`Yes::Core::Commands::Group` (flat / subject-nested / context-nested),
extracted into `Yes::Core::Commands::GroupPayloadNormalizer`.

Test-DSL extension: `command_group 'name' do … end` block with
`success_group`, `invalid_group`, `no_change_group` helpers and three
new shared examples mirroring the per-command DSL.

Initial scope is single-aggregate groups. The legacy stateless
`Yes::Core::Commands::Group` / `GroupHandler` are untouched and continue
to serve cross-aggregate use cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds two README sections covering the new command_group construct:

- `### Command Groups` under Aggregate DSL (after Guards). Covers the
  macro, payload model, `can_<group_name>?` predicate, response shape,
  concurrency semantics, and generated artifacts.
- `### Command Group Test DSL` under Testing. Covers `command_group`,
  `success_group`, `invalid_group`, `no_change_group`, the auto-defined
  lets, and the `draft: true` option.

ToC updated with both entries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…mments

Removes two "see plan" pointers that referenced a session-scoped planning
document not present in the repo. The surrounding comments already
explain the design intent — sub-command guards are bypassed; read-model
updates are intentionally not wrapped in an outer AR transaction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ncri ncri merged commit 7923d31 into main May 18, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant