Low Level Design

Design Splitwise

Low-level design of an expense-sharing app — user and group management, three split strategies (equal, percentage, exact), Factory pattern for entity creation, Singleton orchestration, and a Facade that hides all internal complexity.

August 21, 2026·18 min read·Updated September 18, 2026

Problem Description#

Design an expense-sharing application like Splitwise, where users can add expenses within groups and the app automatically tracks who owes what to whom.

The core difficulty isn't the arithmetic — it's the design. You need a model that cleanly separates how expenses are created (Factory), how they are split (Strategy), how state is coordinated (Singleton Orchestrator), and how the API surface is simplified for callers (Facade). The domain also has natural OOP tension: an Expense owns a SplitStrategy but can be mutated after creation (add/remove users, change amount), requiring recalculation without tight coupling.


Clarify Requirements#

Functional

  • Can users create accounts with name, email, and phone?
  • Can users create groups and add/remove other members?
  • When adding an expense, who are the valid participants — must they all be group members?
  • What split methods must be supported — equal split only, or also percentage and exact amounts?
  • Can expenses be updated after creation (add users, change amount)?
  • Should the system show per-user balance summaries and per-group balance summaries?
  • Is a full transaction history per user required?

Non-functional

  • Should the orchestration layer be a singleton (one global system)?
  • Do we need thread safety for concurrent expense additions?
  • Is data persistence required or is in-memory sufficient?

Final Requirements#

  • User management: create users with auto-generated IDs; view user profiles
  • Group management: create groups; add/remove members; view group details; show all groups for a user
  • Expense management: add expenses within a group specifying participants and a split strategy; add/remove users from an expense; update expense amount
  • Three split strategies: EqualSplit, PercentageSplit, ExactAmountSplit
  • Balance tracking: per-user owed-amount map updated on every expense; group-level balance summary
  • Transaction history: per-user list of all expenses they are part of
  • Singleton SplitwiseOrchestration — one global coordinator; exposed through SplitwiseInterface (Facade)

Core Entities#

EntityResponsibility
SplitwiseInterfaceFacade — single entry point; delegates all calls to SplitwiseOrchestration
SplitwiseOrchestrationSingleton orchestrator — coordinates user, group, expense, and split logic
UserHolds profile data, a list of all their expenses, and a Map<User, Double> of amounts owed per person
GroupHolds name, member map (userId → User), and expense map (expenseId → Expense)
ExpenseOwns amount, description, payer, participant list, and a SplitStrategy; calls calculateSplit() on demand
SplitStrategyInterface — split(amount, users, payer) returns Map<User, Double> and updates owedAmountPerUser on each user
EqualSplitDivides total equally among all participants
PercentageSplitEach participant pays their specified percentage of the total
ExactAmountSplitEach participant pays a fixed exact amount
UserInventorySingleton repository — Map<userId, User> + Map<User, List<Group>>
GroupInventorySingleton repository — Map<groupId, Group>
UserFactoryCreates User objects with auto-generated IDs
GroupFactoryCreates Group objects with auto-generated IDs
ExpenseFactoryCreates Expense objects; resolves the SplitStrategy from a string
LoggerCentralized display logic — user details, group details, expense summaries, balances
ExpenseTypeEnum: CREATED, UPDATED
ExpenseUpdateTypeEnum: ADD_USER, REMOVE_USER, UPDATE_AMOUNT

Patterns Used#

Singleton — SplitwiseOrchestration, UserInventory, GroupInventory, Logger#

All four of these must be shared globally. The Orchestrator is the coordination hub; the inventories are the data stores; the Logger is a stateless display utility. Each uses a synchronized getInstance() factory method to guarantee a single instance across the application lifecycle.

Facade — SplitwiseInterface#

The Interface class wraps the Orchestrator and exposes one clean method per user action (addUser, addExpense, showGroupBalanceSummary, etc.). Callers never import anything from the internal packages — they only depend on this Facade. This means the entire internal structure can change without breaking API clients.

Strategy — SplitStrategy#

How an expense is split is a runtime decision that varies per expense. Extracting this into a SplitStrategy interface means:

  • Expense.calculateSplit() never changes when a new split type is added.
  • EqualSplit, PercentageSplit, and ExactAmountSplit are independently testable.
  • The ExpenseFactory resolves the correct strategy from the caller's string ("equal", "percentage", "exact") without leaking that decision into Expense or the Orchestrator.

Factory Method — UserFactory, GroupFactory, ExpenseFactory#

Object creation is non-trivial: users need auto-generated unique IDs, groups need sequential IDs, and expenses need their split strategy resolved before construction. Extracting this into dedicated factories keeps constructors clean and gives a single place to change the ID generation scheme.


Code#

Facade & Orchestrator#

java
package Splitwise;

import Splitwise.model.*;
import Splitwise.orchestration.SplitwiseOrchestration;
import java.util.Map;

public class SplitwiseInterface {
    private final SplitwiseOrchestration orchestration;

    public SplitwiseInterface() {
        this.orchestration = SplitwiseOrchestration.getInstance();
    }

    public void addUser(String name, String email, String phone) {
        orchestration.addUser(name, email, phone);
    }

    public void viewUser(String userId) {
        orchestration.viewUser(userId);
    }

    public void createGroup(String name) {
        orchestration.createGroup(name);
    }

    public void addUserToGroup(String userId, String groupId) {
        orchestration.addUserToGroup(userId, groupId);
    }

    public void removeUserFromGroup(String userId, String groupId) {
        orchestration.removeUserFromGroup(userId, groupId);
    }

    public void showGroupDetails(String groupId) {
        orchestration.showGroupDetails(groupId);
    }

    public void showGroupsForUser(String userId) {
        orchestration.showGroupsForUser(userId);
    }

    /** Equal split (no splitMap needed). */
    public void addExpense(String groupId, double amount, String desc,
                           String adderId, String participants, String strategy) {
        orchestration.addExpense(groupId, amount, desc, adderId, participants, strategy);
    }

    /** Percentage or exact split — caller provides the split map. */
    public void addExpense(String groupId, double amount, String desc,
                           String adderId, String participants, String strategy,
                           Map<String, Double> splitMap) {
        orchestration.addExpense(groupId, amount, desc, adderId, participants, strategy, splitMap);
    }

    public void addUsersToExpense(String groupId, String expId, String newUsers) {
        orchestration.updateExpense(ExpenseUpdateType.ADD_USER, groupId, expId, newUsers);
    }

    public void removeUsersFromExpense(String groupId, String expId, String users) {
        orchestration.updateExpense(ExpenseUpdateType.REMOVE_USER, groupId, expId, users);
    }

    public void updateAmountForExpense(String groupId, String expId, double amount) {
        orchestration.updateExpense(ExpenseUpdateType.UPDATE_AMOUNT, groupId, expId, amount);
    }

    public void viewExpenseSummaryForUser(String userId) {
        orchestration.viewExpenseSummaryForUser(userId);
    }

    public void showTransactionHistoryForUser(String userId) {
        orchestration.showTransactionHistoryForUser(userId);
    }

    public void showGroupBalanceSummary(String groupId) {
        orchestration.showGroupBalanceSummary(groupId);
    }
}

Models#

java
package Splitwise.model;

import java.util.*;

class User {
    private final String id;
    private final String name;
    private final String email;
    private final String phone;
    private final List<Expense> expenses;
    private final Map<User, Double> owedAmountPerUser; // key = the person who paid

    public User(String id, String name, String email, String phone) {
        this.id = id; this.name = name; this.email = email; this.phone = phone;
        this.expenses = new ArrayList<>();
        this.owedAmountPerUser = new HashMap<>();
    }

    public String  getId()    { return id; }
    public String  getName()  { return name; }
    public String  getEmail() { return email; }
    public String  getPhone() { return phone; }
    public List<Expense>        getExpenses()          { return expenses; }
    public Map<User, Double>    getOwedAmountPerUser() { return owedAmountPerUser; }

    public void addExpense(Expense expense) { expenses.add(expense); }

    /** Accumulate how much this user owes to `payer`. */
    public void updateOwedAmount(User payer, double amount) {
        owedAmountPerUser.merge(payer, amount, Double::sum);
    }
}

Factories#

UserFactory auto-generates a unique ID from the user's name and the last four digits of their phone number. GroupFactory does the same with a sequential counter. ExpenseFactory owns the strategy resolution — it maps the caller's plain string ("equal", "percentage", "exact") to the correct SplitStrategy implementation so that neither Expense nor the Orchestrator need to know which strategies exist.

java
package Splitwise.factory;

import Splitwise.model.User;
import java.util.concurrent.atomic.AtomicInteger;

class UserFactory {
    private final AtomicInteger idCounter = new AtomicInteger(0);

    public User createUser(String name, String email, String phone) {
        String id = name + "_" + phone.substring(phone.length() - 4)
                    + "_" + idCounter.getAndIncrement();
        return new User(id, name, email, phone);
    }
}

Inventories#

UserInventory and GroupInventory are Singleton repositories — they are the single source of truth for all User and Group objects in the system. The Orchestrator always looks up entities here rather than holding references directly, which keeps the coordination logic decoupled from storage.

java
package Splitwise.inventory;

import Splitwise.model.*;
import java.util.*;

class UserInventory {
    private static UserInventory instance;

    private final Map<String, User>        userMap      = new HashMap<>();
    private final Map<User, List<Group>>   userGroupMap = new HashMap<>();

    private UserInventory() {}

    public static synchronized UserInventory getInstance() {
        if (instance == null) instance = new UserInventory();
        return instance;
    }

    public void addUser(User user) { userMap.put(user.getId(), user); }

    public User getUser(String id) {
        if (!userMap.containsKey(id)) {
            System.out.println("[ERROR] User not found: " + id);
            return null;
        }
        return userMap.get(id);
    }

    public void addGroupForUser(User user, Group group) {
        userGroupMap.computeIfAbsent(user, k -> new ArrayList<>()).add(group);
    }

    public List<Group> getGroupsForUser(User user) {
        return userGroupMap.getOrDefault(user, Collections.emptyList());
    }
}

Strategy#

java
package Splitwise.strategy;

import Splitwise.model.User;
import java.util.List;
import java.util.Map;

interface SplitStrategy {
    /** Returns amount each participant owes, and updates user.owedAmountPerUser. */
    Map<User, Double> split(double amount, List<User> users, User expenseAddingUser);
}

Logger#

Logger is a Singleton with no business logic — it only knows how to format and print domain objects. Keeping all display logic here means the Orchestrator, models, and strategies never call System.out.println directly, so output formatting can change in one place without touching anything else.

java
package Splitwise.orchestration;

import Splitwise.model.*;
import java.util.*;

class Logger {
    private static Logger logger;

    private Logger() {}

    public static synchronized Logger getInstance() {
        if (logger == null) logger = new Logger();
        return logger;
    }

    public void displayUserDetails(User user) {
        System.out.println("\n==============================");
        System.out.println("[USER DETAILS]");
        System.out.println("ID    : " + user.getId());
        System.out.println("Name  : " + user.getName());
        System.out.println("Email : " + user.getEmail());
        System.out.println("Phone : " + user.getPhone());
        System.out.println("==============================");
    }

    public void displayGroupDetails(Group group) {
        StringBuilder sb = new StringBuilder();
        sb.append("\n==============================\n")
          .append("[GROUP DETAILS]\n")
          .append("ID   : ").append(group.getId()).append("\n")
          .append("Name : ").append(group.getName()).append("\n");

        if (group.getMembers().isEmpty()) {
            sb.append("Members : none\n");
        } else {
            sb.append("Members : ").append(group.getMembers().size()).append("\n")
              .append("------------------------------\n");
            for (User u : group.getMembers().values()) {
                sb.append("• ").append(u.getName())
                  .append(" (").append(u.getId()).append(")\n");
            }
        }
        sb.append("==============================");
        System.out.println(sb);
    }

    public void displayGroupsForUser(User user, List<Group> groups) {
        System.out.println("\n==============================");
        System.out.println("[GROUPS FOR " + user.getName() + "]");
        groups.forEach(g -> System.out.println("• " + g.getName() + " (" + g.getId() + ")"));
        System.out.println("==============================");
    }

    public void displayExpense(ExpenseType type, Expense expense, Map<User, Double> splitAmounts) {
        StringBuilder sb = new StringBuilder();
        sb.append("\n==============================\n")
          .append("EXPENSE [").append(type).append("]\n")
          .append("ID        : ").append(expense.getExpenseId()).append("\n")
          .append("Desc      : ").append(expense.getDescription()).append("\n")
          .append("Amount    : ").append(expense.getAmount()).append("\n")
          .append("Group     : ").append(expense.getGroup().getName())
          .append(" (").append(expense.getGroup().getId()).append(")\n")
          .append("SplitType : ").append(expense.getStrategy().getClass().getSimpleName()).append("\n")
          .append("------------------------------\n");

        splitAmounts.forEach((u, amt) ->
            sb.append("• ").append(u.getName()).append(" owes ").append(amt).append("\n"));

        sb.append("==============================");
        System.out.println(sb);
    }

    public void displayExpenseForUser(Map<User, Double> owedMap, User user) {
        System.out.println("\n==============================");
        System.out.println("[EXPENSE SUMMARY FOR " + user.getName() + "]");
        if (owedMap.isEmpty()) {
            System.out.println(user.getName() + " owes nothing.");
        } else {
            owedMap.forEach((payer, amt) ->
                System.out.println("Owes " + payer.getName() + " : " + amt));
        }
        System.out.println("==============================");
    }

    public void displayAllExpensesForUser(List<Expense> expenses, User user) {
        System.out.println("\n==============================");
        System.out.println("[TRANSACTION HISTORY FOR " + user.getName() + "]");
        if (expenses.isEmpty()) {
            System.out.println("No transactions found.");
        } else {
            for (Expense exp : expenses) {
                double owed = exp.calculateSplit().getOrDefault(user, 0.0);
                System.out.println("• " + exp.getExpenseId()
                    + " | " + exp.getDescription()
                    + " | Total: " + exp.getAmount()
                    + (exp.getExpenseAddingUser().equals(user)
                        ? " | You paid"
                        : " | You owe: " + owed + " to " + exp.getExpenseAddingUser().getName()));
            }
        }
        System.out.println("==============================");
    }

    public void displayGroupBalances(Map<String, Double> balances, Group group) {
        System.out.println("\n==============================");
        System.out.println("[GROUP BALANCES: " + group.getName() + "]");
        if (balances.isEmpty()) {
            System.out.println("No outstanding balances.");
        } else {
            Map<String, User> members = group.getMembers();
            balances.forEach((key, amt) -> {
                String[] ids = key.split("->");
                User from = members.get(ids[0]);
                User to   = members.get(ids[1]);
                System.out.printf("%s owes ₹%.2f to %s%n", from.getName(), amt, to.getName());
            });
        }
        System.out.println("==============================");
    }
}

Simulation#

java
import Splitwise.SplitwiseInterface;
import java.util.*;

public class SplitwiseSimulation {
    public static void main(String[] args) {
        SplitwiseInterface app = new SplitwiseInterface();

        // Users
        app.addUser("Alice",   "alice@gmail.com",   "1234567890");
        app.addUser("Bob",     "bob@gmail.com",     "0987654321");
        app.addUser("Charlie", "charlie@gmail.com", "1122334455");
        app.addUser("Dan",     "dan@gmail.com",     "1323234455");
        app.addUser("Eve",     "eve@gmail.com",     "1323234878");

        app.viewUser("Alice_7890_0");

        // Groups
        app.createGroup("Friends");
        app.addUserToGroup("Alice_7890_0",   "Friends_0");
        app.addUserToGroup("Dan_4455_3",     "Friends_0");
        app.addUserToGroup("Charlie_4455_2", "Friends_0");
        app.showGroupDetails("Friends_0");
        app.showGroupDetails("Friens_0"); // invalid → graceful error

        app.createGroup("Trip");
        app.addUserToGroup("Dan_4455_3",     "Trip_1");
        app.addUserToGroup("Charlie_4455_2", "Trip_1");
        app.addUserToGroup("Eve_4878_4",     "Trip_1");

        // Remove user, show groups
        app.removeUserFromGroup("Dan_4455_3", "Friends_0");
        app.showGroupsForUser("Charlie_4455_2");

        // Equal split
        app.addExpense("Trip_1", 2346, "Cab 1", "Dan_4455_3",
                "Dan_4455_3,Charlie_4455_2", "equal");
        // Update: add user, then change amount
        app.addUsersToExpense("Trip_1", "EXP_0", "Eve_4878_4");
        app.updateAmountForExpense("Trip_1", "EXP_0", 600);

        app.addExpense("Trip_1", 890, "Cab 2", "Charlie_4455_2",
                "Dan_4455_3,Charlie_4455_2", "equal");

        // Exact split
        Map<String, Double> exactMap = new HashMap<>();
        exactMap.put("Dan_4455_3", 50.0);
        exactMap.put("Eve_4878_4", 30.0);
        app.addExpense("Trip_1", 200, "Lunch", "Dan_4455_3",
                "Dan_4455_3,Charlie_4455_2", "exact", exactMap);

        // Percentage split
        Map<String, Double> percMap = new HashMap<>();
        percMap.put("Alice_7890_0",   60.0);
        percMap.put("Charlie_4455_2", 30.0);
        app.addExpense("Friends_0", 780, "Dinner", "Charlie_4455_2",
                "Alice_7890_0,Charlie_4455_2", "percentage", percMap);

        // Views
        app.viewExpenseSummaryForUser("Charlie_4455_2");
        app.showTransactionHistoryForUser("Dan_4455_3");
        app.showGroupBalanceSummary("Trip_1");
    }
}

Class Diagram#


Extendible — Follow Ups#

Settle up between two users#

Implement settleUp(String userId, String otherUserId) on the Facade. The Orchestrator looks up both users, computes the net balance between them from their owedAmountPerUser maps, records a settlement Transaction (new entity), and zeroes out the relevant entries. Add a Transaction entity (id, payer, payee, amount, timestamp) and a TransactionHistory repository so settlements are logged alongside expenses.

Simplify debts across a group (debt minimization)#

When showing group balances, the naive summary shows every pair's debt separately. Add a DebtSimplifier utility that takes the group's raw balance map, builds a net-flow graph, and uses a greedy algorithm (max-heap on creditors, min-heap on debtors) to produce the minimum number of transactions needed to clear all balances. Expose this as showSimplifiedGroupBalances(groupId) on the Facade.

Add a new split type — proportional split by custom weights#

Add WeightedSplit that implements SplitStrategy. Each participant is assigned a weight (e.g., Alice=3, Bob=1); their share is (weight / totalWeight) × amount. This requires extending ExpenseFactory to recognize the strategy string "weighted" and constructing the weight map from a new parameter — the Facade and Orchestrator API only need a new overload, existing callers are unaffected.

Recurring expenses (monthly subscriptions, rent)#

Introduce a RecurringExpense entity (wraps a template Expense + frequency + next-due-date). A RecurringExpenseScheduler (background thread or scheduled job) checks due dates and calls addExpense on the Orchestrator automatically. Use the existing Strategy infrastructure — the recurring expense just re-creates a regular Expense on each cycle and calls calculateSplit() normally.