Skip to content

On-Demand (Transient) Projections by Aggregate ID #589

@sabenamor

Description

@sabenamor

Problem

In Event Sourcing / CQRS architectures, event-triggered side-effects (emails, documents, webhooks, integrations, etc.) often need access to a consistent read view that is not contained in the triggering event and may evolve over time.

Common approaches have known trade-offs:

  • async persistent projections → eventual consistency
  • rehydrating aggregates → coupling to the write model + unnecessary state
  • enriching events → bloated and rigid event contracts

Ecotone currently has no first-class mechanism to build a minimal read model on demand, scoped to a single aggregate, without persistence.


Proposal

Introduce On-Demand (Transient) Projections as a new, optional module.

These projections would be:

  • built on demand
  • partitioned by Aggregate ID
  • in-memory only (no persistence)
  • replaying only events for the targeted aggregate
  • defined declaratively (attributes)
  • suitable for side-effects and internal logic
  • consistent with the Event Store at execution time

The goal is to provide a lightweight, strongly scoped alternative to persistent projections, without impacting existing projection semantics.


Example (realistic use case)

An OrderPlaced event triggers a confirmation email.
Customer contact data is owned by another aggregate and evolves over time
(CustomerEmailUpdated, CustomerAddressUpdated, etc.).

We want the current customer state:

  • without bloating OrderPlaced
  • without rehydrating the Customer write model
  • without relying on async projections

On-demand projection for the Customer aggregate

#[OnDemandProjection(stream: 'customer_stream', partitionBy: 'aggregateId')]
final class CustomerContactView
{
    public string $email;
    public string $fullName;
    public ?string $address = null;

    #[ProjectEvent]
    public function whenCustomerWasCreated(CustomerWasCreated $e): void
    {
        $this->fullName = $e->fullName;
        $this->email = $e->email;
    }

    #[ProjectEvent]
    public function whenCustomerEmailUpdated(CustomerEmailUpdated $e): void
    {
        $this->email = $e->email;
    }

    #[ProjectEvent]
    public function whenCustomerAddressUpdated(CustomerAddressUpdated $e): void
    {
        $this->address = $e->address;
    }
}

Used from another event handler

final class OrderConfirmationEmailHandler
{
    public function __construct(
        private OnDemandProjectionBuilder $builder,
        private MailerInterface $mailer
    ) {}

    #[EventHandler]
    public function handle(OrderPlaced $event): void
    {
        $customer = $this->builder->build(
            CustomerContactView::class,
            aggregateId: $event->customerId,
            options: [
                // Optional:
                // rebuild state at a specific point in time
                // 'upToVersion' or 'upToTimestamp'
            ]
        );

        $this->mailer->sendOrderConfirmation(
            to: $customer->email,
            name: $customer->fullName,
            address: $customer->address,
            orderId: $event->orderId
        );
    }
}

Note: rebuilding state at a specific point in time is optional.
The feature remains useful without version or timestamp bounding.

Non-goals / Limits

  • single aggregate partition only (no cross-aggregate joins)
  • no persistence
  • replay per call (intended for bounded streams)
  • not a replacement for persistent projections

Implementation Positioning

This feature is intended to be implemented as a new, dedicated, optional module
(e.g. OnDemandProjection), rather than an extension of the existing projection system.

This keeps:

  • a clear and limited scope
  • no ambiguity with persistent projections
  • no impact on existing functionality

Questions

  1. Does this proposal align with Ecotone’s current vision and roadmap for Event Sourcing / CQRS?
  2. Are there any known constraints or ongoing efforts that should be considered before moving forward with this module?

I would be happy to contribute a full implementation (code, tests, documentation) as this new module if the direction aligns with the project goals.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions