Skip to content

feat: support union type identifiers for aggregates and sagas#683

Open
dgafka wants to merge 2 commits into
mainfrom
worktree-union-identifiers
Open

feat: support union type identifiers for aggregates and sagas#683
dgafka wants to merge 2 commits into
mainfrom
worktree-union-identifiers

Conversation

@dgafka

@dgafka dgafka commented Jul 17, 2026

Copy link
Copy Markdown
Member

Why is this change proposed?

Aggregates and sagas built around multiple identifier sources — e.g. an internally generated ID alongside one supplied by an external system — need a single identifier property that can hold either, without an artificial wrapper type. Ecotone lets #[Identifier] properties use any PHP type, but native union types (InternalId|ExternalId) had never been verified or documented as supported end-to-end.

Description of Changes

  • Confirmed union type identifiers work across every identifier-resolution mechanism: native mapping (in-memory and via async channel), aggregate.id metadata override, and #[TargetIdentifier] mapping.
  • Confirmed event sourcing needs one extra step: JMS Serializer (used for event-store JSON) can't tell union members apart without a discriminator, so replay fails with a raw Error on an uninitialized property unless the union member classes carry their own discriminator field and the event property declares #[UnionDiscriminator].
  • Added end-to-end tests (including a real event-store round trip) proving this pattern works today, with no framework changes needed.

Example

final class InternalId
{
    public function __construct(private string $id) {}
    public function __toString(): string { return $this->id; }
}

final class ExternalId
{
    public function __construct(private string $id) {}
    public function __toString(): string { return $this->id; }
}

#[Aggregate]
final class Ticket
{
    #[Identifier]
    private InternalId|ExternalId $ticketId;

    #[CommandHandler]
    public static function create(CreateTicket $command): self
    {
        $ticket = new self();
        $ticket->ticketId = $command->ticketId;

        return $ticket;
    }
}

For event-sourced aggregates, add a discriminator field on each union member and point #[UnionDiscriminator] at it, so JMS can rebuild the right class on replay:

final class InternalId
{
    public string $type = 'internal';

    public function __construct(private string $id) {}
    public function __toString(): string { return $this->id; }
}

final class TicketWasCreated
{
    public function __construct(
        #[UnionDiscriminator(field: 'type', map: ['internal' => InternalId::class, 'external' => ExternalId::class])]
        public readonly InternalId|ExternalId $ticketId
    ) {}
}

Pull Request Contribution Terms

  • I have read and agree to the contribution terms outlined in CONTRIBUTING.

dgafka added 2 commits July 17, 2026 08:00
Characterizes union-typed identifier support (e.g. InternalId|ExternalId)
across every identifier-resolution mechanism, and documents the one gap:
event sourcing needs JMS's UnionDiscriminator to disambiguate on replay.
…wns them

CI runs each package against only its own declared dependencies, so the
event-sourcing tests failed there even though they passed locally, where
every package shares one vendor tree. packages/Ecotone has no event
sourcing dependency and packages/JmsConverter had none either, so both
the failing-without-discriminator and working-with-discriminator cases
now live in JmsConverter, which declares ecotone/pdo-event-sourcing as a
require-dev dependency for exactly this kind of integration test.
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