feat(yes-command-api): dispatch aggregate-DSL command groups#29
Merged
ncri merged 7 commits intoMay 18, 2026
Merged
Conversation
`CommandGroup#to_h` previously returned the NESTED per-context/per-subject form (mirroring legacy `Yes::Core::Commands::Group#to_h`). That broke round-tripping: `Class.new(cmd.to_h)` produced a group whose `payload` was nested — but the aggregate's group method expects the FLAT form. This surfaces wherever the command travels through code that reconstructs via `cmd.class.new(cmd.to_h.merge(...))`, notably: - `Yes::Core::ActiveJobSerializers::CommandGroupSerializer#serialize` → `#deserialize` round-trip, - `Yes::Command::Api::V1::CommandsController#add_metadata` round-trip for command groups. Both of those now produce a group with the correct flat `payload`. The legacy stateless `Yes::Core::Commands::Group` is untouched. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…ssor `Yes::Core::Commands::Processor#run_command` calls `aggregate.public_send(name, payload, guards:)` to invoke the aggregate's command method. Previously it always passed `cmd.to_h`, which for a `CommandGroup` returns the nested per-context/per-subject payload. Aggregate group methods expect the FLAT form (mirroring direct Ruby invocation: `aggregate.create_apprenticeship(company_id:, user_id:, …)`). Use `cmd.payload` (flat input minus reserved keys) when the command is a `CommandGroup`; keep `cmd.to_h` for regular commands (Dry::Struct's `to_h` is already the flat attribute hash). Also extracts `Processor#reinstantiate_with_reserved_keys` from the `commands.map!` step that injects origin/batch_id. The legacy `cmd.class.new(cmd.to_h.merge(...))` pattern round-tripped through the nested form for groups and produced a group whose `payload` was nested — breaking subsequent dispatch. The new helper round-trips groups through their FLAT `payload` and uses `to_h` for regular commands. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Makes HTTP `POST /v1/commands` requests targeting an aggregate-DSL `command_group` route end-to-end the same way regular commands do. Three small changes line up with the legacy stateless `Group` pattern: 1. **Deserializer** — add a `command_group_v2_class` candidate matching `<Context>::<Subject>::CommandGroups::<Name>::Command` (the path the `command_group` DSL macro generates). Tried after the V2 command path and before the legacy top-level group fallback. 2. **CommandsController#expand_commands** — also unwrap `Yes::Core::Commands::CommandGroup` (in addition to the legacy `Group`) so its sub-commands flow through `BatchAuthorizer` / `BatchValidator`. Each sub-command's existing per-command Authorizer and Validator run individually — no group-level authorizer or validator class needs to exist. The wrapped originals (`deserialize_commands`) still go to `command_bus.call`, so the Processor dispatches each group as a single atomic unit. 3. Test fixtures — add `Dummy::Activity::CommandGroups::DoTwoThings::Command` plus a method-missing path on `Dummy::Activity::Aggregate` that recognises group methods and returns a `CommandGroupResponse`. Register the dummy sub-commands in the configuration registry so `CommandGroup#sub_command_classes` can resolve them. 4. Specs — Deserializer spec exercises the new resolution candidate. Request spec posts a `DoTwoThings` action, asserts the group method is dispatched once on the aggregate with the FLAT payload (not the nested form), and that sub-command authorizers are invoked. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Adds a short note at the end of the Command Groups section explaining that groups can be invoked over HTTP exactly like regular commands: same request shape, group name as `command`, flat payload in `data`. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
After the earlier fix that makes `CommandGroup#to_h` return the flat form (payload merged with reserved keys, round-trippable through `Class.new(to_h)`), the `cmd.is_a?(CommandGroup)` branch in `Processor#run_command` is redundant — `cmd.to_h` already produces the shape the aggregate's group method expects. Bonus: dropping `reinstantiate_with_reserved_keys` and going back to the direct `cmd.class.new(cmd.to_h.merge(origin:, batch_id:))` means CommandGroups now ALSO get origin/batch_id propagated through the reserved-key channel — previously the helper used `cmd.payload` which stripped the reserved keys before merging, losing the original command_id, metadata, and transaction. Updates the related Processor and request-spec test descriptions to drop the now-misleading `cmd.payload` references. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
`Yes::Core::ActiveJobSerializers::CommandGroupSerializer#serialize?` only matched the legacy `Yes::Core::Commands::Group`, so the new aggregate-DSL `Yes::Core::Commands::CommandGroup` instances would fall through to the default ActiveJob serializer, which can't round-trip a non-trivial object. Match both classes. Both round-trip cleanly via the `to_h` / `Class.new(symbolized_hash)` pair the serializer already uses (the aggregate-DSL form's `to_h` is the flat payload merged with reserved keys after the earlier fix). Adds a dedicated serializer spec covering: - `#serialize?` matches both group flavours and rejects regular commands, - the aggregate-DSL group round-trips: class, payload, reserved keys, and sub-command set are all preserved. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Two additional test areas suggested by code review: 1. **Reserved-key propagation through Processor#perform** for command groups. The simpler `cmd.class.new(cmd.to_h.merge(origin:, batch_id:))` re-instantiation path (introduced by the previous refactor) is supposed to preserve origin, batch_id, command_id, metadata, and transaction through to the aggregate's group method. New processor spec context exercises each of these. 2. **End-to-end ActiveJob round-trip** for command groups via the Command API. Adds a `running async (ActiveJob round-trip)` context to the aggregate-DSL command-group request spec that switches the queue adapter to `:test`, asserts a CommandGroupSerializer-tagged argument is enqueued, and uses `perform_enqueued_jobs` to actually run the queued Processor job — proving the serializer round-trips end-to-end and the dispatched group method on the (stubbed) aggregate receives the equivalent command. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
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
Makes HTTP
POST /v1/commandsrequests targeting an aggregate-DSLcommand_grouproute end-to-end the same way regular commands do.Stacked on top of #28 (the
command_groupDSL itself). Base branch isfeat/command-group-aggregate-dsl; once #28 merges this PR will retargetmainautomatically.End-to-end flow now: Deserializer → BatchAuthorizer (on unwrapped sub-commands) → BatchValidator (same) → command_bus.call (wrapped group intact) → Processor#run_command →
aggregate.create_apprenticeship(flat_payload, guards: true)→ atomic eventstore commit + read-model updates.Three small changes (mirroring the legacy stateless
Grouppattern)1. Deserializer (
yes-command-api/lib/yes/command/api/commands/deserializer.rb)Adds a
command_group_v2_classcandidate matching<Context>::<Subject>::CommandGroups::<Name>::Command— the path thecommand_groupDSL macro generates. Tried between V2 and the legacy top-level fallback so existing resolution paths still work.2.
CommandsController#expand_commandsNow unwraps
Yes::Core::Commands::CommandGroupin addition to the legacyGroup. Each sub-command's existing per-commandAuthorizerandValidatorruns individually viaBatchAuthorizer/BatchValidator— no group-level authorizer or validator class needs to exist. The wrapped originals still go tocommand_bus.call, so the Processor dispatches each group as a single atomic unit.3.
Yes::Core::Commands::Processor#run_commandPasses
cmd.payload(flat) when the command is aCommandGroup; keepscmd.to_hfor regular commands. Extractsreinstantiate_with_reserved_keysto handle thecommands.map!step that injects origin/batch_id without round-tripping aCommandGroupthrough its nestedto_hform.4. Supporting fix:
CommandGroup#to_hreturns flat formPR #28 had
CommandGroup#to_hreturn the nested per-context/per-subject form (mirroring the legacyGroup). That broke round-tripping:Class.new(cmd.to_h)produced a group whosepayloadwas nested — which then dispatched incorrectly. This PR's first commit fixes that soto_handpayloadagree on the flat shape. Affects:Yes::Core::ActiveJobSerializers::CommandGroupSerializerserialize/deserialize round-trip.CommandsController#add_metadataround-trip.Auth & validate strategy (mirror of legacy)
expand_commandsproduces the unwrapped list forBatchAuthorizer/BatchValidator;command_bus.callreceives the original list with the group wrapped. Each sub-command's existing per-command Authorizer and Validator runs. No group-level authorizer auto-generation needed — the same model the legacy statelessGroupalready uses.A future group-level
authorize do … endblock insidecommand_group { … }is a deferred follow-up; it would slot on top of this without breaking it.Commits
fix(yes-core): makeCommandGroup#to_hround-trip cleanly (flat form).feat(yes-core): dispatchCommandGroupwith its flat payload via Processor.feat(yes-command-api): Deserializer +expand_commands+ dummy fixtures + request spec + deserializer spec.docs: README note about HTTP invocation.Test plan
bundle exec rspecinyes-core— 1163 examples, 0 failures.bundle exec rspecinyes-command-api— 185 examples, 0 failures (6 new request-spec cases + 1 new deserializer-spec case + 2 new Processor-spec cases).bundle exec rubocopfrom the umbrella on all touched Ruby files — 0 offenses.Out of scope (planned follow-ups)
authorize do … endDSL insidecommand_group { … }.validatorDSL.app/command_groups/cross-aggregate form (still goes through the legacy statelessGrouppath).