feat(yes-core): add command_group DSL to aggregate#28
Merged
Conversation
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>
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a
command_group :name do … endmacro toYes::Core::Aggregate, mirroring howcommandworks. 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.How it runs
PgEventstore.client.multipleblock at SERIALIZABLE isolation — atomic by transaction, with pg_eventstore retrying SSI conflicts transparently.EventPublisher, getting both:expected_revision = read_model.revisionoptimistic locking, andAggregateTrackerpopulated by the group'sGuardEvaluator(coversattribute :foo, :aggregateaccess inside guards and payload-resolved aggregates viaPayloadProxy).Drift on either surfaces as
WrongExpectedRevisionError→ the executor's outer rescue retries → guards re-evaluate against fresh state.expected_revision: :any; revision sequencing comes for free inside the samemultipletransaction.set_pending_update_stateprovides the read-model-side mutex (same mechanism as per-command commands).Scope
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 inapp/command_groups/) an incremental layer.:no_changeand the:not_removedaggregate auto-block) are fully bypassed when running the group.Generated artifacts (per
command_group :foo)CommandContext::Aggregate::CommandGroups::Foo::Command(subclass ofYes::Core::Commands::CommandGroup)GuardEvaluatorContext::Aggregate::CommandGroups::Foo::GuardEvaluator(subclass ofYes::Core::CommandHandling::GuardEvaluator)Aggregate#foo(payload, guards:, metadata:)Aggregate#can_foo?(payload)Aggregate#foo_errorSub-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 … endblock withsuccess_group,invalid_group,no_change_grouphelpers, plus three new shared examples mirroring the per-command DSL. Draft mode supported viadraft: true.Commits
GroupPayloadNormalizerso the legacy statelessGroupand the newCommandGroupshare the three-form payload normalization (flat / subject-nested / context-nested).command_groupDSL: data + definer, class resolvers, method definers, configuration + command_utils helpers,Aggregate.command_groupmacro,CommandGroupHandler+CommandGroupExecutor, test DSL extension, integration + unit specs.Test plan
bundle exec rspecfromyes-core/— full suite green: 1159 examples, 0 failures (44 new examples across 6 new spec files).bundle exec rubocopfrom the umbrella repo on all touched files — 0 offenses.command_group_executor_spec.rbcover:EventPublisherwithaccessed_external_aggregates,MAX_RETRIESand re-raises,GuardEvaluatoris reconstructed on each retry (proves guards re-evaluate).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)
ActiveRecord::Base.transactionaround the read-model update loop, to rollback partial application on a per-sub failure.app/command_groups/form for cross-aggregate groups.authorize do … endblock.AggregateTrackercoverage 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.