Same surrogate ID for aggregates in different bounded contexts

Hello,

this may not be an Axon-specific question, but I’ve decided to post it here anyway.

Short version

Is it possible for event-sourced aggregates, from different bounded contexts, to have the same surrogate ID in the same EventStore?

Longer version

Let’s say I have 2 Product aggregates one in Marketing and the other in Sales bounded context which are described by a different set of attributes and business rules. Conceptually Product lifecycle is owned by the Marketing context and when it is created there, the corresponding aggregate in the Sales context is also created automatically. There is also an API layer on top of these bounded contexts for uniform access.

For the full picture, let’s consider the following example:

  1. API client invokes POST /products. API layer generates ID X for the Product aggregate, dispatches the CreateProduct command to the Marketing context and returns X back in the response.
  2. When CreateProduct command is handled in the Marketing context, ProductCreated event is published, to which Sales context reacts with creating a Product in the Sales context too (= publishes ProductCreatedInSales).
  3. API client then wants to assign price to the product by invoking PUT /products/X/price, which in turn dispatches AssignPriceToProduct command to Product in Sales context, with X as the target aggregate identifier.
  • In the 2-nd step above, if Product in the Sales context is created with the same ID X as the ID of the Product from the Marketing context, then I get the following exception when handling AssignPriceToProduct command:

    org.axonframework.eventsourcing.IncompatibleAggregateException: Aggregate identifier must be non-null after applying an event. Make sure the aggregate identifier is initialized at the latest when handling the creation event.

    I believe it’s because, ProductCreated event (from the Marketing context), with ID X is loaded from the event store, but it is not actually applied to Sales Product because event(-sourcing) handler does not expect the ProductCreated type (but rather a ProductCreatedInSales). Which is expected behavior.

  • If Product in the Sales context is created with a different ID Y, then AxonFramework won’t be able to route the AssignPriceToProduct command to the aggregate with target identifier X (which is the only ID known to the API client).

So far I see the following options to address this issue:

  1. Let these 2 aggregates have different surrogate IDs and let the API layer maintain the lookup table/cache for translating one to another before dispatching the commands. This can be achieved if API will listen to ProductCreatedInSales event which can contain both IDs, update the lookup table which can be queried later on by custom CommandTargetResolver implementation. But this seems to be a bit complicated solution.
  2. Let these 2 aggregates have the same surrogate IDs, but store them in different instances of EventStores (or different Contexts in case of AxonServer). This brings additional pain because my application is designed to be a monolith (at least initially).
  3. Do not create a Product in the Sales context automatically, let the client explicitly create it with a different ID, which will be known for AssignPriceToProduct command routing. This would effectively mean API per Bounded Context, which I’d like to avoid, because in reality this API is Backend-for-Frontend, and there is only one notion of Product in the UI, which should be created only once.

I have a feeling that I’m missing something simple. Either in the modeling/approach or in the implementation/AxonFramework. And surprisingly I couldn’t manage to find any useful resource on that matter. Your directions will be much appreciated.

I discovered that Aggregate annotation has a special parameter called filterEventsByType which, at first sight, seems to be what I need. Thanks to that, I was able to stick to the same ID for 2 aggregates of different types approach. Everything worked like a charm, all the tests passed! But unfortunately, this works only for the in-memory storage engine. AxonServer fails with

OUT_OF_RANGE: [AXONIQ-2000] Invalid sequence number 0 for aggregate a461158d-b9f0-4d21-b259-8313cc987da8, expected 1

this happens in ProductCreated event handler when I’m trying to create the product in the Sales context.

@Component
class ProductCreatedHandler {

    @EventHandler
    fun on(e: ProductCreated, commandGateway: CommandGateway) {
        commandGateway.sendAndWait<Void>(CreateProductInSales(e.id))
    }
}
@Aggregate(filterEventsByType = true)
class SalesProduct {

    @AggregateIdentifier
    private lateinit var id: UUID

    constructor()

    @CommandHandler
    constructor(c: CreateProductInSales) {
        apply(ProductCreatedInSales(c.id))
    }

    @EventSourcingHandler
    fun on(e: ProductCreatedInSales) {
        id = e.id
    }
}

Looks like AxonServer rejects the CreateProductInSales command because an event (for MarketingProduct type) with the same aggregate identifier as c.id already exists in the store.

But, is this behavior valid? Should not AxonServer respect the filterEventsByType annotation when detecting a gap in sequence numbers?

One workaround that I found is that instead of attempting to create Product aggregate again in the sales context, simply “source” the creation event from the Marketing context

@Aggregate
class SalesProduct {

    @AggregateIdentifier
    private lateinit var id: UUID

    constructor()

    @CommandHandler
    fun handle(c: AssignPriceToProduct) {
        apply(PriceAssignedToProduct(id, c.price))
    }

    @EventSourcingHandler
    fun on(e: ProductCreated) { // event from Marketing context
        id = e.id
    }
}

However, this is not a preferable way, because now ProductCreated is used as state-change (application) and integration (domain) event at the same time, which increases the coupling between Bounded Contexts (in my previous posts ProductCreated in Sales context can be assumed to be an integration event).

P.S. referring to my previous comment

But, is this behavior valid? Should not AxonServer respect the filterEventsByType annotation when detecting a gap in sequence numbers?

I’m guessing that this filtering is most likely done at the AxonFramework side, so AxonServer isn’t actually aware of this annotation. The real question here is if this particular example is the right application for filterEventsByType parameter?

The combination of “one monolith” with “several bounded contexts” doesn’t completely feel right, to be honest. When it comes to bounded contexts, you would typically have segregation of teams, repositories, databases, coding language, let alone what both those context’s their domain language (the messages) are.

It is from that point of view that Axon Server’s multi-context solution, through which you could design several contexts using the same infrastructure, segregates the message streams and event stores entirely. You wouldn’t want the traffic of another bounded context to flow over the same lane, as the concepts of context-one can completely deviate from context-two.

The above does not help you directly however, except for the fact that if you would use Axon Server Enterprise and define a Sales and Marketing context. Assuming you’re using the standard edition for the time being, I think it’s good to look at another solution.

The filterEventsByType field in the Aggregate annotation indeed would fit the bill, but wouldn’t work in combination with Axon Server. This feature stems from a migration requirement, where a user comes from an Axon 2 based application and moves up to the current version. This field was added as Axon 2 has a uniqueness constraint on the Event Store comprising of the aggregateIdentifier, sequenceNumber and aggregateType. As of Axon 3 (and thus Axon Server), the constraint is between the aggregateIdentifier and sequenceNumber only. So the filter would only work if you have a legacy, non-Axon Server event store (this should be documented more clearly though, my apologies for this info not being present).

Turning back to your original set of suggestions, this essentially gives you option 1 (look-up table in the controller) and option 2 (distinct event stores per context (actually what Axon Server Enterprise does). There is however a third option you could take. The uniqueness constraint is, as stated, on the aggregateIdentifier and the sequenceNumber. You wouldn’t really have any control over the sequenceNumber, but you do over the aggregateIdentifier. When storing an event, Axon will invoke the toString() method on the @AggregateIdentifier annotated field. Since you can define the type of the @AggregateIdentifier annotated field, you can thus control what the toString() method would return.

This would land you up with a solution where you’d introduce typed identifiers. To make this into the solution of your problem, the typed identifier class would not only return the (uu)id on the toString() invocation, but it should append the aggregate type to it. Doing so will ensure that whenever the framework loads your aggregate, it would use the TypedId#toString() method to check which aggregate to retrieve. Furthermore, you wouldn’t have any clashes anymore in the event store with this solution.

So, to round up, I actually think there are 4 options out there:

  1. Have distinct Event Stores per context, which you would likely want eventually any way. Axon Server Enterprise can help you with that.
  2. Used Typed Aggregate Identifiers which return a combined result of id+type on the toString(). Regardless of whether you take this route through customizing the toString() operation, I would recommend using a Typed Aggregate Identifier any how.
  3. Construct a custom look-up table solution. Would work, might be error prone to ensure this is as up-to-date as possible in the eventually consistent world you’re in. Dealing with set-based-validation, to ensure an identifier is not accidentally used twice, would likely be necessary in this case.
  4. Pretty straightforward, but simply not reuse the aggregate identifier.

I hope all of the above makes sense @Beka_Tsotsoria. And, that it clarifies what options you have. If anything from my comment is unclear, please do not hesitate to provide a response.