Sunday, March 15, 2015

Scaling promotion codes

In our system a backoffice user can issue a promotion code for users to redeem. Redeeming a promotion code, a user receives a discount on his next purchase or a free gift. A promotion code is only active for a limited amount of time, can only be redeemed a limited amount of times and can only be redeemed once per user.

In code these requirements translated into a promotion code aggregate which would guard three invariants.

The command handler looked something like this.

Depending on the promotion code, we would often have a bunch of users doing this simultaneously, leading to a hot aggregate, leading to concurrency exceptions.

Studying the system, we discovered that the limit on the amount of times a promotion code could be redeemed was not being used in practice. Issued promotion codes all had the limit set to 999999. Just by looking at production usage, we were able to remove an invariant, saving us some trouble.

The next invariant we looked at, is the one that avoids users redeeming a promotion code multiple times. Instead of this being part of the big promotion code aggregate, a promotion code redemption now is a separate aggregate. The promotion code aggregate now picks up a new role; the role of a factory, it decides on the creation of new life.

The promotion code redemption's identifier is a composition of the promotion code identifier and the user identifier. Thus even when the aggregate is stored as a stream, we can check in a consistent fashion whether the aggregate (or stream) already exists, avoiding users redeeming a promotion code multiple times. On creation of the stream, the repository can pass to the event store that it expects no stream to be there yet, making absolutely sure we don't redeem twice. The event store would throw an exception when it would find a stream to already exist (think unique key constraint).

In this example, we were able to remove an annoying and expensive invariant by looking at the data. Even if we had to keep supporting promotion code depletion, we might have removed this invariant and replaced it with data fed into the aggregate/factory from the read model. Ask yourself, how big is the cost of having a few more people redeem a promotion code? Teasing apart the aggregate even further, we discovered that the promotion code had a second role; a creational role. It now helps us spawning promotion code redemptions while still making sure this only happens when the promotion code is active. Each promotion code redemption is now a new short-lived aggregate, while the promotion code itself stays untouched. By checking the existence of the aggregate up front and by using the stream name to enforce uniqueness, we avoid users redeeming a promotion code more than once. This has allowed us to completely avoid contention on the promotion code, making it perform without hiccups.

9 comments:

  1. Usually Maybe is a functor (and a monad, but we only need functor here):

    var promotionCodeRedemptionId = new PromotionCodeRedemptionId(promotionCodeId, userId);

    return promotionCode
    .Redeem(userId)
    .Select(_promotionCodeRedemptionRepository.Add)
    .Select(_ => RedeemPromotionCodeStatus.Success())
    .GetOrElse(RedeemPromotionCodeResponse.Unavailable())

    ReplyDelete
    Replies
    1. That's nice. To be honest, not that much FP style has sneaked into this C# code base.

      Delete
  2. This is very interesting.
    Just wanted to understand how the returns will be handled. We have a use case where the promotion code will be restoed to the customer's account on the return of a purchase over which the redemptions happened.

    ReplyDelete
  3. Nice post. I'm not completely comfortable with the new PromotionCodeRedemptionId aggregate though. On the one hand, you've removed the contention on the original aggregate. On the other hand, it's still an invariant that needs to be maintained, but now across different aggregates. That makes me a bit nervous, since that's what aggregates are supposed to be for. It feels like there's still a "logical" aggregate somewhere, even though it's implemented by multiple physical ones. That could lead to a disconnect between model and code. What are your thoughts on that?

    ReplyDelete
    Replies
    1. Would you have the same concerns if we were talking about username uniqueness?

      Delete
    2. Actually, both yes and no. Yes because it's technically the same. No because there is a difference: the key (promotion,username) is compound instead of simple. I think aggregates with a simple key are generally understood and it's almost implied that the data store make sure we can't have 2 aggregates with the same id. This also pretty easily understood by customers. I'm not sure if that's the same with the compound key approach, i.e. are you able to shift your and the customer's mental model to this implementation? Or are you still thinking in terms of a promotion having a list of users that already have redeemed it?

      Delete
    3. I see where you're getting at. To be honest, this space hasn't gotten much love from a domain expert, allowing us to make these changes without putting too much effort into selling it.

      Delete
    4. However, if this had been the case, I think we would have reached the same solution using pen, paper and some role playing :-)

      Delete
    5. Ok, that makes sense. I also tend to use the DDD tactical patterns even though there's no domain expert around. Thank you for your answers.

      Delete