Written by Yisong Wu
As a top-grossing App in the Google Play Store, Tinder is providing services to millions of members across the world. We also provide paid members a premium experience that includes Tinder Gold, Tinder Plus, and a la carte products such as Super Boost, Super Like, Boost, Top Picks, and more.
The payment flow lays down a foundation to provide a smooth and quick payment experience for our paid members. During the early stage when Tinder was a startup, the payment experience was built quickly in several God classes and it met the demands at the moment. However, as Tinder grows and the team expands, the codebase has become more difficult to maintain and debug, and as a result, the feature development becomes more challenging.
The legacy code reached the end of its life cycle and we took a bold move and decided to rewrite the whole payment flow.
In the new payment flow, we want to make the payment code predictable, self-documented, testable, and observable. Based on those factors, we chose the state machine to lay the foundation.
Before we started putting together a state machine to reconstruct the code, we went through our payment flow and figured out that we needed four main steps to complete a purchase as below.
- Load payment data
- Validate payment data
- Charge for the purchase
- Verify the receipt
Compose Purchase States
With those product requirements identified above, we built them into four main states as below.
Together with the Idle state as initial state and Completed state as the terminal state, we modeled our completed states set.
To represent those states in code, we created a sealed class Purchase and made each state implement it.
Below is an example for the RunningBiller state.
The PurchaseData contains all the data we needed through a purchase flow, and it will be carried by each purchase state in the state machine graph.
Build State Graphs
With those states defined above, we now can wire them all together. At a very high level, there are two scenarios.
- Happy Flow
- Failure Flows
Let’s walk through Happy Flow first, the state flow graph is as below.
Each state will receive an event to move forward to the next state, and finally reach the completed state to finish the purchase.
One benefit of using the state machine approach is that it helps us prioritize failure cases as much as success cases, as we have to think about failure cases ahead of time when building the branches in the state graph.
The complete state graph with failure scenarios are shown below in the red boxes.
Now we made sure we covered both success and failure scenarios on the purchase flow.
Let’s build the state flow graph in code using the state machine library in a declarative way:
After a state transition, the side effect which can also be thought of as the command is triggered to run if defined.
For example, after the state machine transitions from LoadingData to PreValidating State, the RunPreValidation sideEffect will run, which triggers a set of pre validation rules. Here you can add a specific rule to check if someone already has a subscription, and in this case, we shouldn’t allow them to buy again to avoid double charging our subscribers.
Delegating Business Logic / Side Effect
To avoid the state machine becoming another God class, we delegate the business logic, such as validating data and biller purchasing, to the side effect / command. So we need a flow coordinator to coordinate all those different actions.
With the PurchaseFlowCoordinator, the side effects are delegated to their own case to handle specifically, such as loadData, preValidatePurchase and runningBiller in the example above.
Now we have a flexible and scalable state machine for payment processing. The state machine approach also makes it very easy to observe what happens during each state in the purchase flow.
In many cases we need to observe the payment states or any issues, we can call purchaseCoordinator.observeStatesUpdate()to log analytics and track everything that happens there.
Modularization also comes with the rewrite as it is a good practice to keep code structured and improve build time on incremental builds. At Tinder, we always try our best to make sure the code base is modularized (checkout this Road to modularization droidcon talk for more information).
At a very high level, the module structure is outlined as below.
- Tinder App Module
- Feature Modules
- Purchase SDK Module (Expose interfaces for external access)
- Purchase Core Module (Hide implementation details for internal business logic)
- Purchase DI Module (Wire up purchase interfaces and implementations).
Based on the needs, the code that we want to hide from feature module access will be put in the purchase core module, such as the purchase flow coordinator. And the interfaces and state entities will be exposed for external modules. So this way, we protect our core features and only expose as little as needed for external use.
Testing was taken very seriously during the rewrite. The purpose was to make the unit testing easy and flexible to add. The state machine approach also provides a structured way for unit testing and we can apply a parameterized test there very easily.
With all of the above, we covered the high-level implementation of the state machine-driven payment flow, which brings us the benefits below.
- The state machine approach provides a flexible and determinative way to integrate with more payment methods in the future.
- The modularized approach helps A/B testing as well as build performances.
- Unit tests become scalable and straightforward to add.
Additionally, the state machine approach is not only limited in payment flow, we also successfully applied it in many other projects, such as the WebSocket . Let us know what you think, and we are happy to hear your experiences to scale out the payment system on other mobile apps!
If you want to learn more about the state machine-driven payment flow, feel free to check out our droidcon talk.