Design the Uber App (Part I)

CoolGuy
8 min readDec 8, 2020

Across this doc, we will discuss two use cases which are commonly seen in recent apps and will introduce what object oriented design methodogies to apply for each use case.

  • Calculation of referral bonus under different strategies
  • Transition of referral states.
  • Ads promotion (see the other article).

Use case 1: Referral Bonus

I believe referral bonus is already well known to everyone, especially in those crazy days that food delivery apps tried everything to gain new users. A simplified version of a referal bonus includes following steps

  1. Customer B clicks the referral link sent from customer A
  2. Customer B is redirected to a promotional page based on some rules such as customer B’s location and the available promotions
  3. Customer B places the order on day T
  4. After T+N days, customer A receives the referral bonus. Meanwhile customer A can check the state of all his/her referrals.

In order to balance between the cost and gains from each new user, the company usually has different strategies to generate different bonus. In this use case, we will focus on two parts:

  1. Generate different referral bonus based on different strategies
  2. Track and update referral state

Part I: Design pattern in multiple reward strategies

There are multiple strategies in the market. In general, we can see following types

  1. Same bonus for all new customers
  2. Accumulative bonus (the more you referred and the more you get per referral)
  3. Existing customers who just have not used the Apps for 3 months.

After computing the referral bonus, we also need to update referer with the actual bonus they got and notify the payment services to send deposits, which will happen in the same way regardless of how the referral bonus is computed.

From above, we can notice that the only changing factor is the rules of computing the bonus while the whole workflow remain the same. Here we can apply the Open Closed Principle, meaning we can open for extending to different rewards strategies and disallow the changes to the workflow.

We can name different rules of computing bonus as reward strategy, and different strategy will generate different bonus. Let’s see how we can use Factory Pattern to generate different reward strategy which further results in different referral bonus.

  1. Referral Reward Strategy

Factory pattern can be further divided into Factory Method Pattern and Abstract Factory Pattern. This article will use the Factory Method Pattern as the example. The Factory Method Pattern is a creational pattern that uses factory methods to deal with the problem of creating objects without having to specify the exact class of the object that will be created. The concrete class of the created objects will be specified in the concrete implementation of the factory. See the UML class diagram below:

Let’s see it in code

public abstract class Product { 
public abstract void method();
}
class ProductA extends Product {
@Override public void method() { ... } // concrete implementation
}
abstract class Factory<T> {
abstract Product createProduct(Class<T> c);
}

// concrete implementation of Factory which generates concrete Product based the given class
class FactoryA extends Factory{
@Override
Product createProduct(Class c) {
Product product = (Product) Class.forName(c.getName()).newInstance(); return product;
}
}

Strategy pattern: is a behavioral software design pattern that enables selecting an algorithm from multipel algorithms with the same interface at runtime. See its UML class diagram below:

Let’s see it in code:

public interface Strategy { 
void strategyImplementation();
} ​
public class StrategyA implements Strategy{
@Override
public void strategyImplementation() {
System.out.println(“Executing stragegy A”);
}
} ​
// Preventer externals from accessing concrete strategis being executed in `doStrategy()` method.
public class Context {
private Strategy strategy = null; ​

public Context(Strategy strategy) {
this.strategy = strategy;
}
public void doStrategy() {
strategy.strategyImplementation();
}
}

The above concepts are documented everywhere. Let’s use concrete examples to see how they work with the Referral bonus example in production.

Below we have a strategy factory to generate different referral reward strategy:

public abstract class RewardStrategy { 
public abstract void reward(long userId);
public void insertRewardAndSettlement(long userId, int reward) {};
}
public class newUserRewardStrategyA extends RewardStrategy { @Override public void reward(long userId) {}
}
public class OldUserRewardStrategyA extends RewardStrategy { @Override public void reward(long userId) {}
}
public abstract class StrategyFactory<T> {
abstract RewardStrategy createStrategy(Class<T> c);
}
public class FactorRewardStrategyFactory extends StrategyFactory {
@Override
RewardStrategy createStrategy(Class c) {
RewardStrategy product = null;
try {
product = (RewardStrategy) Class.forName(c.getName()).newInstance();
} catch (Exception e) { ... }
return product;
}
}

Once we get the concrete strategy using the Factory above, we can further pass this strategy to downstreams to execute the strategy. See code below. Note that this does not have knowledge for the type of strategy.

public class RewardContext { 
private RewardStrategy strategy; ​
public RewardContext(RewardStrategy strategy) {
this.strategy = strategy;
} ​
public void doStrategy(long userId) {
int rewardMoney = strategy.reward(userId);
insertRewardAndSettlement(long userId, int reward) {
insertReward(userId, rewardMoney);
settlement(userId);
}
}
}

Eventually, let’s combine the factory pattern and strategy pattern together in the main workflow.

public class InviteRewardImpl { 
// Main flow for processing referral bonus.
public void sendReward(long userId) {
// Create the reward strategy factory
FactorRewardStrategyFactory strategyFactory = new FactorRewardStrategyFactory();
// Get the information of the referee
Invitee invitee = getInviteeByUserId(userId);
Strategy strategy = null;
if (invitee.userType == UserTypeEnum.NEW_USER) {
// Get the strategy as the new customer
strategy = (NewUserBasicReward) strategyFactory.createStrategy(NewUserBasicReward.class);
}
if(invitee.userType == UserTypeEnum.OLD_USER){
// Strategy for existing customers
}
RewardContext rewardContext = new RewardContext(newUserBasicReward);
// Execute the strategy
rewardContext.doStrategy(userId);
}
}

From the above code, let’s recap the roles and benefits of using the factory and strategy pattern:

  1. Factory pattern generates concrete implementation of different strategies.
  2. Strategy pattern allows us to freely switch between different strategies without modified other logics
  3. Both pattern decouple the reward strategy logic from other parts of the system (such as payment services). Whenever we need to add a new strategy, we’d only need to implement from the RewardStrategy interface without touching other classes. This largely improves the extensibility of the system.

Part II Transition of multiple referral states

The state of a referral can flow into multiple states:

  1. Order validation => Validation Failure
  2. Order validation => Prepare rewards => Preparation failure => Compensate rewards
  3. Order validation => Prepare rewards => Deposite Rewards => Deposite failure => Compensate rewards
  4. Order validation => Prepare rewards => Deposite Rewards => Referral complete

In such case, we often use State pattern to model the states. See example chart below.

You may notice this is very similar to the strategy pattern above. But if you look at the concrete classes, you will notice that: for strategy pattern, one context generates a single ConcreteStrategy, while in the state pattern, one context organizes and interacts with multiple concrete states which futher forms a state transition flow based on the business logic. Let’s use the code below to demonstrate this idea which you can see the output below. You can also run the program at here directly.

  • Output of the program below: A => A to B => B
public abstract class State {
Context context;
public void setContext(Context context) {
this.context = context;
}
public abstract void handle1();
public abstract void handle2();
}

public class ConcreteStateA extends State {
@Override
public void handle1() { System.out.println("A"); }

@Override
public void handle2() {
// Transition to state B
System.out.println("A to B");
super.context.setCurrentState(Context.contreteStateB);
// Execute the tasks from state B
super.context.handle2();
}
}

public class ConcreteStateB extends State {
@Override
public void handle2() { System.out.println("B"); } // logic to execute under current state A

@Override
public void handle1() {
// transition to State A
super.context.setCurrentState(Context.contreteStateA);
// Execute the tasks from state A
super.context.handle1();
}
}
// Define the context class which organizes and interacts with multiple states
public class Context {
public final static ConcreteStateA contreteStateA = new ConcreteStateA();
public final static ConcreteStateB contreteStateB = new ConcreteStateB();

private State CurrentState;
public State getCurrentState() { return currentState; }

public void setCurrentState(State currentState) {
this.currentState = currentState;
this.currentState.setContext(this);
}

public void handle1() { this.CurrentState.handle1(); }
public void handle2() { this.CurrentState.handle2(); }
}
// Actual client which transitions between states
public class Client {
public static void main(String[] args) {
Context context = new Context();
context.setCurrentState(new ContreteStateA());
context.handle1();
context.handle2();
}
}

See below program with the real use reward state and context.

// Context of reward state
public class RewardStateContext {

private RewardState rewardState;

public void setRewardState(RewardState currentState) {
this.rewardState = currentState;
}
public RewardState getRewardState() {return rewardState;}
public void echo(RewardStateContext context, Request request) {
rewardState.doReward(context, request);
}
}

public abstract class RewardState {
abstract void doReward(RewardStateContext context, Request request);
}
public class OrderCheckState extends RewardState {
@Override
public void doReward(RewardStateContext context, Request request) {
orderCheck(context, request); // execute the logic like validating the order in the incoming context
}
}
public class CompensateRewardState extends RewardState {
@Override
public void doReward(RewardStateContext context, Request request) {
compensateReward(context, request); // if the validation failure, then it may go into this state for compensation rewards with less bonus.
}
}

// There are other states such as waiting for/success/failure for payment, confirmed referral, etc.

In above, we defined two rewards states from the same interface RewardState. The interface defines the signature doReward

Below, we initialized a `RewardStateContext` object which will organize and interact with multiple states. We use the `isResultFlag` to understand whether the prev state is successful or not.

public class ReferralRewardServiceImpl {
public boolean sendRewardFoReferee(long userId, long orderId) {
Request request = new Request(userId, orderId);
RewardStateContext rewardContext = new RewardStateContext();
rewardContext.setRewardState(new OrderCheckState());
rewardContext.echo(rewardContext, request); // Start the first state
// The if-else here is only to show the state transition and should not be used in production.
if (rewardContext.isResultFlag()) { // if order check succeeds, then move to the rewards preparation state
rewardContext.setRewardState(new BeforeRewardCheckState());
rewardContext.echo(rewardContext, request);
} else { // if order check fails, then move to the rewards failure state
rewardContext.setRewardState(new RewardFailedState());
rewardContext.echo(rewardContext, request);
return false;
}
if (rewardContext.isResultFlag()) { // if the reward preparation succeeds, then move to deposite reward
rewardContext.setRewardState(new SendRewardState());
rewardContext.echo(rewardContext, request);
} else { // if the reward preparation fails, then move to failure state
rewardContext.setRewardState(new RewardFailedState());
rewardContext.echo(rewardContext, request);
return false;
}
if (rewardContext.isResultFlag()) { // if the reward is successfully deposited, then move the final reward success state.
rewardContext.setRewardState(new RewardSuccessState());
rewardContext.echo(rewardContext, request);
} else { // if the reward is successfully deposited, then move the final reward compensation state.
rewardContext.setRewardState(new CompensateRewardState());
rewardContext.echo(rewardContext, request);
}
if (rewardContext.isResultFlag()) { // if the reward is successfully compensated, then move the final reward success state.
rewardContext.setRewardState(new RewardSuccessState());
rewardContext.echo(rewardContext, request);
} else { // if the reward fails at composation, then try again
rewardContext.setRewardState(new CompensateRewardState());
rewardContext.echo(rewardContext, request);
}
return true;
}
}

From the code above, we can see the core of state pattern is encapsulation which encapsulates the state process and state transition logic within a single class. This also reflects the closed-open principle and single responsibility principle. Every state is a subclass, no matter we are adding or modifying state, we just need to add a new or modify the existing subclassed state. In actual application, the number of states and transitions are much more than the above example. By using the state pattern, we can largely reduce the number of if-else check which reduces the maintanance burden.

--

--