CS 2 - Splitwise for LLD - IndianTechnoEra
Latest update Android YouTube

CS 2 - Splitwise for LLD

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:

  1. Recurring Expenses - Monthly rent, subscriptions
  2. Expense Categories - Food, Transport, Entertainment
  3. Currency Conversion - Support multiple currencies with exchange rates
  4. Push Notifications - Notify users when expenses are added
  5. Expense Comments - Allow users to comment on expenses
  6. Photo Attachments - Upload receipts
  7. Export Reports - CSV/PDF export of expenses and balances
  8. 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! 🚀

Post a Comment

Feel free to ask your query...
Cookie Consent
We serve cookies on this site to analyze traffic, remember your preferences, and optimize your experience.
Oops!
It seems there is something wrong with your internet connection. Please connect to the internet and start browsing again.
AdBlock Detected!
We have detected that you are using adblocking plugin in your browser.
The revenue we earn by the advertisements is used to manage this website, we request you to whitelist our website in your adblocking plugin.
Site is Blocked
Sorry! This site is not available in your country.