Creating a ticket reservation system

We recently had a client come to us with an exciting challenge: creating a robust ticket purchasing system to allow a large number of customers to purchase tickets for time-limited products.
The Challenge
Ticket purchasing systems can be tricky to implement and required us to carefully consider and plan for the problems we could end up facing.
The initial set of requirements we explored were:
Avoid Race conditions - If two people place an order at the same time, we need to make sure they don’t both buy the same ticket number.
Error Handling - Any errors that occur throughout the user journey should be handled gracefully, ensuring once a payment is taken that tickets are assigned successfully to the user.
Seamless User Experience - It would be frustrating if a user adds some tickets to their basket, then get to the checkout page and suddenly they can no longer purchase their tickets because they have already been purchased by somebody else. The user should be made aware as early as possible whether a ticket is not available to reduce frustration.
The Naive Approach
To understand the problems stated above, let’s look at a naive implementation that does not take these problems into consideration.
The users journey might look something like this:

This immediately poses a few problems. This approach makes the assumption that the same number of tickets that were available when the user added the tickets to their basket, will also be available by the time they complete the checkout flow. This problem becomes more obvious as soon as we introduce a second user into the mix.


The diagram above shows two users concurrently interacting with the application, attempting to purchase some tickets. Let’s assume that there is only one ticket left and run a simulation to see what will happen.
Step 1 - User 1 adds a ticket to their basket, we check whether there are any tickets available, it returns successful and we take them to the basket page.
Step 2 - User 1 arrives at their basket page with their with their new ticket added to their cart, ready to be purchased by following the rest of the journey. Meanwhile, User 2 is now attempting to add the ticket to their basket. When we check to see if the tickets are available, this will also return successful as the last remaining ticket has not been allocated yet, despite it technically sitting in User 1’s basket. Now both User 1 and User 2 are attempting to purchase the same ticket.
Step n-1 - User 1 & 2, both submit their card details and we take a payment from the External Payment Provider. This returns successful for both user and we now take them to allocate their tickets.
Step n - User 1 is allocated ticket n (the last remaining ticket). There are now no longer any tickets remaining for the ticket but User 2 has already paid. The best case scenario in this situation would be that the system throws an error and provides a refund for User 2. The worst case scenario is that the system doesn’t care and just allocates the same ticket to User 2, leaving User 1 without any ticket despite them purchasing it!
Let’s adjust the diagram to match the best case scenario in Step n.

Adding a check before the tickets being allocated helps ensure that we can accurately display to the user whether their ticket is still available. However, charging back refunds every time this happens could end up being costly if the Payment Provider charges fees for refunds, alongside being extremely frustrating for a user that thought they just bought their tickets.
What if we move this new check forwards? So rather than checking after the payment provider has taken the payment, we check before. Let’s adjust the diagram once again to reflect this.

Great! So now we can check whether there are any tickets available just before the payment is taken and stop the user from making a payment if there are none. But wait… We’ve just re-introduced the same issue we had earlier. If two users are checking out at the same time, the system could return successful to both users as the tickets are available, and take the payment from each of them, only to run into the same situation in Step n as before.
The issue, if not clear already, is that there are “gaps” within our user flow where our system does not have guarantees about the flow’s consistency, such as whether the user submits a valid form, whether the payment provider throws an error or even whether the payment provider is currently available.
These steps where the system is passing “control” of the data to another system - be it waiting for the user entering a form, or the Payment Provider taking a payment - can be referred to as Side Effects. Side effects occur when an operation returns different outputs when given the same input. In fact, the act of reading the database itself to check for the number of available tickets would be considered a side effect, as we have no way of knowing what value it will be until we read it.
What we need is a sub-system to provide us with guarantees about how the data will be handled in the event of any of these side effects causing unintended results, and to limit the impact of any side effects in which we cannot control, such as the payment provider.
In order to do this we need to think about where we first gain control of the data and when we lose it. If we follow the user flow diagram, we can see that the first time we gain control in the system is after the user clicks Add to basket on the Product Page and we’re checking if tickets are available. Then immediately after that we lose control as we redirect the user back to the basket page and they are able to proceed at any time they wish.
The solution
So what if we create a sub-system to “reserve” the tickets at this point, essentially pre-allocating them to the user, locking them from being reserved by anyone else. This would provide us with a guarantee that the tickets will still be available at the end of the flow.
But what about if the user abandons their cart? This would lead to tickets being “stuck” in reserve. One of the benefits of this system is that we can “release” tickets up until the user pays, so the easiest solution is to implement a timeout for the reservation. If we allow the ticket reservations to expire after 5 minutes, this would mean that users that abandon their carts would eventually lose their reservation, freeing them up for other users to reserve.
Alongside this, we can “refresh” the current users ticket reservations whenever they take any actions within the flow to ensure that it does not expire when a user is struggling to complete the checkout in time.
Once the user submits their card details, the payment provider can attempt to take the payment and if successful, we can finally upgrade the tickets from a reservation to an allocation. If the payment has failed, we can show the user an error and allow them to re-submit without the worry of losing their tickets as they will still be reserved.
This new sub-system provided us with a solution which meets all of our initial criteria:
Race conditions - The reservations of the tickets are now handled atomically, ensuring that only one user can reserve a specific ticket at a time.
Error Handling - Errors that occur before the payment can easily be presented to the user whilst maintaining their ticket reservation. We only allocate the tickets after the payment is successful, and any issues with payment can be resolved with a form re-submission by the user without them losing their reservations. The final ticket allocation is guaranteed to be available as the user has reserved the tickets at the start of the journey.
Seamless User Experience - The user is made aware at the very start of the journey whether the tickets are available or not, and are provided with a guarantee that they will remain that way throughout the journey. The reservation timeout also provides transparency to the user about the length of time they will be able to hold their tickets for, avoiding any frustrations with the user getting to the end of the checkout process and being unable to acquire their tickets.







