LLD Cohort · Week 8 · Class 1 · recording 1h22m
Payment System & Splitwise
Two classic LLD interview problems. First, a payment system: payment methods, the service / processor / gateway layers, Strategy + Factory, and idempotency (the "charge only once" rule). Then Splitwise, the expense-sharing app built around a balance sheet that is a map of maps.
Each topic: one-line idea → the problem → real-life picture → the design step by step → Python code → doubts & corrections → remember
- Payment system: requirements and the four layers
- Idempotency: an action that counts only once
- Payment system: class design, patterns and Python code
- How a gateway / UPI payment really moves (background)
- Splitwise: expense-sharing app
Payment system: requirements and the four layers ▶ 0:00:00 – 0:21:17
A payment system is the part of a business that takes "please pay ₹1000 for this order" and gets it done safely, through a third-party gateway, exactly once.
The problem
Almost every app (Amazon, Swiggy, a ticket site) ends with a "Pay" button. Behind that button many things must happen: show payment options, validate them, talk to an external company that actually moves the money, track the status, and never charge twice. In the interview you are not asked to build Razorpay or Stripe. You are asked to design the payment system of your business, which uses such a company. The teacher warns: if you do not first understand what a payment system does, you will assume bad requirements and design the wrong thing.
On Amazon you click Pay. You land on a page with options: UPI, card, net banking, cash on delivery. You pick one, you get an OTP, and you see "Payment successful". Amazon does not talk to every bank itself. It hands the job to a payment gateway such as Razorpay or Stripe, whose whole business is talking to banks. Amazon's own code only sits in the middle and decides things like discounts, retries and "was this already paid?".
Requirements gathered in class
Functional (what it must do):
- A user can initiate a payment (who pays, how much, to whom).
- Support multiple payment methods: UPI, credit/debit card, net banking, digital wallet, cash on delivery. New ones must be easy to add.
- Process the payment through an external payment gateway (Razorpay, Stripe, PayPal...).
- Track payment status: CREATED → PROCESSING → SUCCESS / FAILED.
- Handle success and failure (and refunds).
- Keep a record of every transaction (payer, payee, amount, status, metadata).
Non-functional (how well it must do it):
- Idempotency: no double charge, even if the user clicks Pay ten times. (Next topic.)
- Reliability and extensibility (new gateways / methods without rewriting code).
- Durability: once a payment is recorded, it survives crashes and disasters. Durability is the "D" in ACID (Atomicity, Consistency, Isolation, Durability), the four guarantees of a database transaction. Mention it: it shows you care about how fragile money flows are.
The four layers (the part people mix up)
| Layer | Who owns it | Job |
|---|---|---|
| PaymentService | You | The front door (a facade: one simple class that hides the rest). Clients call make_payment(...). Checks idempotency, creates the Payment, logs. |
| PaymentProcessor | You | Your business logic between you and the gateway: validation, discounts/coupons, retries, fraud rules. It knows which gateway to use (via a factory). |
| PaymentGateway | Third party (Stripe, Razorpay, PayPal) | Actually moves money with the banks. Exposes authorize, capture, refund. |
| PaymentMethod | Chosen by the user | How the user pays (UPI, card...). Each method validates itself (is the UPI id valid? is the card number valid?). |
Why not let the service call the gateway directly? Because the gateway is someone else's system and you have no control over it. You need a layer where your rules live. Tomorrow you may want a retry around the gateway call (a Decorator), a coupon, or a check that blocks duplicates. That layer is the processor. This is plain separation of concerns: each class has one job.
Payment gateway = third party that talks to banks. Payment processor = your code that orchestrates the gateway and applies your company's rules. In most companies you design the system/processor, not the gateway (unless you interview at Razorpay itself).
Core entities
- User: id, name, email, preferred payment method.
- Payment (transaction): payment id, amount, status, payer, payee, metadata.
- PaymentStatus: CREATED, PROCESSING, SUCCESS, FAILED.
- PaymentMethod strategies: Card, UPI, NetBanking, Wallet, COD.
- PaymentGateway strategies: Stripe, Razorpay, PayPal; created by a GatewayFactory.
- IdempotencyService: remembers which requests were already handled.
→
The cashback is promised and paid by the bank, not by the payment system. Your system's part is to pass the right metadata with the payment (partner, offer code, amount, card/bank). The bank keeps its own offer database (for example a "Diwali sale" table) and background workers that later credit the cashback to the original source (or an alternate UPI id). The cashback is a separate transaction recorded in a ledger, not part of the original one. In your LLD, a discount that your company gives (a coupon) belongs in the PaymentProcessor; a bank's cashback is just metadata you forward.
- Start by understanding what a payment system does before listing classes.
- Functional: initiate, multiple methods, external gateway, status tracking, transaction record.
- Non-functional: idempotency, reliability, extensibility, durability (ACID).
- Service (facade) → Processor (your rules) → Gateway (third party). Method = how the user pays.
Idempotency: an action that counts only once ▶ 0:22:30 – 0:40:52
An operation is idempotent if doing it many times has the same effect as doing it once.
The problem
The user is on the final payment page and, because of a slow network or an impatient finger, clicks Pay 10 times. The backend receives 10 "pay ₹1000" requests. Without protection, the user is charged ₹10,000. Payment systems are very sensitive: a duplicate charge is never acceptable.
Pressing the lift call button 10 times still brings one lift. Paying for an order should work the same way: ten clicks, one payment. In maths terms: if an action gives result Y the first time, repeating it must still leave you at Y, not 2Y or 10Y.
How it works: the idempotency key
- When the user starts a payment, the client generates a unique idempotency key (for example a UUID) for that one action. Every retry or double-click of the same action carries the same key (usually in an HTTP header).
- The server keeps an idempotency table: key → result. The teacher used a
ConcurrentHashMap<String, String>; in production it is a database table. - On each request: if the key is already in the table, do not charge again, just return the stored result. If not, process the payment and store the result under that key.
Example: key order-42-click. Click 1: key unknown → charge ₹1000 → store "SUCCESS, payment ea89". Clicks 2–10: key known → return "SUCCESS, payment ea89". Total charged: ₹1000.
It is not a "UI thing" or a "backend thing"
A student asked whether the key comes from the UI. The teacher's point: idempotency is a property you add wherever one system calls another and a repeat would hurt. Between browser and your server, the browser sends the key. Between your server and the gateway, your server may retry after a network failure, so your server generates a key and the gateway uses it to detect the repeat. Rule of thumb: if duplicates would cause damage, introduce an idempotency key at that boundary.
In the class code the flow was: check key → process payment → then store the key. If two clicks arrive at exactly the same moment, both can pass the check before either stores the key, and you charge twice. Fix: check and reserve the key in one atomic step (Java: putIfAbsent on the ConcurrentHashMap; Python: a lock around check+insert; database: a unique constraint on the key), mark it "in progress", and fill in the result later. The Python code in the next topic does this.
→
That would be a man-in-the-middle attack: someone captures the request on the way, then replays it in a loop with key 1, 2, 3, 4... Each new key looks like a new payment. This is exactly why payment traffic always uses HTTPS: the request is encrypted on the client and decrypted on the server, so a middleman can neither read nor modify it. (A student from fintech added that payment systems also go through heavy third-party security audits and penetration tests, and RBI requires such reports before launch.)
→
Persisting. Once a payment action is done, it must be stored somewhere safe, so that even after a crash or disaster you still know it happened. It is the last property of ACID.
- Idempotent = many executions, one effect. Payments must be idempotent.
- Same user action → same idempotency key; server stores key → result and returns the old result on repeats.
- Check-and-reserve must be atomic, or simultaneous duplicates slip through.
- Use it at any boundary where retries happen (UI → server, server → gateway).
- Mentioning an idempotency table in an interview usually leads to good follow-up questions — be ready.
Payment system: class design, patterns and Python code ▶ 0:26:50 – 0:35:45, 0:47:01 – 0:54:04
Strategy for payment methods and gateways, Factory to create and reuse gateways, a Facade service in front, and an idempotency check before every charge.
Which design patterns and why (as the teacher said)
| Pattern | Where | Why |
|---|---|---|
| Strategy | PaymentMethod (Card, UPI, NetBanking); PaymentGateway (Stripe, Razorpay, PayPal) | Many interchangeable ways to do the same job. Adding "Wallet" or "PayPal" means adding one class, not editing if/else chains (Open/Closed Principle). |
| Factory | GatewayFactory | One place that creates gateway objects. It also keeps them in a dictionary so each gateway object is created once and reused. |
| Facade | PaymentService | Clients see one simple method, make_payment; processor, gateway and idempotency stay hidden. |
| Decorator (extension) | around a gateway | Add retry or logging around gateway.capture without changing the gateway class. |
The flow
- Client calls
PaymentService.make_payment(amount, method, idempotency_key). - Service asks
IdempotencyService: seen this key? If yes → return the earlier result. - Service creates a
Payment(status CREATED). PaymentProcessor.process_payment: status PROCESSING → method validates itself → gatewayauthorize+capture→ SUCCESS or FAILED.- Service stores the result under the key and returns it.
classDiagram
class PaymentService {
+make_payment(amount, method, key)
}
class IdempotencyService {
+try_reserve(key)
+save(key, payment)
}
class PaymentProcessor {
+process_payment(payment)
}
class GatewayFactory {
+get(name)
}
class PaymentGateway {
+authorize(payment)
+capture(payment)
+refund(payment)
}
class PaymentMethod {
+validate()
}
class Payment {
+payment_id
+amount
+status
}
PaymentService *-- PaymentProcessor
PaymentService *-- IdempotencyService
PaymentProcessor o-- PaymentGateway
GatewayFactory --> PaymentGateway
Payment --> PaymentMethod
PaymentProcessor --> Payment
StripeGateway ..|> PaymentGateway
RazorpayGateway ..|> PaymentGateway
CardPayment ..|> PaymentMethod
UPIPayment ..|> PaymentMethod
NetBankingPayment ..|> PaymentMethod
Python code (small working version)
import threading
import uuid
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum
class PaymentStatus(Enum):
CREATED = "CREATED"
PROCESSING = "PROCESSING"
SUCCESS = "SUCCESS"
FAILED = "FAILED"
@dataclass
class Payment:
amount: float
method: "PaymentMethod"
payment_id: str = field(default_factory=lambda: uuid.uuid4().hex[:8])
status: PaymentStatus = PaymentStatus.CREATED
# ---------- Strategy 1: HOW the user pays (payment method) ----------
class PaymentMethod(ABC):
@abstractmethod
def validate(self) -> bool: ...
class CardPayment(PaymentMethod):
def __init__(self, card_number: str):
self.card_number = card_number
def validate(self) -> bool:
return len(self.card_number) == 16 and self.card_number.isdigit()
class UPIPayment(PaymentMethod):
def __init__(self, upi_id: str):
self.upi_id = upi_id
def validate(self) -> bool:
return "@" in self.upi_id
class NetBankingPayment(PaymentMethod):
def __init__(self, bank: str):
self.bank = bank
def validate(self) -> bool:
return bool(self.bank)
# ---------- Strategy 2: WHO moves the money (third-party gateway) ----------
class PaymentGateway(ABC):
@abstractmethod
def authorize(self, payment: Payment) -> bool: ...
@abstractmethod
def capture(self, payment: Payment) -> bool: ...
@abstractmethod
def refund(self, payment: Payment) -> bool: ...
class StripeGateway(PaymentGateway):
def authorize(self, payment): print(f" [Stripe] authorize {payment.amount}"); return True
def capture(self, payment): print(f" [Stripe] capture {payment.amount}"); return True
def refund(self, payment): print(f" [Stripe] refund {payment.amount}"); return True
class RazorpayGateway(PaymentGateway):
def authorize(self, payment): print(f" [Razorpay] authorize {payment.amount}"); return True
def capture(self, payment): print(f" [Razorpay] capture {payment.amount}"); return True
def refund(self, payment): print(f" [Razorpay] refund {payment.amount}"); return True
# ---------- Factory: create each gateway once, then reuse it ----------
class GatewayFactory:
_registry = {"stripe": StripeGateway, "razorpay": RazorpayGateway}
_cache: dict[str, PaymentGateway] = {}
_lock = threading.Lock()
@classmethod
def get(cls, name: str) -> PaymentGateway:
with cls._lock:
if name not in cls._cache: # build only the first time
cls._cache[name] = cls._registry[name]()
return cls._cache[name]
# ---------- Processor: OUR business logic between service and gateway ----------
class PaymentProcessor:
def __init__(self, gateway: PaymentGateway):
self.gateway = gateway
def process_payment(self, payment: Payment) -> Payment:
payment.status = PaymentStatus.PROCESSING
if not payment.method.validate(): # method validates itself
payment.status = PaymentStatus.FAILED
return payment
# place for company rules: discounts, retries, fraud checks, logging...
ok = self.gateway.authorize(payment) and self.gateway.capture(payment)
payment.status = PaymentStatus.SUCCESS if ok else PaymentStatus.FAILED
return payment
# ---------- Idempotency: same key => same single result ----------
class IdempotencyService:
def __init__(self):
self._seen: dict[str, Payment | None] = {} # key -> result (None = in progress)
self._lock = threading.Lock()
def try_reserve(self, key: str) -> tuple[bool, Payment | None]:
"""(True, None) if key is new; (False, old_result) if seen before."""
with self._lock: # check AND reserve in one atomic step
if key in self._seen:
return False, self._seen[key]
self._seen[key] = None # mark "in progress"
return True, None
def save(self, key: str, payment: Payment) -> None:
with self._lock:
self._seen[key] = payment
# ---------- Facade: the only class the outside world talks to ----------
class PaymentService:
def __init__(self, gateway_name: str = "razorpay"):
self.processor = PaymentProcessor(GatewayFactory.get(gateway_name))
self.idempotency = IdempotencyService()
def make_payment(self, amount: float, method: PaymentMethod, idem_key: str):
is_new, previous = self.idempotency.try_reserve(idem_key) # 1. check key
if not is_new:
print(f"Duplicate request {idem_key} -> no new charge")
return previous
payment = Payment(amount, method) # 2. create
self.processor.process_payment(payment) # 3. process
self.idempotency.save(idem_key, payment) # 4. remember
return payment
# usage
if __name__ == "__main__":
service = PaymentService("razorpay")
key = "order-42-click" # same UI action => same key
p1 = service.make_payment(1000, UPIPayment("pradeep@okhdfc"), key)
print(p1.payment_id, p1.status.value) # e.g. 'ea892f91 SUCCESS' (after authorize+capture logs)
p2 = service.make_payment(1000, UPIPayment("pradeep@okhdfc"), key) # double click
print(p2 is p1) # True: charged only once
p3 = service.make_payment(500, CardPayment("1234"), "order-43")
print(p3.status.value) # FAILED: bad card numberJava interface PaymentGateway becomes an ABC with @abstractmethod. Java's ConcurrentHashMap has no direct twin; a normal dict guarded by a threading.Lock gives the same safety (and lets us do check+reserve atomically). An enum becomes enum.Enum.
Extensions you can mention
- Retry with a Decorator:
RetryingGateway(inner_gateway, attempts=3)that wrapscapture; pass an idempotency key to the gateway so retries are safe. - Refunds:
PaymentService.refund(payment_id)→ processor →gateway.refund; status REFUNDED. - Discounts / coupons: a
DiscountStrategyapplied inside the processor before calling the gateway. - Bank offers: forward offer metadata to the gateway; the bank pays cashback later as a separate ledger transaction.
- Notifications: Observer — notify order service / email / SMS when status changes.
- Ledger + durability: write every state change to a database table (never only in memory).
- Gateway routing: factory picks the gateway by method, cost or health (fallback to Stripe if Razorpay is down).
→
A friend of the teacher was asked: you have 5–10 payment methods; how do you keep and reuse those objects? The expected answer was a Factory backed by a HashMap: create each object once, store it in the map, and return the stored one next time. The candidate kept writing if/else chains and missed it. Lesson: interviews dig deep into basics.
→
First list the problems. Duplicate requests are already solved by idempotency (and the idempotency map itself is thread-safe). Each payment is independent and shares no data with the others, so the processor needs no lock. Balance updates and locking (pessimistic/optimistic locks, ACID) happen inside the gateway's and the banks' databases — not in your orchestration layer. Handling thousands of requests per second is a scaling (HLD) topic, out of scope for this LLD. You can still mention these points to show awareness.
- Two Strategy hierarchies: PaymentMethod (how user pays) and PaymentGateway (who moves money).
- Factory creates gateways and caches them for reuse.
- PaymentService is a facade; PaymentProcessor holds your business rules.
- Idempotency check happens before creating the payment; result is stored after.
How a gateway / UPI payment really moves (background) ▶ 0:40:52 – 0:47:01
Context only, not needed in the LLD: what happens after your system hands the payment to the outside world.
Gateway path (Razorpay, Stripe): banks expose APIs; the gateway validates the request, runs fraud checks, asks the payer's bank to debit (checking balance) and the payee's bank to credit. This is why gateways charge around 2% per transaction.
UPI path (students explained, teacher summarised):
- You pay from PhonePe. PhonePe is your PSP (Payment Service Provider).
- The PSP forwards the request to NPCI, the central router of UPI.
- NPCI asks the payee's PSP (say Google Pay) to resolve the UPI id to a bank account (say SBI).
- NPCI tells the banks to debit and credit, and sends the result back.
UPI became fast because it removed many middle layers: it is almost bank-to-bank with NPCI in the middle. Earlier transfers could take 4–6 hours.
→
You do not choose by phone number; you pay to a UPI id. That id belongs to one PSP, and NPCI's registry knows which PSP and bank sit behind it.
Splitwise: expense-sharing app ▶ 0:54:04 – 1:11:34
Record who paid for a shared expense, split it among the people involved, and keep a running "who owes whom" balance for every pair of users.
The problem
The teacher calls this one of the most feared LLD questions; candidates have failed Amazon rounds on it (recently, one did not know the balance-sheet idea and could not manage the "map of map"). Once you know the structure, it is one of the easier ones; only the "simplify debts" part is a hard DSA problem.
Five college friends go to dinner. The bill is ₹4,000 and only Pradeep pays. Nobody settles on the spot. Over a month there are many such bills: ₹30 here, ₹230 there, ₹620 somewhere else. Splitwise keeps the account: open the app and you see "Ajay owes you ₹120" in green and "you owe Sumit ₹200" in red.
Requirements
- Add users; create groups and add users to them.
- Add an expense: amount, who paid, who is involved.
- Split types: EQUAL, EXACT (someone did not eat the burger), PERCENT. New types must be easy to add.
- Show balances for a user; (optional) simplify debts so the group needs the fewest transfers.
- Non-functional: thread safety of balance updates, a scalable ledger, extensible split types.
Core entities
- User: id, name, email.
- Split: one person's share of an expense — which user and how much they owe.
- Expense: id, amount, paid_by, list of splits.
- SplitStrategy: Equal / Exact / Percent — computes and validates the shares (Strategy pattern).
- BalanceSheet:
balances[user][other] = amount— a map of maps. - SplitwiseService: facade exposing
add_expense,show_balances.
The balance-sheet update, step by step
Pradeep pays ₹1000 for A, B, C, D equally: each split is ₹250. For every split whose user is not the payer:
- In Pradeep's sheet:
balances[Pradeep][Ajay] += 250→ "Ajay owes me 250 more". - In Ajay's sheet:
balances[Ajay][Pradeep] -= 250→ "I owe Pradeep 250 more" (negative = I owe). - If the split's user is the payer, skip it — you do not owe yourself.
Example with history: Ajay already owed Pradeep ₹100. After the dinner, Pradeep's sheet shows Ajay → ₹350. If instead Pradeep had owed Ajay ₹1000, the new ₹250 simply reduces that debt — the plus/minus convention handles both cases automatically. Always update both sides.
classDiagram
class SplitwiseService {
+add_expense(amount, paid_by, users, kind, values)
}
class BalanceSheet {
+balances
+update(expense)
+show(user)
}
class Expense {
+expense_id
+amount
+paid_by
+splits
}
class Split {
+user
+amount
}
class User {
+user_id
+name
+email
}
class SplitStrategy {
+compute(amount, splits, values)
}
SplitwiseService *-- BalanceSheet
SplitwiseService --> SplitStrategy
Expense *-- Split
Expense --> User
Split --> User
EqualSplit ..|> SplitStrategy
ExactSplit ..|> SplitStrategy
PercentSplit ..|> SplitStrategy
Python code
import threading
from abc import ABC, abstractmethod
from collections import defaultdict
from dataclasses import dataclass, field
@dataclass(frozen=True)
class User:
user_id: str
name: str
email: str = ""
@dataclass
class Split:
user: User # who owes
amount: float = 0 # how much of the bill is this user's share
# ---------- Strategy: how one bill is divided ----------
class SplitStrategy(ABC):
@abstractmethod
def compute(self, amount: float, splits: list[Split], values: list[float]) -> None: ...
class EqualSplit(SplitStrategy):
def compute(self, amount, splits, values):
share = round(amount / len(splits), 2)
for s in splits:
s.amount = share
class ExactSplit(SplitStrategy):
def compute(self, amount, splits, values):
if abs(sum(values) - amount) > 0.01:
raise ValueError("exact amounts must add up to the total")
for s, v in zip(splits, values):
s.amount = v
class PercentSplit(SplitStrategy):
def compute(self, amount, splits, values):
if abs(sum(values) - 100) > 0.01:
raise ValueError("percentages must add up to 100")
for s, pct in zip(splits, values):
s.amount = round(amount * pct / 100, 2)
@dataclass
class Expense:
expense_id: str
amount: float
paid_by: User
splits: list[Split] = field(default_factory=list)
# ---------- Balance sheet: map of maps ----------
class BalanceSheet:
def __init__(self):
# balances[a][b] > 0 => b owes a ; < 0 => a owes b
self.balances: dict[User, dict[User, float]] = defaultdict(lambda: defaultdict(float))
self._lock = threading.Lock() # updates are a critical section
def update(self, expense: Expense) -> None:
payer = expense.paid_by
with self._lock:
for split in expense.splits:
if split.user == payer: # I don't owe myself
continue
self.balances[payer][split.user] += split.amount # they owe me more
self.balances[split.user][payer] -= split.amount # in their sheet: they owe me
def show(self, user: User) -> None:
for other, amt in self.balances[user].items():
if amt > 0:
print(f" {other.name} owes {user.name}: {amt}")
elif amt < 0:
print(f" {user.name} owes {other.name}: {-amt}")
# ---------- Facade ----------
class SplitwiseService:
STRATEGIES = {"EQUAL": EqualSplit(), "EXACT": ExactSplit(), "PERCENT": PercentSplit()}
def __init__(self):
self.sheet = BalanceSheet()
self._next_id = 0
def add_expense(self, amount, paid_by, users, kind="EQUAL", values=None):
splits = [Split(u) for u in users]
self.STRATEGIES[kind].compute(amount, splits, values or [])
self._next_id += 1
expense = Expense(f"E{self._next_id}", amount, paid_by, splits)
self.sheet.update(expense)
return expense
# usage
if __name__ == "__main__":
pradeep, ajay, sumit, david = (User("1", "Pradeep"), User("2", "Ajay"),
User("3", "Sumit"), User("4", "David"))
app = SplitwiseService()
app.add_expense(1000, pradeep, [pradeep, ajay, sumit, david]) # dinner
app.add_expense(300, ajay, [pradeep, ajay], "EXACT", [200, 100]) # cab
print("Pradeep's view:"); app.sheet.show(pradeep)
print("Ajay's view:"); app.sheet.show(ajay)The update method is a critical section: many users add expenses at the same time and all touch the same map. Protect it with a lock. The teacher used a Java ReentrantLock with lock() / unlock() in finally; in Python, with self._lock: does both.
Java writes balances.computeIfAbsent(a, k -> new HashMap<>()).merge(b, amt, Double::sum). Python's defaultdict(lambda: defaultdict(float)) creates missing inner maps and zero values for you, so balances[a][b] += amt just works. For real money use decimal.Decimal or integer paise instead of floats.
Extensions
- Groups:
Group(name, members, expenses); balances per group plus overall. - Simplify debts: compute each person's net balance, then repeatedly match the biggest debtor with the biggest creditor (greedy), or the exact minimum-transfers version (LeetCode 465, Optimal Account Balancing, backtracking).
- Settle up: a payment is just an expense where the debtor "pays" the creditor.
- Notifications: Observer — notify people when an expense involving them is added.
- Entities: User, Split (a share), Expense (amount, payer, splits), BalanceSheet, SplitwiseService.
- Split types are a Strategy; each one validates its own input (exact sums to total, percents to 100).
- BalanceSheet = map of maps; update both sides with opposite signs; skip the payer.
- Lock the update — it is the critical section.