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 ▶ 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.

Real-life picture

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):

Non-functional (how well it must do it):

The four layers (the part people mix up)

LayerWho owns itJob
PaymentServiceYouThe front door (a facade: one simple class that hides the rest). Clients call make_payment(...). Checks idempotency, creates the Payment, logs.
PaymentProcessorYouYour business logic between you and the gateway: validation, discounts/coupons, retries, fraud rules. It knows which gateway to use (via a factory).
PaymentGatewayThird party (Stripe, Razorpay, PayPal)Actually moves money with the banks. Exposes authorize, capture, refund.
PaymentMethodChosen by the userHow 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.

Remember

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

Doubt: Bank offers like "10% cashback with HDFC card" — where does that sit in the design?
→

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.

Key takeaways
  • 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.

Real-life picture

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

  1. 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).
  2. The server keeps an idempotency table: key → result. The teacher used a ConcurrentHashMap<String, String>; in production it is a database table.
  3. 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.

Small correction:

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.

Doubt: The key travels in an HTTP header. Can an attacker change it and charge the user many times?
→

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.)

Doubt: What does durability mean here?
→

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.

Key takeaways
  • 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)

PatternWhereWhy
StrategyPaymentMethod (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).
FactoryGatewayFactoryOne place that creates gateway objects. It also keeps them in a dictionary so each gateway object is created once and reused.
FacadePaymentServiceClients see one simple method, make_payment; processor, gateway and idempotency stay hidden.
Decorator (extension)around a gatewayAdd retry or logging around gateway.capture without changing the gateway class.

The flow

  1. Client calls PaymentService.make_payment(amount, method, idempotency_key).
  2. Service asks IdempotencyService: seen this key? If yes → return the earlier result.
  3. Service creates a Payment (status CREATED).
  4. PaymentProcessor.process_payment: status PROCESSING → method validates itself → gateway authorize + capture → SUCCESS or FAILED.
  5. 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)

Python
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 number
Java vs Python

Java 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

Doubt: Interview story — "how will you avoid creating payment-method objects again and again?"
→

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.

Doubt: How do we handle thread safety when many users pay at the same time?
→

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.

Key takeaways
  • 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):

  1. You pay from PhonePe. PhonePe is your PSP (Payment Service Provider).
  2. The PSP forwards the request to NPCI, the central router of UPI.
  3. NPCI asks the payee's PSP (say Google Pay) to resolve the UPI id to a bank account (say SBI).
  4. 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.

Doubt: My number is registered on both Google Pay and PhonePe. How does the payer know which one to use?
→

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.

Real-life picture

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

Core entities

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:

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

Python
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)
Remember

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 vs Python

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

Key takeaways
  • 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.