Skip to content

feat(yes-command-api): dispatch aggregate-DSL command groups#29

Merged
ncri merged 7 commits into
feat/command-group-aggregate-dslfrom
feat/command-group-command-api-integration
May 18, 2026
Merged

feat(yes-command-api): dispatch aggregate-DSL command groups#29
ncri merged 7 commits into
feat/command-group-aggregate-dslfrom
feat/command-group-command-api-integration

Conversation

@ncri
Copy link
Copy Markdown
Contributor

@ncri ncri commented May 16, 2026

Summary

Makes HTTP POST /v1/commands requests targeting an aggregate-DSL command_group route end-to-end the same way regular commands do.

Stacked on top of #28 (the command_group DSL itself). Base branch is feat/command-group-aggregate-dsl; once #28 merges this PR will retarget main automatically.

POST /v1/commands
{
  "commands": [{
    "context": "Companies",
    "subject": "Apprenticeship",
    "command": "CreateApprenticeship",
    "data": { "company_id": "...", "user_id": "...", "name": "Acme", "description": "..." }
  }]
}

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 Group pattern)

1. Deserializer (yes-command-api/lib/yes/command/api/commands/deserializer.rb)

Adds a command_group_v2_class candidate matching <Context>::<Subject>::CommandGroups::<Name>::Command — the path the command_group DSL macro generates. Tried between V2 and the legacy top-level fallback so existing resolution paths still work.

2. CommandsController#expand_commands

Now unwraps Yes::Core::Commands::CommandGroup in addition to the legacy Group. Each sub-command's existing per-command Authorizer and Validator runs individually via BatchAuthorizer / BatchValidator — no group-level authorizer or validator class needs to exist. The wrapped originals still go to command_bus.call, so the Processor dispatches each group as a single atomic unit.

3. Yes::Core::Commands::Processor#run_command

Passes cmd.payload (flat) when the command is a CommandGroup; keeps cmd.to_h for regular commands. Extracts reinstantiate_with_reserved_keys to handle the commands.map! step that injects origin/batch_id without round-tripping a CommandGroup through its nested to_h form.

4. Supporting fix: CommandGroup#to_h returns flat form

PR #28 had CommandGroup#to_h return the nested per-context/per-subject form (mirroring the legacy Group). That broke round-tripping: Class.new(cmd.to_h) produced a group whose payload was nested — which then dispatched incorrectly. This PR's first commit fixes that so to_h and payload agree on the flat shape. Affects:

  • Yes::Core::ActiveJobSerializers::CommandGroupSerializer serialize/deserialize round-trip.
  • CommandsController#add_metadata round-trip.

Auth & validate strategy (mirror of legacy)

expand_commands produces the unwrapped list for BatchAuthorizer / BatchValidator; command_bus.call receives 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 stateless Group already uses.

A future group-level authorize do … end block inside command_group { … } is a deferred follow-up; it would slot on top of this without breaking it.

Commits

  1. fix(yes-core): make CommandGroup#to_h round-trip cleanly (flat form).
  2. feat(yes-core): dispatch CommandGroup with its flat payload via Processor.
  3. feat(yes-command-api): Deserializer + expand_commands + dummy fixtures + request spec + deserializer spec.
  4. docs: README note about HTTP invocation.

Test plan

  • bundle exec rspec in yes-core1163 examples, 0 failures.
  • bundle exec rspec in yes-command-api185 examples, 0 failures (6 new request-spec cases + 1 new deserializer-spec case + 2 new Processor-spec cases).
  • bundle exec rubocop from the umbrella on all touched Ruby files — 0 offenses.
  • New request spec 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.

Out of scope (planned follow-ups)

  • Group-level authorize do … end DSL inside command_group { … }.
  • Group-level validator DSL.
  • Top-level app/command_groups/ cross-aggregate form (still goes through the legacy stateless Group path).

ncri and others added 7 commits May 16, 2026 23:51
`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]>
@ncri ncri merged commit f464ee0 into feat/command-group-aggregate-dsl 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