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.
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#
| Entity | Responsibility |
|---|---|
| SplitwiseInterface | Facade — single entry point; delegates all calls to SplitwiseOrchestration |
| SplitwiseOrchestration | Singleton orchestrator — coordinates user, group, expense, and split logic |
| User | Holds profile data, a list of all their expenses, and a Map<User, Double> of amounts owed per person |
| Group | Holds name, member map (userId → User), and expense map (expenseId → Expense) |
| Expense | Owns amount, description, payer, participant list, and a SplitStrategy; calls calculateSplit() on demand |
| SplitStrategy | Interface — split(amount, users, payer) returns Map<User, Double> and updates owedAmountPerUser on each user |
| EqualSplit | Divides total equally among all participants |
| PercentageSplit | Each participant pays their specified percentage of the total |
| ExactAmountSplit | Each participant pays a fixed exact amount |
| UserInventory | Singleton repository — Map<userId, User> + Map<User, List<Group>> |
| GroupInventory | Singleton repository — Map<groupId, Group> |
| UserFactory | Creates User objects with auto-generated IDs |
| GroupFactory | Creates Group objects with auto-generated IDs |
| ExpenseFactory | Creates Expense objects; resolves the SplitStrategy from a string |
| Logger | Centralized display logic — user details, group details, expense summaries, balances |
| ExpenseType | Enum: CREATED, UPDATED |
| ExpenseUpdateType | Enum: 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#
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);
}
}package Splitwise.orchestration;
import Splitwise.factory.*;
import Splitwise.inventory.*;
import Splitwise.model.*;
import java.util.*;
class SplitwiseOrchestration {
private static SplitwiseOrchestration orchestration;
private final UserInventory userInventory;
private final GroupInventory groupInventory;
private final UserFactory userFactory;
private final GroupFactory groupFactory;
private final ExpenseFactory expenseFactory;
private final Logger logger;
private SplitwiseOrchestration() {
this.userInventory = UserInventory.getInstance();
this.groupInventory = GroupInventory.getInstance();
this.logger = Logger.getInstance();
this.userFactory = new UserFactory();
this.groupFactory = new GroupFactory();
this.expenseFactory = new ExpenseFactory();
}
public static synchronized SplitwiseOrchestration getInstance() {
if (orchestration == null) orchestration = new SplitwiseOrchestration();
return orchestration;
}
// ── User ──────────────────────────────────────────────────────────────────
public void addUser(String name, String email, String phone) {
User user = userFactory.createUser(name, email, phone);
userInventory.addUser(user);
System.out.println("[INFO] User added: " + user.getId());
}
public void viewUser(String userId) {
User user = userInventory.getUser(userId);
if (user != null) logger.displayUserDetails(user);
}
// ── Group ─────────────────────────────────────────────────────────────────
public void createGroup(String name) {
Group group = groupFactory.createGroup(name);
groupInventory.addGroup(group);
System.out.println("[INFO] Group created: " + group.getId());
}
public void addUserToGroup(String userId, String groupId) {
User user = userInventory.getUser(userId);
Group group = groupInventory.getGroup(groupId);
if (user == null || group == null) return;
group.addMember(user);
userInventory.addGroupForUser(user, group);
System.out.println("[INFO] " + userId + " added to " + groupId);
}
public void removeUserFromGroup(String userId, String groupId) {
User user = userInventory.getUser(userId);
Group group = groupInventory.getGroup(groupId);
if (user == null || group == null) return;
group.removeMember(user);
System.out.println("[INFO] " + userId + " removed from " + groupId);
}
public void showGroupDetails(String groupId) {
Group group = groupInventory.getGroup(groupId);
if (group != null) logger.displayGroupDetails(group);
}
public void showGroupsForUser(String userId) {
User user = userInventory.getUser(userId);
if (user != null) {
List<Group> groups = userInventory.getGroupsForUser(user);
if (!groups.isEmpty()) logger.displayGroupsForUser(user, groups);
}
}
// ── Expense ───────────────────────────────────────────────────────────────
public void addExpense(String groupId, double amount, String desc,
String adderId, String participants, String strategy) {
addExpenseInternal(groupId, amount, desc, adderId, participants, strategy, null);
}
public void addExpense(String groupId, double amount, String desc,
String adderId, String participants, String strategy,
Map<String, Double> splitMap) {
addExpenseInternal(groupId, amount, desc, adderId, participants, strategy, splitMap);
}
private void addExpenseInternal(String groupId, double amount, String desc,
String adderId, String participants,
String strategy, Map<String, Double> splitMap) {
Group group = groupInventory.getGroup(groupId);
User adder = userInventory.getUser(adderId);
if (group == null || adder == null) return;
List<User> users = resolveParticipants(group, participants);
if (users.isEmpty()) { System.out.println("[ERROR] Invalid participant(s)"); return; }
Map<User, Double> userSplitMap = resolveSplitMap(group, splitMap);
Expense expense = expenseFactory.createExpense(
group, amount, desc, adder, users, strategy, userSplitMap);
if (expense == null) { System.out.println("[ERROR] Failed to create expense"); return; }
group.addExpense(expense);
Map<User, Double> splits = expense.calculateSplit();
logger.displayExpense(ExpenseType.CREATED, expense, splits);
}
public void updateExpense(ExpenseUpdateType type, String groupId, String expId, Object value) {
Group group = groupInventory.getGroup(groupId);
if (group == null) return;
Expense expense = group.getExpenseById(expId);
if (expense == null) { System.out.println("[ERROR] Expense not found: " + expId); return; }
switch (type) {
case ADD_USER -> {
if (value instanceof String s)
resolveParticipants(group, s).forEach(expense::addUserToExpense);
}
case REMOVE_USER -> {
if (value instanceof String s)
resolveParticipants(group, s).forEach(expense::removeUserFromExpense);
}
case UPDATE_AMOUNT -> {
if (value instanceof Double d) expense.updateAmount(d);
}
}
group.updateExpense(expId, expense);
logger.displayExpense(ExpenseType.UPDATED, expense, expense.calculateSplit());
}
// ── Views ─────────────────────────────────────────────────────────────────
public void viewExpenseSummaryForUser(String userId) {
User user = userInventory.getUser(userId);
if (user != null) logger.displayExpenseForUser(user.getOwedAmountPerUser(), user);
}
public void showTransactionHistoryForUser(String userId) {
User user = userInventory.getUser(userId);
if (user != null) logger.displayAllExpensesForUser(user.getExpenses(), user);
}
public void showGroupBalanceSummary(String groupId) {
Group group = groupInventory.getGroup(groupId);
if (group == null) { System.out.println("[ERROR] Group not found: " + groupId); return; }
// Build a "participant → payer" balance map across all group expenses
Map<String, Double> balanceMap = new HashMap<>();
for (Expense exp : group.getExpenses().values()) {
User payer = exp.getExpenseAddingUser();
exp.calculateSplit().forEach((participant, amount) -> {
if (!participant.equals(payer)) {
String key = participant.getId() + "->" + payer.getId();
balanceMap.merge(key, amount, Double::sum);
}
});
}
logger.displayGroupBalances(balanceMap, group);
}
// ── Helpers ───────────────────────────────────────────────────────────────
private List<User> resolveParticipants(Group group, String participants) {
List<User> users = new ArrayList<>();
for (String id : participants.split(",")) {
User u = userInventory.getUser(id.trim());
if (u == null || !group.getMembers().containsKey(u.getId())) {
System.out.println("[ERROR] Invalid participant: " + id);
return Collections.emptyList();
}
users.add(u);
}
return users;
}
private Map<User, Double> resolveSplitMap(Group group, Map<String, Double> raw) {
if (raw == null || raw.isEmpty()) return null;
Map<User, Double> result = new HashMap<>();
for (Map.Entry<String, Double> e : raw.entrySet()) {
User u = userInventory.getUser(e.getKey());
if (u == null || !group.getMembers().containsKey(u.getId())) {
System.out.println("[ERROR] Invalid user in splitMap: " + e.getKey());
return null;
}
result.put(u, e.getValue());
}
return result;
}
}Models#
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);
}
}package Splitwise.model;
import java.util.*;
import java.util.concurrent.atomic.AtomicInteger;
class Group {
private static final AtomicInteger idCounter = new AtomicInteger(0);
private final String id;
private final String name;
private final Map<String, User> members; // userId → User
private final Map<String, Expense> expenses; // expenseId → Expense
public Group(String name) {
this.id = name + "_" + idCounter.getAndIncrement();
this.name = name;
this.members = new LinkedHashMap<>();
this.expenses = new LinkedHashMap<>();
}
public String getId() { return id; }
public String getName() { return name; }
public Map<String, User> getMembers() { return members; }
public Map<String, Expense> getExpenses() { return expenses; }
public void addMember(User user) { members.put(user.getId(), user); }
public void removeMember(User user) { members.remove(user.getId()); }
public void addExpense(Expense exp) { expenses.put(exp.getExpenseId(), exp); }
public void updateExpense(String id, Expense exp){ expenses.put(id, exp); }
public Expense getExpenseById(String id) { return expenses.get(id); }
}package Splitwise.model;
import Splitwise.strategy.SplitStrategy;
import java.util.*;
import java.util.concurrent.atomic.AtomicInteger;
class Expense {
private static final AtomicInteger idCounter = new AtomicInteger(0);
private final String expenseId;
private double amount;
private final String description;
private final User expenseAddingUser; // the person who paid
private final Group group;
private final List<User> users;
private final SplitStrategy strategy;
public Expense(double amount, Group group, String desc,
User adder, List<User> users, SplitStrategy strategy) {
this.expenseId = "EXP_" + idCounter.getAndIncrement();
this.amount = amount;
this.description = desc;
this.expenseAddingUser = adder;
this.group = group;
this.users = new ArrayList<>(users);
this.strategy = strategy;
}
public String getExpenseId() { return expenseId; }
public double getAmount() { return amount; }
public String getDescription() { return description; }
public User getExpenseAddingUser() { return expenseAddingUser; }
public Group getGroup() { return group; }
public List<User> getUsers() { return users; }
public void addUserToExpense(User user) { users.add(user); }
public void removeUserFromExpense(User u) {
if (!users.remove(u)) System.out.println("[ERROR] User not in expense: " + u.getName());
}
public void updateAmount(double newAmount) { this.amount = newAmount; }
/** Delegates to strategy; also updates per-user owedAmount as a side effect. */
public Map<User, Double> calculateSplit() {
return strategy.split(amount, users, expenseAddingUser);
}
}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.
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);
}
}package Splitwise.factory;
import Splitwise.model.Group;
import java.util.concurrent.atomic.AtomicInteger;
class GroupFactory {
private final AtomicInteger idCounter = new AtomicInteger(0);
public Group createGroup(String name) {
String id = name + "_" + idCounter.getAndIncrement();
return new Group(id, name);
}
}package Splitwise.factory;
import Splitwise.model.*;
import Splitwise.strategy.*;
import java.util.*;
class ExpenseFactory {
public Expense createExpense(Group group, double amount, String description,
User adder, List<User> users,
String strategy, Map<User, Double> splitMap) {
SplitStrategy splitStrategy = resolveStrategy(strategy, splitMap);
if (splitStrategy == null) return null;
return new Expense(amount, group, description, adder, users, splitStrategy);
}
private SplitStrategy resolveStrategy(String strategy, Map<User, Double> splitMap) {
return switch (strategy.toLowerCase()) {
case "equal" -> new EqualSplit();
case "percentage" -> {
if (splitMap == null || splitMap.isEmpty()) {
System.out.println("[ERROR] Percentage map required for percentage split");
yield null;
}
yield new PercentageSplit(splitMap);
}
case "exact" -> {
if (splitMap == null || splitMap.isEmpty()) {
System.out.println("[ERROR] Exact amount map required for exact split");
yield null;
}
yield new ExactAmountSplit(splitMap);
}
default -> {
System.out.println("[ERROR] Unknown strategy: " + strategy);
yield null;
}
};
}
}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.
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());
}
}package Splitwise.inventory;
import Splitwise.model.Group;
import java.util.*;
class GroupInventory {
private static GroupInventory instance;
private final Map<String, Group> groupMap = new HashMap<>();
private GroupInventory() {}
public static synchronized GroupInventory getInstance() {
if (instance == null) instance = new GroupInventory();
return instance;
}
public void addGroup(Group group) { groupMap.put(group.getId(), group); }
public Group getGroup(String id) {
if (!groupMap.containsKey(id)) {
System.out.println("[ERROR] Group not found: " + id);
return null;
}
return groupMap.get(id);
}
}Strategy#
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);
}package Splitwise.strategy;
import Splitwise.model.User;
import java.util.*;
class EqualSplit implements SplitStrategy {
@Override
public Map<User, Double> split(double amount, List<User> users, User payer) {
Map<User, Double> owed = new HashMap<>();
if (users.isEmpty()) return owed;
double share = amount / users.size();
for (User u : users) {
owed.put(u, share);
u.updateOwedAmount(payer, share);
}
System.out.println("Splitting equally: " + share + " each");
return owed;
}
}package Splitwise.strategy;
import Splitwise.model.User;
import java.util.*;
class PercentageSplit implements SplitStrategy {
private final Map<User, Double> percentageMap;
public PercentageSplit(Map<User, Double> percentageMap) {
this.percentageMap = percentageMap;
}
@Override
public Map<User, Double> split(double amount, List<User> users, User payer) {
Map<User, Double> owed = new HashMap<>();
for (User u : users) {
double pct = percentageMap.getOrDefault(u, 0.0);
double userAmount = amount * pct / 100.0;
owed.put(u, userAmount);
u.updateOwedAmount(payer, userAmount);
}
System.out.println("Splitting by percentage.");
return owed;
}
}package Splitwise.strategy;
import Splitwise.model.User;
import java.util.*;
class ExactAmountSplit implements SplitStrategy {
private final Map<User, Double> exactAmountMap;
public ExactAmountSplit(Map<User, Double> exactAmountMap) {
this.exactAmountMap = exactAmountMap;
}
@Override
public Map<User, Double> split(double amount, List<User> users, User payer) {
Map<User, Double> owed = new HashMap<>();
for (User u : users) {
double userAmount = exactAmountMap.getOrDefault(u, 0.0);
owed.put(u, userAmount);
u.updateOwedAmount(payer, userAmount);
}
System.out.println("Splitting by exact amounts.");
return owed;
}
}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.
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#
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.