Chapter 9: Case Study - Splitwise (Expense Sharing System)
Series: Low Level Design for .NET Developers | Previous: Chapter 8: Case Study - Parking Lot System | Next: Chapter 10: Advanced Concurrency & Caching
📖 Introduction
Splitwise is a popular expense-sharing application that helps groups of people track debts and simplify payments. This case study demonstrates how to design a complex system with:
- User management and groups
- Multiple expense splitting strategies
- Debt simplification algorithms
- Balance tracking
- Transaction history
- Settlement calculations
This system tests your ability to model complex business logic and implement efficient algorithms for debt settlement.
1. Requirements Analysis
1.1 Functional Requirements
- Users can create accounts and manage profiles
- Users can create groups and add members
- Users can add expenses in groups or between individuals
- Support multiple split types: Equal, Exact, Percentage, Adjustment
- Track balances between users
- Simplify debts to minimize number of transactions
- Generate settlement plans
- Track expense history and activity feed
- Users can settle debts (record payments)
1.2 Non-Functional Requirements
- Low latency for balance calculations
- Efficient debt simplification algorithm
- Audit trail of all expenses and settlements
- Support for large groups (up to 1000 members)
1.3 Domain Language
- User - Person using the application
- Group - Collection of users sharing expenses
- Expense - An amount paid by one user for others
- Split - How an expense is divided among participants
- Balance - Net amount one user owes another
- Settlement - Payment to clear debts
- Transaction - Record of a payment between users
2. Domain Model
2.1 Core Enums
namespace Splitwise.Domain.Enums
{
public enum SplitType
{
Equal, // Split equally among all participants
Exact, // Specific amounts per participant
Percentage, // Percentage split that sums to 100%
Adjustment // Manual adjustment (e.g., one person pays more)
}
public enum ExpenseStatus
{
Pending,
Settled,
Disputed
}
public enum TransactionStatus
{
Pending,
Completed,
Failed,
Cancelled
}
public enum GroupRole
{
Member,
Admin,
Creator
}
}
2.2 Value Objects
namespace Splitwise.Domain.ValueObjects
{
// Money value object (same as previous chapters)
public class Money : IEquatable<Money>
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency = "USD")
{
if (amount < 0)
throw new ArgumentException("Amount cannot be negative", nameof(amount));
if (string.IsNullOrWhiteSpace(currency))
throw new ArgumentException("Currency is required", nameof(currency));
Amount = Math.Round(amount, 2);
Currency = currency.ToUpperInvariant();
}
public Money Add(Money other)
{
if (Currency != other.Currency)
throw new InvalidOperationException($"Cannot add different currencies");
return new Money(Amount + other.Amount, Currency);
}
public Money Subtract(Money other)
{
if (Currency != other.Currency)
throw new InvalidOperationException($"Cannot subtract different currencies");
return new Money(Amount - other.Amount, Currency);
}
public static Money Zero(string currency = "USD") => new Money(0, currency);
public override bool Equals(object obj) => Equals(obj as Money);
public bool Equals(Money other) => other != null && Amount == other.Amount && Currency == other.Currency;
public override int GetHashCode() => HashCode.Combine(Amount, Currency);
public override string ToString() => $"{Amount:F2} {Currency}";
public static Money operator +(Money a, Money b) => a.Add(b);
public static Money operator -(Money a, Money b) => a.Subtract(b);
}
// Percentage value object
public class Percentage
{
public decimal Value { get; }
public Percentage(decimal value)
{
if (value < 0 || value > 100)
throw new ArgumentException("Percentage must be between 0 and 100", nameof(value));
Value = Math.Round(value, 2);
}
public static Percentage FromDecimal(decimal value) => new Percentage(value * 100);
public decimal ToDecimal() => Value / 100;
public override string ToString() => $"{Value}%";
}
}
2.3 User Entity
namespace Splitwise.Domain.Entities
{
public class User : IEquatable<User>
{
public int Id { get; private set; }
public string Name { get; private set; }
public string Email { get; private set; }
public string PhoneNumber { get; private set; }
public DateTime CreatedAt { get; private set; }
public DateTime? LastActiveAt { get; private set; }
private readonly List<GroupMembership> _memberships = new();
private readonly List<Expense> _expensesPaid = new();
private readonly List<Transaction> _transactionsSent = new();
private readonly List<Transaction> _transactionsReceived = new();
public IReadOnlyList<GroupMembership> Memberships => _memberships.AsReadOnly();
public IReadOnlyList<Expense> ExpensesPaid => _expensesPaid.AsReadOnly();
private User() { } // For persistence
public User(string name, string email, string phoneNumber = null)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Name is required", nameof(name));
if (string.IsNullOrWhiteSpace(email))
throw new ArgumentException("Email is required", nameof(email));
Name = name;
Email = email.ToLowerInvariant();
PhoneNumber = phoneNumber;
CreatedAt = DateTime.UtcNow;
}
public void UpdateProfile(string name, string phoneNumber)
{
if (!string.IsNullOrWhiteSpace(name))
Name = name;
PhoneNumber = phoneNumber;
LastActiveAt = DateTime.UtcNow;
}
public void AddMembership(Group group, GroupRole role = GroupRole.Member)
{
if (_memberships.Any(m => m.GroupId == group.Id))
throw new InvalidOperationException($"User already a member of group {group.Name}");
_memberships.Add(new GroupMembership(this, group, role));
}
public void RemoveMembership(int groupId)
{
var membership = _memberships.FirstOrDefault(m => m.GroupId == groupId);
if (membership != null)
{
_memberships.Remove(membership);
}
}
public void RecordExpensePaid(Expense expense)
{
_expensesPaid.Add(expense);
}
public override bool Equals(object obj) => Equals(obj as User);
public bool Equals(User other) => other != null && Id == other.Id;
public override int GetHashCode() => Id.GetHashCode();
public override string ToString() => Name;
}
}
2.4 Group Entity
namespace Splitwise.Domain.Entities
{
public class Group
{
public int Id { get; private set; }
public string Name { get; private set; }
public string Description { get; private set; }
public int CreatedByUserId { get; private set; }
public DateTime CreatedAt { get; private set; }
public bool IsActive { get; private set; }
private readonly List<GroupMembership> _members = new();
private readonly List<Expense> _expenses = new();
public IReadOnlyList<GroupMembership> Members => _members.AsReadOnly();
public IReadOnlyList<Expense> Expenses => _expenses.AsReadOnly();
private Group() { }
public Group(string name, int createdByUserId, string description = null)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Group name is required", nameof(name));
Name = name;
Description = description;
CreatedByUserId = createdByUserId;
CreatedAt = DateTime.UtcNow;
IsActive = true;
}
public void AddMember(User user, GroupRole role = GroupRole.Member)
{
if (_members.Any(m => m.UserId == user.Id))
throw new InvalidOperationException($"User {user.Name} is already a member");
_members.Add(new GroupMembership(user, this, role));
}
public void RemoveMember(int userId)
{
var membership = _members.FirstOrDefault(m => m.UserId == userId);
if (membership != null)
{
_members.Remove(membership);
}
}
public void AddExpense(Expense expense)
{
// Verify all participants are group members
foreach (var split in expense.Splits)
{
if (!_members.Any(m => m.UserId == split.UserId))
throw new InvalidOperationException($"User {split.UserId} is not a group member");
}
_expenses.Add(expense);
}
public void Deactivate()
{
IsActive = false;
}
public IEnumerable<User> GetMembers()
{
return _members.Select(m => m.User);
}
}
public class GroupMembership
{
public int UserId { get; private set; }
public int GroupId { get; private set; }
public GroupRole Role { get; private set; }
public DateTime JoinedAt { get; private set; }
// Navigation properties
public User User { get; private set; }
public Group Group { get; private set; }
private GroupMembership() { }
public GroupMembership(User user, Group group, GroupRole role)
{
UserId = user.Id;
GroupId = group.Id;
Role = role;
JoinedAt = DateTime.UtcNow;
User = user;
Group = group;
}
public void ChangeRole(GroupRole newRole)
{
Role = newRole;
}
}
}
3. Expense Splitting Strategies (Strategy Pattern)
namespace Splitwise.Domain.Strategies
{
public interface ISplitStrategy
{
string StrategyName { get; }
List<ExpenseSplit> CalculateSplits(Money totalAmount, List<int> participantIds, params object[] parameters);
bool Validate(Money totalAmount, List<int> participantIds, params object[] parameters);
}
// Equal split strategy
public class EqualSplitStrategy : ISplitStrategy
{
public string StrategyName => "Equal Split";
public List<ExpenseSplit> CalculateSplits(Money totalAmount, List<int> participantIds, params object[] parameters)
{
var splitAmount = new Money(totalAmount.Amount / participantIds.Count, totalAmount.Currency);
var splits = new List<ExpenseSplit>();
foreach (var userId in participantIds)
{
splits.Add(new ExpenseSplit(userId, splitAmount));
}
return splits;
}
public bool Validate(Money totalAmount, List<int> participantIds, params object[] parameters)
{
return participantIds != null && participantIds.Count > 0;
}
}
// Exact split strategy
public class ExactSplitStrategy : ISplitStrategy
{
public string StrategyName => "Exact Split";
public List<ExpenseSplit> CalculateSplits(Money totalAmount, List<int> participantIds, params object[] parameters)
{
if (parameters.Length == 0 || parameters[0] is not List<decimal> amounts)
throw new ArgumentException("Exact amounts required for exact split");
if (amounts.Count != participantIds.Count)
throw new ArgumentException("Number of amounts must match number of participants");
var splits = new List<ExpenseSplit>();
for (int i = 0; i < participantIds.Count; i++)
{
splits.Add(new ExpenseSplit(participantIds[i], new Money(amounts[i], totalAmount.Currency)));
}
return splits;
}
public bool Validate(Money totalAmount, List<int> participantIds, params object[] parameters)
{
if (parameters.Length == 0 || parameters[0] is not List<decimal> amounts)
return false;
if (amounts.Count != participantIds.Count)
return false;
var sum = amounts.Sum();
return Math.Abs(sum - totalAmount.Amount) < 0.01m;
}
}
// Percentage split strategy
public class PercentageSplitStrategy : ISplitStrategy
{
public string StrategyName => "Percentage Split";
public List<ExpenseSplit> CalculateSplits(Money totalAmount, List<int> participantIds, params object[] parameters)
{
if (parameters.Length == 0 || parameters[0] is not List<decimal> percentages)
throw new ArgumentException("Percentages required for percentage split");
if (percentages.Count != participantIds.Count)
throw new ArgumentException("Number of percentages must match number of participants");
var splits = new List<ExpenseSplit>();
for (int i = 0; i < participantIds.Count; i++)
{
var amount = totalAmount.Amount * (percentages[i] / 100);
splits.Add(new ExpenseSplit(participantIds[i], new Money(amount, totalAmount.Currency)));
}
return splits;
}
public bool Validate(Money totalAmount, List<int> participantIds, params object[] parameters)
{
if (parameters.Length == 0 || parameters[0] is not List<decimal> percentages)
return false;
if (percentages.Count != participantIds.Count)
return false;
var sum = percentages.Sum();
return Math.Abs(sum - 100) < 0.01m;
}
}
// Adjustment split strategy (one person pays more/less)
public class AdjustmentSplitStrategy : ISplitStrategy
{
public string StrategyName => "Adjustment Split";
public List<ExpenseSplit> CalculateSplits(Money totalAmount, List<int> participantIds, params object[] parameters)
{
if (parameters.Length < 2 || parameters[0] is not int payerId || parameters[1] is not decimal adjustment)
throw new ArgumentException("Payer ID and adjustment amount required");
var equalShare = totalAmount.Amount / participantIds.Count;
var splits = new List<ExpenseSplit>();
foreach (var userId in participantIds)
{
var share = equalShare;
if (userId == payerId)
{
share -= adjustment;
}
splits.Add(new ExpenseSplit(userId, new Money(share, totalAmount.Currency)));
}
return splits;
}
public bool Validate(Money totalAmount, List<int> participantIds, params object[] parameters)
{
return parameters.Length >= 2 && parameters[0] is int && parameters[1] is decimal;
}
}
}
4. Expense Aggregate Root
namespace Splitwise.Domain.Aggregates
{
public class Expense : IAggregateRoot
{
private readonly List<ExpenseSplit> _splits = new();
private readonly List<DomainEvent> _domainEvents = new();
public int Id { get; private set; }
public string Description { get; private set; }
public Money Amount { get; private set; }
public int PaidByUserId { get; private set; }
public int GroupId { get; private set; }
public SplitType SplitType { get; private set; }
public DateTime CreatedAt { get; private set; }
public DateTime? SettledAt { get; private set; }
public ExpenseStatus Status { get; private set; }
public string Notes { get; private set; }
public string ReceiptImageUrl { get; private set; }
public IReadOnlyList<ExpenseSplit> Splits => _splits.AsReadOnly();
public IReadOnlyCollection<DomainEvent> DomainEvents => _domainEvents.AsReadOnly();
private Expense() { }
public Expense(
string description,
Money amount,
int paidByUserId,
int groupId,
SplitType splitType,
List<int> participantIds,
ISplitStrategy splitStrategy,
params object[] parameters)
{
if (string.IsNullOrWhiteSpace(description))
throw new ArgumentException("Description is required", nameof(description));
if (amount.Amount <= 0)
throw new ArgumentException("Amount must be positive", nameof(amount));
if (!participantIds.Contains(paidByUserId))
participantIds.Add(paidByUserId);
// Validate splits
if (!splitStrategy.Validate(amount, participantIds, parameters))
throw new InvalidOperationException("Invalid split configuration");
Description = description;
Amount = amount;
PaidByUserId = paidByUserId;
GroupId = groupId;
SplitType = splitType;
CreatedAt = DateTime.UtcNow;
Status = ExpenseStatus.Pending;
// Calculate splits
var calculatedSplits = splitStrategy.CalculateSplits(amount, participantIds, parameters);
_splits.AddRange(calculatedSplits);
// Validate total matches
var total = _splits.Sum(s => s.Amount.Amount);
if (Math.Abs(total - amount.Amount) > 0.01m)
throw new InvalidOperationException($"Split amounts sum ({total}) does not match total amount ({amount.Amount})");
AddDomainEvent(new ExpenseCreatedEvent(this));
}
public void Settle()
{
if (Status == ExpenseStatus.Settled)
throw new InvalidOperationException("Expense already settled");
Status = ExpenseStatus.Settled;
SettledAt = DateTime.UtcNow;
AddDomainEvent(new ExpenseSettledEvent(this));
}
public void AddNote(string note)
{
Notes = note;
}
public void AddReceipt(string imageUrl)
{
ReceiptImageUrl = imageUrl;
}
public Money GetShareForUser(int userId)
{
var split = _splits.FirstOrDefault(s => s.UserId == userId);
return split?.Amount ?? Money.Zero(Amount.Currency);
}
private void AddDomainEvent(DomainEvent eventItem)
{
_domainEvents.Add(eventItem);
}
public void ClearDomainEvents()
{
_domainEvents.Clear();
}
}
public class ExpenseSplit
{
public int Id { get; private set; }
public int ExpenseId { get; private set; }
public int UserId { get; private set; }
public Money Amount { get; private set; }
public bool IsSettled { get; private set; }
public DateTime? SettledAt { get; private set; }
// Navigation
public Expense Expense { get; private set; }
public User User { get; private set; }
private ExpenseSplit() { }
public ExpenseSplit(int userId, Money amount)
{
UserId = userId;
Amount = amount;
IsSettled = false;
}
public void MarkAsSettled()
{
IsSettled = true;
SettledAt = DateTime.UtcNow;
}
public bool IsOwning(int userId) => UserId != userId && Amount.Amount > 0;
public bool IsOwed(int userId) => UserId == userId && Amount.Amount > 0;
}
// Domain Events
public class ExpenseCreatedEvent : DomainEvent
{
public Expense Expense { get; }
public ExpenseCreatedEvent(Expense expense)
{
Expense = expense;
}
}
public class ExpenseSettledEvent : DomainEvent
{
public Expense Expense { get; }
public ExpenseSettledEvent(Expense expense)
{
Expense = expense;
}
}
}
5. Transaction Entity for Settlements
namespace Splitwise.Domain.Entities
{
public class Transaction
{
public int Id { get; private set; }
public int FromUserId { get; private set; }
public int ToUserId { get; private set; }
public Money Amount { get; private set; }
public int? GroupId { get; private set; }
public string Reference { get; private set; }
public TransactionStatus Status { get; private set; }
public DateTime CreatedAt { get; private set; }
public DateTime? CompletedAt { get; private set; }
public string PaymentMethod { get; private set; }
public string TransactionId { get; private set; }
// Navigation
public User FromUser { get; private set; }
public User ToUser { get; private set; }
public Group Group { get; private set; }
private Transaction() { }
public Transaction(int fromUserId, int toUserId, Money amount, int? groupId = null, string reference = null)
{
if (fromUserId == toUserId)
throw new ArgumentException("Cannot send transaction to self");
if (amount.Amount <= 0)
throw new ArgumentException("Transaction amount must be positive", nameof(amount));
FromUserId = fromUserId;
ToUserId = toUserId;
Amount = amount;
GroupId = groupId;
Reference = reference;
Status = TransactionStatus.Pending;
CreatedAt = DateTime.UtcNow;
}
public void Complete(string paymentMethod, string transactionId)
{
if (Status != TransactionStatus.Pending)
throw new InvalidOperationException($"Transaction already {Status}");
PaymentMethod = paymentMethod;
TransactionId = transactionId;
Status = TransactionStatus.Completed;
CompletedAt = DateTime.UtcNow;
}
public void Fail(string reason)
{
Status = TransactionStatus.Failed;
}
public void Cancel()
{
if (Status == TransactionStatus.Completed)
throw new InvalidOperationException("Cannot cancel completed transaction");
Status = TransactionStatus.Cancelled;
}
}
}
6. Balance Calculator & Debt Simplification Algorithm
namespace Splitwise.Domain.Services
{
public class BalanceCalculator
{
// Calculate net balances from all expenses
public Dictionary<int, Money> CalculateNetBalances(List<Expense> expenses, List<Transaction> transactions, string currency = "USD")
{
var balances = new Dictionary<int, Money>();
// Add expenses
foreach (var expense in expenses)
{
// Payer is owed money
if (!balances.ContainsKey(expense.PaidByUserId))
balances[expense.PaidByUserId] = Money.Zero(currency);
balances[expense.PaidByUserId] = balances[expense.PaidByUserId].Add(expense.Amount);
// Participants owe money
foreach (var split in expense.Splits)
{
if (!balances.ContainsKey(split.UserId))
balances[split.UserId] = Money.Zero(currency);
balances[split.UserId] = balances[split.UserId].Subtract(split.Amount);
}
}
// Subtract settlements (transactions)
foreach (var transaction in transactions.Where(t => t.Status == TransactionStatus.Completed))
{
if (!balances.ContainsKey(transaction.FromUserId))
balances[transaction.FromUserId] = Money.Zero(currency);
balances[transaction.FromUserId] = balances[transaction.FromUserId].Subtract(transaction.Amount);
if (!balances.ContainsKey(transaction.ToUserId))
balances[transaction.ToUserId] = Money.Zero(currency);
balances[transaction.ToUserId] = balances[transaction.ToUserId].Add(transaction.Amount);
}
// Round to eliminate floating point errors
foreach (var userId in balances.Keys.ToList())
{
balances[userId] = new Money(Math.Round(balances[userId].Amount, 2), currency);
}
return balances;
}
// Simplified debt settlement algorithm (minimum transactions)
public List<SettlementTransaction> SimplifyDebts(Dictionary<int, Money> balances)
{
// Split into debtors (negative balance) and creditors (positive balance)
var debtors = balances
.Where(b => b.Value.Amount < -0.01m)
.Select(b => new BalanceNode { UserId = b.Key, Balance = b.Value })
.OrderByDescending(b => b.Balance.Amount)
.ToList();
var creditors = balances
.Where(b => b.Value.Amount > 0.01m)
.Select(b => new BalanceNode { UserId = b.Key, Balance = b.Value })
.OrderByDescending(b => b.Balance.Amount)
.ToList();
var settlements = new List<SettlementTransaction>();
int i = 0, j = 0;
while (i < debtors.Count && j < creditors.Count)
{
var debtor = debtors[i];
var creditor = creditors[j];
var amount = Math.Min(Math.Abs(debtor.Balance.Amount), creditor.Balance.Amount);
var settlementAmount = new Money(amount, debtor.Balance.Currency);
settlements.Add(new SettlementTransaction
{
FromUserId = debtor.UserId,
ToUserId = creditor.UserId,
Amount = settlementAmount
});
// Update balances
debtor.Balance = new Money(debtor.Balance.Amount + amount, debtor.Balance.Currency);
creditor.Balance = new Money(creditor.Balance.Amount - amount, creditor.Balance.Currency);
// Move to next if balance is zero
if (Math.Abs(debtor.Balance.Amount) < 0.01m) i++;
if (Math.Abs(creditor.Balance.Amount) < 0.01m) j++;
}
return settlements;
}
// Alternative algorithm that minimizes transactions using graph theory
public List<SettlementTransaction> SimplifyDebtsOptimized(Dictionary<int, Money> balances)
{
// Filter out users with zero balance
var nonZeroBalances = balances
.Where(b => Math.Abs(b.Value.Amount) > 0.01m)
.ToDictionary(b => b.Key, b => b.Value);
if (nonZeroBalances.Count <= 1)
return new List<SettlementTransaction>();
// Convert to list for processing
var userIds = nonZeroBalances.Keys.ToList();
var amounts = userIds.Select(id => nonZeroBalances[id].Amount).ToList();
// Use DP to find subsets that sum to zero (optimal grouping)
var optimalGroups = FindOptimalGroups(userIds, amounts);
// For each group, calculate settlements within the group
var settlements = new List<SettlementTransaction>();
foreach (var group in optimalGroups)
{
if (group.Count > 1)
{
var groupSettlements = SimplifyGroup(group);
settlements.AddRange(groupSettlements);
}
}
return settlements;
}
private List<List<int>> FindOptimalGroups(List<int> userIds, List<decimal> amounts)
{
// Simplified: return all users as one group
// In production, implement subset sum algorithm to find zero-sum subsets
return new List<List<int>> { userIds };
}
private List<SettlementTransaction> SimplifyGroup(List<int> userIds)
{
// Use the simple algorithm for a single group
var balances = userIds.ToDictionary(id => id, id => new Money(GetBalanceForUser(id), "USD"));
return SimplifyDebts(balances);
}
private decimal GetBalanceForUser(int userId)
{
// Placeholder - would get from actual balances
return 0;
}
}
public class BalanceNode
{
public int UserId { get; set; }
public Money Balance { get; set; }
}
public class SettlementTransaction
{
public int FromUserId { get; set; }
public int ToUserId { get; set; }
public Money Amount { get; set; }
public override string ToString()
{
return $"User {FromUserId} pays {Amount} to User {ToUserId}";
}
}
}
7. Balance Service
namespace Splitwise.Domain.Services
{
public interface IBalanceService
{
Task<Dictionary<int, Money>> GetNetBalancesAsync(int userId);
Task<Dictionary<int, Money>> GetGroupBalancesAsync(int groupId);
Task<List<SettlementTransaction>> GetSettlementPlanAsync(int groupId);
Task<List<SettlementTransaction>> GetUserSettlementPlanAsync(int userId);
Task<Transaction> RecordSettlementAsync(int fromUserId, int toUserId, Money amount, int? groupId = null);
}
public class BalanceService : IBalanceService
{
private readonly IExpenseRepository _expenseRepository;
private readonly ITransactionRepository _transactionRepository;
private readonly IGroupRepository _groupRepository;
private readonly IUserRepository _userRepository;
private readonly BalanceCalculator _calculator;
private readonly ILogger<BalanceService> _logger;
public BalanceService(
IExpenseRepository expenseRepository,
ITransactionRepository transactionRepository,
IGroupRepository groupRepository,
IUserRepository userRepository,
ILogger<BalanceService> logger)
{
_expenseRepository = expenseRepository;
_transactionRepository = transactionRepository;
_groupRepository = groupRepository;
_userRepository = userRepository;
_logger = logger;
_calculator = new BalanceCalculator();
}
public async Task<Dictionary<int, Money>> GetNetBalancesAsync(int userId)
{
var expenses = await _expenseRepository.GetExpensesForUserAsync(userId);
var transactions = await _transactionRepository.GetTransactionsForUserAsync(userId);
var allBalances = _calculator.CalculateNetBalances(expenses, transactions);
// Return only balances involving the user
return allBalances
.Where(b => b.Key == userId || Math.Abs(b.Value.Amount) > 0.01m)
.ToDictionary(b => b.Key, b => b.Value);
}
public async Task<Dictionary<int, Money>> GetGroupBalancesAsync(int groupId)
{
var group = await _groupRepository.GetByIdAsync(groupId);
if (group == null)
throw new ArgumentException($"Group {groupId} not found");
var expenses = await _expenseRepository.GetExpensesForGroupAsync(groupId);
var transactions = await _transactionRepository.GetTransactionsForGroupAsync(groupId);
var balances = _calculator.CalculateNetBalances(expenses, transactions);
// Only include group members
var memberIds = group.Members.Select(m => m.UserId).ToHashSet();
return balances
.Where(b => memberIds.Contains(b.Key))
.ToDictionary(b => b.Key, b => b.Value);
}
public async Task<List<SettlementTransaction>> GetSettlementPlanAsync(int groupId)
{
var balances = await GetGroupBalancesAsync(groupId);
var settlements = _calculator.SimplifyDebts(balances);
_logger.LogInformation("Generated {Count} settlement transactions for group {GroupId}",
settlements.Count, groupId);
return settlements;
}
public async Task<List<SettlementTransaction>> GetUserSettlementPlanAsync(int userId)
{
var balances = await GetNetBalancesAsync(userId);
// Filter out zero balances
var relevantBalances = balances
.Where(b => b.Key != userId && Math.Abs(b.Value.Amount) > 0.01m)
.ToDictionary(b => b.Key, b => b.Value);
var settlements = new List<SettlementTransaction>();
foreach (var balance in relevantBalances)
{
if (balance.Value.Amount > 0)
{
// User is owed money
settlements.Add(new SettlementTransaction
{
FromUserId = balance.Key,
ToUserId = userId,
Amount = balance.Value
});
}
else if (balance.Value.Amount < 0)
{
// User owes money
settlements.Add(new SettlementTransaction
{
FromUserId = userId,
ToUserId = balance.Key,
Amount = new Money(Math.Abs(balance.Value.Amount), balance.Value.Currency)
});
}
}
return settlements;
}
public async Task<Transaction> RecordSettlementAsync(int fromUserId, int toUserId, Money amount, int? groupId = null)
{
var fromUser = await _userRepository.GetByIdAsync(fromUserId);
var toUser = await _userRepository.GetByIdAsync(toUserId);
if (fromUser == null || toUser == null)
throw new ArgumentException("User not found");
var transaction = new Transaction(fromUserId, toUserId, amount, groupId,
$"Settlement payment from {fromUser.Name} to {toUser.Name}");
// Process payment (simulate)
transaction.Complete("Bank Transfer", $"TXN-{DateTime.Now.Ticks}");
await _transactionRepository.AddAsync(transaction);
_logger.LogInformation("Settlement recorded: {FromUser} paid {Amount} to {ToUser}",
fromUser.Name, amount, toUser.Name);
return transaction;
}
}
}
8. Expense Service
namespace Splitwise.Domain.Services
{
public interface IExpenseService
{
Task<Expense> CreateExpenseAsync(CreateExpenseRequest request);
Task<Expense> GetExpenseAsync(int expenseId);
Task<IEnumerable<Expense>> GetGroupExpensesAsync(int groupId);
Task<IEnumerable<Expense>> GetUserExpensesAsync(int userId);
Task<IEnumerable<Expense>> GetRecentExpensesAsync(int userId, int count = 50);
Task<Expense> UpdateExpenseAsync(int expenseId, UpdateExpenseRequest request);
Task DeleteExpenseAsync(int expenseId);
Task<ExpenseSplit> GetUserShareAsync(int expenseId, int userId);
}
public class ExpenseService : IExpenseService
{
private readonly IExpenseRepository _expenseRepository;
private readonly IGroupRepository _groupRepository;
private readonly IUserRepository _userRepository;
private readonly IBalanceService _balanceService;
private readonly Dictionary<SplitType, ISplitStrategy> _splitStrategies;
private readonly ILogger<ExpenseService> _logger;
public ExpenseService(
IExpenseRepository expenseRepository,
IGroupRepository groupRepository,
IUserRepository userRepository,
IBalanceService balanceService,
IEnumerable<ISplitStrategy> splitStrategies,
ILogger<ExpenseService> logger)
{
_expenseRepository = expenseRepository;
_groupRepository = groupRepository;
_userRepository = userRepository;
_balanceService = balanceService;
_logger = logger;
_splitStrategies = splitStrategies.ToDictionary(s =>
Enum.Parse<SplitType>(s.StrategyName.Replace(" Split", "")));
}
public async Task<Expense> CreateExpenseAsync(CreateExpenseRequest request)
{
_logger.LogInformation("Creating expense: {Description} for {Amount}",
request.Description, request.Amount);
// Validate group
var group = await _groupRepository.GetByIdAsync(request.GroupId);
if (group == null)
throw new ArgumentException($"Group {request.GroupId} not found");
// Validate payer is group member
if (!group.Members.Any(m => m.UserId == request.PaidByUserId))
throw new InvalidOperationException("Payer is not a group member");
// Get split strategy
if (!_splitStrategies.TryGetValue(request.SplitType, out var strategy))
throw new NotSupportedException($"Split type {request.SplitType} not supported");
// Prepare participants
var participants = request.ParticipantIds.ToList();
if (!participants.Contains(request.PaidByUserId))
participants.Add(request.PaidByUserId);
// Prepare parameters
object[] parameters = request.SplitType switch
{
SplitType.Exact => new object[] { request.ExactAmounts },
SplitType.Percentage => new object[] { request.Percentages },
SplitType.Adjustment => new object[] { request.AdjustmentPayerId, request.AdjustmentAmount },
_ => Array.Empty<object>()
};
// Create expense
var expense = new Expense(
request.Description,
new Money(request.Amount, request.Currency),
request.PaidByUserId,
request.GroupId,
request.SplitType,
participants,
strategy,
parameters);
if (!string.IsNullOrEmpty(request.Notes))
expense.AddNote(request.Notes);
await _expenseRepository.AddAsync(expense);
_logger.LogInformation("Expense {ExpenseId} created successfully", expense.Id);
return expense;
}
public async Task<Expense> GetExpenseAsync(int expenseId)
{
return await _expenseRepository.GetByIdAsync(expenseId);
}
public async Task<IEnumerable<Expense>> GetGroupExpensesAsync(int groupId)
{
return await _expenseRepository.GetExpensesForGroupAsync(groupId);
}
public async Task<IEnumerable<Expense>> GetUserExpensesAsync(int userId)
{
return await _expenseRepository.GetExpensesForUserAsync(userId);
}
public async Task<IEnumerable<Expense>> GetRecentExpensesAsync(int userId, int count = 50)
{
var expenses = await _expenseRepository.GetExpensesForUserAsync(userId);
return expenses.OrderByDescending(e => e.CreatedAt).Take(count);
}
public async Task<Expense> UpdateExpenseAsync(int expenseId, UpdateExpenseRequest request)
{
var expense = await _expenseRepository.GetByIdAsync(expenseId);
if (expense == null)
throw new ArgumentException($"Expense {expenseId} not found");
if (expense.Status == ExpenseStatus.Settled)
throw new InvalidOperationException("Cannot update settled expense");
if (!string.IsNullOrEmpty(request.Description))
expense.GetType().GetProperty("Description").SetValue(expense, request.Description);
if (!string.IsNullOrEmpty(request.Notes))
expense.AddNote(request.Notes);
await _expenseRepository.UpdateAsync(expense);
return expense;
}
public async Task DeleteExpenseAsync(int expenseId)
{
var expense = await _expenseRepository.GetByIdAsync(expenseId);
if (expense == null)
throw new ArgumentException($"Expense {expenseId} not found");
if (expense.Status == ExpenseStatus.Settled)
throw new InvalidOperationException("Cannot delete settled expense");
await _expenseRepository.DeleteAsync(expenseId);
_logger.LogInformation("Expense {ExpenseId} deleted", expenseId);
}
public async Task<ExpenseSplit> GetUserShareAsync(int expenseId, int userId)
{
var expense = await _expenseRepository.GetByIdAsync(expenseId);
if (expense == null)
throw new ArgumentException($"Expense {expenseId} not found");
return expense.Splits.FirstOrDefault(s => s.UserId == userId);
}
}
public class CreateExpenseRequest
{
public string Description { get; set; }
public decimal Amount { get; set; }
public string Currency { get; set; } = "USD";
public int PaidByUserId { get; set; }
public int GroupId { get; set; }
public SplitType SplitType { get; set; }
public List<int> ParticipantIds { get; set; } = new();
// Exact split parameters
public List<decimal> ExactAmounts { get; set; }
// Percentage split parameters
public List<decimal> Percentages { get; set; }
// Adjustment split parameters
public int AdjustmentPayerId { get; set; }
public decimal AdjustmentAmount { get; set; }
public string Notes { get; set; }
}
public class UpdateExpenseRequest
{
public string Description { get; set; }
public string Notes { get; set; }
}
}
9. API Controllers
namespace Splitwise.API.Controllers
{
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class ExpensesController : ControllerBase
{
private readonly IExpenseService _expenseService;
private readonly ILogger<ExpensesController> _logger;
public ExpensesController(IExpenseService expenseService, ILogger<ExpensesController> logger)
{
_expenseService = expenseService;
_logger = logger;
}
[HttpPost]
public async Task<IActionResult> CreateExpense([FromBody] CreateExpenseRequest request)
{
try
{
var expense = await _expenseService.CreateExpenseAsync(request);
return CreatedAtAction(nameof(GetExpense), new { id = expense.Id }, expense);
}
catch (ArgumentException ex)
{
return BadRequest(new { error = ex.Message });
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
[HttpGet("{id}")]
public async Task<IActionResult> GetExpense(int id)
{
var expense = await _expenseService.GetExpenseAsync(id);
if (expense == null)
return NotFound();
return Ok(expense);
}
[HttpGet("group/{groupId}")]
public async Task<IActionResult> GetGroupExpenses(int groupId)
{
var expenses = await _expenseService.GetGroupExpensesAsync(groupId);
return Ok(expenses);
}
[HttpGet("user/{userId}")]
public async Task<IActionResult> GetUserExpenses(int userId)
{
var expenses = await _expenseService.GetUserExpensesAsync(userId);
return Ok(expenses);
}
[HttpGet("user/{userId}/recent")]
public async Task<IActionResult> GetRecentExpenses(int userId, [FromQuery] int count = 50)
{
var expenses = await _expenseService.GetRecentExpensesAsync(userId, count);
return Ok(expenses);
}
[HttpPut("{id}")]
public async Task<IActionResult> UpdateExpense(int id, [FromBody] UpdateExpenseRequest request)
{
try
{
var expense = await _expenseService.UpdateExpenseAsync(id, request);
return Ok(expense);
}
catch (ArgumentException ex)
{
return NotFound(new { error = ex.Message });
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteExpense(int id)
{
try
{
await _expenseService.DeleteExpenseAsync(id);
return NoContent();
}
catch (ArgumentException ex)
{
return NotFound(new { error = ex.Message });
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
[HttpGet("{expenseId}/share/{userId}")]
public async Task<IActionResult> GetUserShare(int expenseId, int userId)
{
var share = await _expenseService.GetUserShareAsync(expenseId, userId);
if (share == null)
return NotFound();
return Ok(new { userId = share.UserId, amount = share.Amount });
}
}
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class BalancesController : ControllerBase
{
private readonly IBalanceService _balanceService;
public BalancesController(IBalanceService balanceService)
{
_balanceService = balanceService;
}
[HttpGet("user/{userId}")]
public async Task<IActionResult> GetUserBalances(int userId)
{
var balances = await _balanceService.GetNetBalancesAsync(userId);
return Ok(balances.Select(b => new
{
UserId = b.Key,
Amount = b.Value.Amount,
Currency = b.Value.Currency,
IsOwed = b.Value.Amount > 0,
IsOwing = b.Value.Amount < 0
}));
}
[HttpGet("group/{groupId}")]
public async Task<IActionResult> GetGroupBalances(int groupId)
{
var balances = await _balanceService.GetGroupBalancesAsync(groupId);
return Ok(balances);
}
[HttpGet("group/{groupId}/settlement")]
public async Task<IActionResult> GetSettlementPlan(int groupId)
{
var settlements = await _balanceService.GetSettlementPlanAsync(groupId);
return Ok(settlements);
}
[HttpGet("user/{userId}/settlement")]
public async Task<IActionResult> GetUserSettlementPlan(int userId)
{
var settlements = await _balanceService.GetUserSettlementPlanAsync(userId);
return Ok(settlements);
}
[HttpPost("settle")]
public async Task<IActionResult> RecordSettlement([FromBody] SettlementRequest request)
{
try
{
var transaction = await _balanceService.RecordSettlementAsync(
request.FromUserId,
request.ToUserId,
new Money(request.Amount, request.Currency),
request.GroupId);
return Ok(transaction);
}
catch (ArgumentException ex)
{
return BadRequest(new { error = ex.Message });
}
}
}
public class SettlementRequest
{
public int FromUserId { get; set; }
public int ToUserId { get; set; }
public decimal Amount { get; set; }
public string Currency { get; set; } = "USD";
public int? GroupId { get; set; }
}
}
10. Activity Feed Service (Observer Pattern)
namespace Splitwise.Domain.Services
{
public interface IActivityFeedService
{
Task<IEnumerable<ActivityItem>> GetUserActivityAsync(int userId, int page = 1, int pageSize = 20);
Task<IEnumerable<ActivityItem>> GetGroupActivityAsync(int groupId, int page = 1, int pageSize = 20);
Task AddActivityAsync(ActivityItem activity);
}
public class ActivityFeedService : IActivityFeedService
{
private readonly IActivityRepository _activityRepository;
private readonly ILogger<ActivityFeedService> _logger;
public ActivityFeedService(IActivityRepository activityRepository, ILogger<ActivityFeedService> logger)
{
_activityRepository = activityRepository;
_logger = logger;
}
public async Task<IEnumerable<ActivityItem>> GetUserActivityAsync(int userId, int page = 1, int pageSize = 20)
{
return await _activityRepository.GetByUserIdAsync(userId, page, pageSize);
}
public async Task<IEnumerable<ActivityItem>> GetGroupActivityAsync(int groupId, int page = 1, int pageSize = 20)
{
return await _activityRepository.GetByGroupIdAsync(groupId, page, pageSize);
}
public async Task AddActivityAsync(ActivityItem activity)
{
await _activityRepository.AddAsync(activity);
_logger.LogDebug("Added activity: {Type} for user {UserId}", activity.Type, activity.UserId);
}
}
// Domain Event Handlers that update activity feed
public class ExpenseCreatedActivityHandler : IDomainEventHandler<ExpenseCreatedEvent>
{
private readonly IActivityFeedService _activityFeed;
private readonly IUserRepository _userRepository;
public ExpenseCreatedActivityHandler(IActivityFeedService activityFeed, IUserRepository userRepository)
{
_activityFeed = activityFeed;
_userRepository = userRepository;
}
public async Task HandleAsync(ExpenseCreatedEvent domainEvent, CancellationToken cancellationToken = default)
{
var expense = domainEvent.Expense;
var payer = await _userRepository.GetByIdAsync(expense.PaidByUserId);
var activity = new ActivityItem
{
UserId = expense.PaidByUserId,
GroupId = expense.GroupId,
Type = ActivityType.ExpenseCreated,
Description = $"{payer.Name} added an expense '{expense.Description}' for {expense.Amount}",
ExpenseId = expense.Id,
CreatedAt = DateTime.UtcNow
};
await _activityFeed.AddActivityAsync(activity);
}
}
public class SettlementRecordedActivityHandler : IDomainEventHandler<TransactionCompletedEvent>
{
private readonly IActivityFeedService _activityFeed;
private readonly IUserRepository _userRepository;
public SettlementRecordedActivityHandler(IActivityFeedService activityFeed, IUserRepository userRepository)
{
_activityFeed = activityFeed;
_userRepository = userRepository;
}
public async Task HandleAsync(TransactionCompletedEvent domainEvent, CancellationToken cancellationToken = default)
{
var transaction = domainEvent.Transaction;
var fromUser = await _userRepository.GetByIdAsync(transaction.FromUserId);
var toUser = await _userRepository.GetByIdAsync(transaction.ToUserId);
var activity = new ActivityItem
{
UserId = transaction.FromUserId,
GroupId = transaction.GroupId,
Type = ActivityType.Settlement,
Description = $"{fromUser.Name} paid {transaction.Amount} to {toUser.Name}",
TransactionId = transaction.Id,
CreatedAt = DateTime.UtcNow
};
await _activityFeed.AddActivityAsync(activity);
}
}
public enum ActivityType
{
ExpenseCreated,
ExpenseUpdated,
ExpenseDeleted,
Settlement,
GroupMemberJoined,
GroupMemberLeft,
GroupCreated
}
public class ActivityItem
{
public int Id { get; set; }
public int UserId { get; set; }
public int? GroupId { get; set; }
public ActivityType Type { get; set; }
public string Description { get; set; }
public int? ExpenseId { get; set; }
public int? TransactionId { get; set; }
public DateTime CreatedAt { get; set; }
}
}
11. Unit Tests
namespace Splitwise.Tests
{
public class BalanceCalculatorTests
{
[Fact]
public void SimplifyDebts_SimpleCase_ReturnsCorrectTransactions()
{
// Arrange
var calculator = new BalanceCalculator();
var balances = new Dictionary<int, Money>
{
[1] = new Money(100, "USD"), // User 1 is owed $100
[2] = new Money(-100, "USD") // User 2 owes $100
};
// Act
var settlements = calculator.SimplifyDebts(balances);
// Assert
Assert.Single(settlements);
Assert.Equal(2, settlements[0].FromUserId);
Assert.Equal(1, settlements[0].ToUserId);
Assert.Equal(100, settlements[0].Amount.Amount);
}
[Fact]
public void SimplifyDebts_ComplexCase_ReturnsMinimalTransactions()
{
// Arrange
var calculator = new BalanceCalculator();
var balances = new Dictionary<int, Money>
{
[1] = new Money(50, "USD"),
[2] = new Money(100, "USD"),
[3] = new Money(-75, "USD"),
[4] = new Money(-75, "USD")
};
// Act
var settlements = calculator.SimplifyDebts(balances);
// Assert
Assert.Equal(2, settlements.Count);
}
[Fact]
public void EqualSplitStrategy_ValidInput_ReturnsEqualSplits()
{
// Arrange
var strategy = new EqualSplitStrategy();
var participants = new List<int> { 1, 2, 3 };
var total = new Money(300, "USD");
// Act
var splits = strategy.CalculateSplits(total, participants);
// Assert
Assert.Equal(3, splits.Count);
Assert.All(splits, s => Assert.Equal(100, s.Amount.Amount));
}
[Fact]
public void ExactSplitStrategy_ValidInput_ReturnsCorrectSplits()
{
// Arrange
var strategy = new ExactSplitStrategy();
var participants = new List<int> { 1, 2, 3 };
var total = new Money(300, "USD");
var amounts = new List<decimal> { 150, 100, 50 };
// Act
var splits = strategy.CalculateSplits(total, participants, amounts);
// Assert
Assert.Equal(3, splits.Count);
Assert.Equal(150, splits[0].Amount.Amount);
Assert.Equal(100, splits[1].Amount.Amount);
Assert.Equal(50, splits[2].Amount.Amount);
}
[Fact]
public void PercentageSplitStrategy_ValidInput_ReturnsCorrectSplits()
{
// Arrange
var strategy = new PercentageSplitStrategy();
var participants = new List<int> { 1, 2, 3 };
var total = new Money(200, "USD");
var percentages = new List<decimal> { 50, 30, 20 };
// Act
var splits = strategy.CalculateSplits(total, participants, percentages);
// Assert
Assert.Equal(3, splits.Count);
Assert.Equal(100, splits[0].Amount.Amount);
Assert.Equal(60, splits[1].Amount.Amount);
Assert.Equal(40, splits[2].Amount.Amount);
}
}
public class ExpenseTests
{
[Fact]
public void CreateExpense_WithEqualSplit_CreatesCorrectSplits()
{
// Arrange
var strategy = new EqualSplitStrategy();
var participants = new List<int> { 1, 2, 3, 4 };
// Act
var expense = new Expense(
"Dinner",
new Money(200, "USD"),
1, // Paid by user 1
1, // Group ID
SplitType.Equal,
participants,
strategy);
// Assert
Assert.Equal(4, expense.Splits.Count);
Assert.All(expense.Splits, s => Assert.Equal(50, s.Amount.Amount));
Assert.Equal(ExpenseStatus.Pending, expense.Status);
}
[Fact]
public void CreateExpense_WithInvalidSplit_ThrowsException()
{
// Arrange
var strategy = new ExactSplitStrategy();
var participants = new List<int> { 1, 2 };
var amounts = new List<decimal> { 60, 30 }; // Sum = 90, but total is 100
// Act & Assert
var exception = Assert.Throws<InvalidOperationException>(() =>
new Expense(
"Dinner",
new Money(100, "USD"),
1,
1,
SplitType.Exact,
participants,
strategy,
amounts));
Assert.Contains("does not match", exception.Message);
}
}
}
12. Dependency Injection Setup
// Program.cs
var builder = WebApplication.CreateBuilder(args);
// Add services
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// Register repositories
builder.Services.AddScoped<IUserRepository, UserRepository>();
builder.Services.AddScoped<IGroupRepository, GroupRepository>();
builder.Services.AddScoped<IExpenseRepository, ExpenseRepository>();
builder.Services.AddScoped<ITransactionRepository, TransactionRepository>();
builder.Services.AddScoped<IActivityRepository, ActivityRepository>();
// Register strategies
builder.Services.AddScoped<ISplitStrategy, EqualSplitStrategy>();
builder.Services.AddScoped<ISplitStrategy, ExactSplitStrategy>();
builder.Services.AddScoped<ISplitStrategy, PercentageSplitStrategy>();
builder.Services.AddScoped<ISplitStrategy, AdjustmentSplitStrategy>();
// Register domain services
builder.Services.AddScoped<IExpenseService, ExpenseService>();
builder.Services.AddScoped<IBalanceService, BalanceService>();
builder.Services.AddScoped<IActivityFeedService, ActivityFeedService>();
// Register domain event handlers
builder.Services.AddScoped<IDomainEventHandler<ExpenseCreatedEvent>, ExpenseCreatedActivityHandler>();
builder.Services.AddScoped<IDomainEventHandler<TransactionCompletedEvent>, SettlementRecordedActivityHandler>();
var app = builder.Build();
// Configure pipeline
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
13. Summary
In this case study, we've built a complete Splitwise expense-sharing system applying:
- Domain-Driven Design - Users, Groups, Expenses, Transactions as aggregates
- Strategy Pattern - For different expense split types (Equal, Exact, Percentage, Adjustment)
- Observer Pattern - For activity feed updates via domain events
- Balance Calculator - Net balance calculation algorithm
- Debt Simplification - Minimum transaction algorithm
- Repository Pattern - Data access abstraction
- SOLID Principles - Single responsibility, open/closed, dependency inversion
- Event Sourcing - Domain events for audit trail
14. Practice Extensions
Try extending this system with:
- Recurring Expenses - Monthly rent, subscriptions
- Expense Categories - Food, Transport, Entertainment
- Currency Conversion - Support multiple currencies with exchange rates
- Push Notifications - Notify users when expenses are added
- Expense Comments - Allow users to comment on expenses
- Photo Attachments - Upload receipts
- Export Reports - CSV/PDF export of expenses and balances
- API Rate Limiting - Prevent abuse
📝 Key Takeaways:
- Use strategy pattern for flexible expense splitting
- Implement debt simplification for minimal transactions
- Domain events enable loose coupling for side effects like activity feeds
- Balance calculator must handle floating point precision
- Transactions provide audit trail for all payments
- Group membership ensures proper access control
Happy Coding! 🚀