Abstraction in OOP: A Checkout That Ignores Payment Details

Abstraction in OOP explained with a TypeScript checkout for card, UPI and wallet payments, with runnable code, real output and interview follow-ups.

OOP in Practice · Episode 2 of 70 · OOP Foundations · Beginner · TypeScript 7.0.2 on Node.js v22.23.4

The problem

You’re building checkout for a small online store, and this is abstraction in OOP at its most practical. Customers pay by card, UPI or a store wallet. Today checkout knows every detail of each method: card limits, UPI id formats, wallet balances. Adding a gift card touches several places, and one missed branch marks unpaid orders as paid.

Your task: redesign checkout so it can take any payment method without knowing how that method moves money.

Requirements

  • Checkout totals the cart, assigns an order id and charges the chosen payment method.
  • Card, UPI and wallet each apply their own rules; a decline shows its reason.
  • A new payment method can be added without editing checkout.
  • Invalid carts (empty, zero quantity, bad price) are rejected before any money moves.
  • Receipts never show a full card number.

Constraints and assumptions

  • Single process, in memory, no real gateway; calls are synchronous.
  • Amounts are integer paise (₹1 = 100 paise) to avoid floating-point rounding.

What this tests: abstraction, interfaces, structural typing, test doubles

Abstraction in OOP means checkout depends on a small PaymentMethod interface with a label and a charge() method, not on card, UPI or wallet classes. Each class implements charge() with its own rules and private state. Adding a gift card is one new class; checkout does not change.

Try it yourself first — hint

Write down the one question checkout actually asks every payment method. That question, as a method signature, is your interface.

Step 1: Identify the objects and responsibilities

Classes after the redesign
Class / interface Responsibility Key methods
PaymentMethod (interface) The contract checkout depends on. label, charge(amountPaise, orderId)
ChargeResult (type) Paid with a reference, or declined with a reason. { ok: true, reference } / { ok: false, reason }
CardPayment, UpiPayment, WalletPayment Each applies its own rules: card limit, UPI id format, wallet balance. charge()
Checkout Validates and totals the cart, assigns order ids, builds receipts. pay(items, method)

The verb every payment method shares is “charge this amount”. That verb becomes the interface.

Step 2: The naive approach and why it breaks

type Payment =
  | { kind: "card"; cardNumber: string; limitLeft: number }
  | { kind: "upi"; vpa: string }
  | { kind: "wallet"; balance: number };

function checkout(totalPaise: number, p: Payment): string {
  if (p.kind === "card") {
    if (totalPaise > p.limitLeft) return "DECLINED card limit exceeded";
    p.limitLeft -= totalPaise;
    return `PAID card ••••${p.cardNumber.slice(-4)}`;
  } else if (p.kind === "upi") {
    if (!p.vpa.includes("@")) return "DECLINED bad UPI id";
    return `PAID UPI ${p.vpa}`;
  } else if (p.kind === "wallet") {
    if (totalPaise > p.balance) return "DECLINED wallet balance";
    p.balance -= totalPaise;
    return "PAID wallet";
  }
  return "PAID"; // unknown kind falls through as success
}

This compiles under --strict and works for three methods. It breaks as soon as the business changes:

  • Adding a gift card edits checkout, plus every other function that switches on kind.
  • A missed branch becomes a free order. Add "giftcard" to the union, forget the branch, and the final return "PAID" still compiles. Nothing was charged. An unexpected kind from JSON does the same.
  • Checkout holds card numbers it never needed, so every checkout test builds fake card data.
  • Nothing is private. Any caller can set balance, which undoes encapsulation.

Step 3: The design

            +------------------------------+
            |           Checkout           |
            |------------------------------|
            | pay(items, method): Receipt  |
            +--------------+---------------+
                           | depends only on the interface
                           v
          +-----------------------------------+
          |   <<interface>> PaymentMethod     |
          |-----------------------------------|
          | readonly label: string            |
          | charge(amountPaise, orderId)      |
          |     : ChargeResult                |
          +-----------------------------------+
             ^          ^           ^          ^
             |          |           |          |   implements
  +----------+--+ +-----+------+ +--+-------+ +-+------------+
  | CardPayment | | UpiPayment | | Wallet-  | | GiftCard-    |
  | #number     | | vpa check  | | Payment  | | Payment      |
  | #limitLeft  | |            | | #balance | | #balance     |
  +-------------+ +------------+ +----------+ +--------------+

Wikipedia’s definition fits well: an abstraction “provides access while hiding details” (Abstraction (computer science)). Checkout needs “charge this amount”, not card limits.

So Checkout points at one interface and never names a concrete class. Each payment class keeps its state in JavaScript private fields (#balance), which code outside the class cannot reference (MDN: private elements).

Interchangeable classes behind one interface is what the Strategy pattern formalises (Refactoring.Guru: Strategy). It gets its own episode, and calling charge() on whichever object you were given is polymorphism.

Step 4: The abstraction in OOP solution in TypeScript

// Abstraction in OOP: checkout depends on a PaymentMethod contract,
// not on card, UPI or wallet internals. Amounts are integer paise (₹1 = 100).

type ChargeResult =
  | { ok: true; reference: string }
  | { ok: false; reason: string };

/** The abstraction: everything checkout needs from a payment method. */
interface PaymentMethod {
  readonly label: string; // safe to print on a receipt
  charge(amountPaise: number, orderId: string): ChargeResult;
}

class CardPayment implements PaymentMethod {
  readonly label: string;
  #number: string;
  #limitLeft: number;
  constructor(cardNumber: string, limitPaise: number) {
    if (!/^\d{16}$/.test(cardNumber)) throw new Error("card number must be 16 digits");
    this.#number = cardNumber;
    this.#limitLeft = limitPaise;
    this.label = `Card ••••${cardNumber.slice(-4)}`;
  }
  charge(amountPaise: number, orderId: string): ChargeResult {
    if (amountPaise > this.#limitLeft) return { ok: false, reason: "card limit exceeded" };
    this.#limitLeft -= amountPaise; // a real gateway call would use this.#number here
    return { ok: true, reference: `CRD-${orderId}` };
  }
}

class UpiPayment implements PaymentMethod {
  readonly label: string;
  constructor(vpa: string) {
    if (!/^[\w.-]+@[a-z]+$/.test(vpa)) throw new Error(`invalid UPI id "${vpa}"`);
    this.label = `UPI ${vpa}`;
  }
  charge(_amountPaise: number, orderId: string): ChargeResult {
    return { ok: true, reference: `UPI-${orderId}` };
  }
}

class WalletPayment implements PaymentMethod {
  readonly label = "Wallet";
  #balance: number;
  constructor(balancePaise: number) {
    this.#balance = balancePaise;
  }
  charge(amountPaise: number, orderId: string): ChargeResult {
    if (amountPaise > this.#balance) {
      return { ok: false, reason: `wallet has only ${rupees(this.#balance)}` };
    }
    this.#balance -= amountPaise;
    return { ok: true, reference: `WAL-${orderId}` };
  }
}

interface LineItem {
  readonly name: string;
  readonly unitPaise: number;
  readonly qty: number;
}

interface Receipt {
  readonly orderId: string;
  readonly totalPaise: number;
  readonly via: string;
  readonly result: ChargeResult;
}

/** Knows carts, totals and order ids. Knows nothing about how money moves. */
class Checkout {
  #nextOrder = 1;
  pay(items: readonly LineItem[], method: PaymentMethod): Receipt {
    if (items.length === 0) throw new Error("cart is empty");
    for (const i of items) {
      if (!Number.isSafeInteger(i.unitPaise) || i.unitPaise <= 0) throw new Error(`bad price for ${i.name}`);
      if (!Number.isSafeInteger(i.qty) || i.qty < 1) throw new Error(`bad quantity for ${i.name}`);
    }
    const totalPaise = items.reduce((sum, i) => sum + i.unitPaise * i.qty, 0);
    const orderId = `ORD-${String(this.#nextOrder++).padStart(3, "0")}`;
    return { orderId, totalPaise, via: method.label, result: method.charge(totalPaise, orderId) };
  }
}

function rupees(paise: number): string {
  return `₹${(paise / 100).toFixed(2)}`;
}

function show(r: Receipt): void {
  const status = r.result.ok ? `PAID     ref ${r.result.reference}` : `DECLINED ${r.result.reason}`;
  console.log(`${r.orderId}  ${rupees(r.totalPaise)}  ${r.via.padEnd(18)} ${status}`);
}

// ── Added later: a new payment method. Checkout is not edited. ──
class GiftCardPayment implements PaymentMethod {
  readonly label = "Gift card";
  #balance: number;
  constructor(balancePaise: number) {
    this.#balance = balancePaise;
  }
  charge(amountPaise: number, orderId: string): ChargeResult {
    if (amountPaise > this.#balance) return { ok: false, reason: "gift card balance too low" };
    this.#balance -= amountPaise;
    return { ok: true, reference: `GFT-${orderId}` };
  }
}

// ── Demo ──
const cart: LineItem[] = [
  { name: "Notebook", unitPaise: 12_000, qty: 2 },
  { name: "Pen", unitPaise: 2_500, qty: 3 },
];
const checkout = new Checkout();
const card = new CardPayment("4111111111111111", 50_000);
const methods: PaymentMethod[] = [card, new UpiPayment("asha@okbank"), new WalletPayment(20_000), new GiftCardPayment(100_000)];

console.log("Same cart, four payment methods:");
for (const m of methods) show(checkout.pay(cart, m));

console.log("\nSecond order on the same card (limit ₹500.00):");
show(checkout.pay(cart, card));

console.log("\nA test double is just an object with the right shape:");
const timeout: PaymentMethod = { label: "Test double", charge: () => ({ ok: false, reason: "gateway timeout" }) };
show(checkout.pay(cart, timeout));

console.log("\nRejected before any money moves:");
const bad: Array<[string, () => unknown]> = [
  ["empty cart", () => checkout.pay([], card)],
  ["zero quantity", () => checkout.pay([{ name: "Pen", unitPaise: 2_500, qty: 0 }], card)],
  ["bad UPI id", () => new UpiPayment("asha at okbank")],
];
for (const [name, attempt] of bad) {
  try {
    attempt();
    console.log(`  ${name}: accepted (unexpected)`);
  } catch (e) {
    console.log(`  ${name}: ${(e as Error).message}`);
  }
}
  • The interface is tiny on purpose. Anything you add, every payment method must implement.
  • implements PaymentMethod is a check, not a conversion. The handbook says an implements clause “is only a check that the class can be treated as the interface type” (TypeScript Handbook: implements clauses). It flags a misspelled charge at the class.
  • Declines are values, not exceptions. ok tags the union, so you must check it before reading reference.
  • Bad input throws before any charge. Prices and quantities must pass Number.isSafeInteger (MDN).
  • readonly label can be set only in the constructor (TypeScript Handbook: readonly).
  • The gift card is one new class. Checkout is not edited.

Step 5: Run it and check the output

Same cart, four payment methods:
ORD-001  ₹315.00  Card ••••1111      PAID     ref CRD-ORD-001
ORD-002  ₹315.00  UPI asha@okbank    PAID     ref UPI-ORD-002
ORD-003  ₹315.00  Wallet             DECLINED wallet has only ₹200.00
ORD-004  ₹315.00  Gift card          PAID     ref GFT-ORD-004

Second order on the same card (limit ₹500.00):
ORD-005  ₹315.00  Card ••••1111      DECLINED card limit exceeded

A test double is just an object with the right shape:
ORD-006  ₹315.00  Test double        DECLINED gateway timeout

Rejected before any money moves:
  empty cart: cart is empty
  zero quantity: bad quantity for Pen
  bad UPI id: invalid UPI id "asha at okbank"

Tested: TypeScript 7.0.2 on Node.js v22.23.4 — tsc --strict --target es2022 --module commonjs main.ts && node main.js, 2026-11-13. Output above is copied from that run.

Checkout treats all four methods the same way. The card’s second order is declined because the first used ₹315.00 of its ₹500.00 limit.

The test double is a plain object. TypeScript accepts it because type compatibility “is based on structural subtyping” (TypeScript Handbook: Type Compatibility).

Why this design works

  • Abstraction: checkout depends on what a payment method does, not how.
  • Encapsulation: each class keeps its limit or balance in a # field. Abstraction hides which class you have; encapsulation protects its state.
  • Open for extension: the gift card arrived as new code, not as edits to tested code. That is the open-closed principle in miniature.
  • Single responsibility: checkout owns totals and order ids; each payment class owns one way of moving money.
  • Testability: any object with the right shape works, so tests need no card data.

Trade-offs, edge cases and when not to use it

Ways to hide the payment details
Option Use when Cost
Interface + classes (this episode) Several implementations, each with its own state One more type; one more jump when reading
Abstract class Implementations share real code, such as retries Single inheritance; base changes reach every subclass
Union type + switch (Step 2) A small, closed set of data-only variants Every new variant edits every switch
Function type (amount, orderId) => ChargeResult One operation, no state No room for a second operation

Abstract classes cannot be created with new and force subclasses to implement abstract members (TypeScript Handbook: abstract classes). Choosing between them is its own episode: interface vs abstract class.

Interfaces are erased at compile time: “type annotations never change the runtime behavior” (TypeScript Handbook: Erased Types). So instanceof PaymentMethod is impossible, and JSON input is unchecked unless you validate it. Abstractions also leak; Joel Spolsky’s law says “all non-trivial abstractions, to some degree, are leaky” (Wikipedia: Leaky abstraction). Real gateways are asynchronous, which this contract cannot express yet.

My recommendation: with one implementation and no second in sight, skip the interface. Extract it when a second case or a test double appears.

Interview follow-up questions

A real gateway is asynchronous. How would you change the design?

Change the contract to charge(...): Promise<ChargeResult> and make Checkout.pay await it. Each implementation changes once; Checkout still names no concrete class. Send the order id to the gateway as an idempotency key so a retry cannot charge twice. Add a pending result for flows where the customer approves on another device. A small interface keeps this change cheap.

How would you unit test Checkout?

Use test doubles that satisfy PaymentMethod, like the object literal in the demo: one approves, one declines, one records the amount it received. Assert the total, the order id sequence and both receipt paths. The tests contain no card numbers, because Checkout never sees them. Test each payment class separately against its own rules, such as the card limit.

Only some methods support refunds. Where does refund() go?

Not on PaymentMethod, where the gift card would have to throw “not supported”. Add a second small interface, Refundable, and implement it only where refunds are real. The refund flow asks for a Refundable, so the compiler blocks refunds to methods that cannot do them. This is interface segregation at work.

The same solution in Python

Show the Python version

Python uses an abstract base class. A class derived from ABC “cannot be instantiated unless all of its abstract methods and properties are overridden” (Python docs: abc), as the last output line shows. For structural typing, use typing.Protocol (Python docs: typing.Protocol).

from __future__ import annotations
import re
from abc import ABC, abstractmethod
from dataclasses import dataclass


@dataclass(frozen=True)
class ChargeResult:
    ok: bool
    detail: str  # reference when ok, reason when declined


class PaymentMethod(ABC):
    """The abstraction: everything checkout needs from a payment method."""
    label: str

    @abstractmethod
    def charge(self, amount_paise: int, order_id: str) -> ChargeResult: ...


class CardPayment(PaymentMethod):
    def __init__(self, number: str, limit_paise: int) -> None:
        if not re.fullmatch(r"\d{16}", number):
            raise ValueError("card number must be 16 digits")
        self._number, self._limit_left = number, limit_paise
        self.label = f"Card ••••{number[-4:]}"

    def charge(self, amount_paise: int, order_id: str) -> ChargeResult:
        if amount_paise > self._limit_left:
            return ChargeResult(False, "card limit exceeded")
        self._limit_left -= amount_paise
        return ChargeResult(True, f"CRD-{order_id}")


class UpiPayment(PaymentMethod):
    def __init__(self, vpa: str) -> None:
        if not re.fullmatch(r"[\w.-]+@[a-z]+", vpa):
            raise ValueError(f'invalid UPI id "{vpa}"')
        self.label = f"UPI {vpa}"

    def charge(self, amount_paise: int, order_id: str) -> ChargeResult:
        return ChargeResult(True, f"UPI-{order_id}")


class WalletPayment(PaymentMethod):
    label = "Wallet"

    def __init__(self, balance_paise: int) -> None:
        self._balance = balance_paise

    def charge(self, amount_paise: int, order_id: str) -> ChargeResult:
        if amount_paise > self._balance:
            return ChargeResult(False, f"wallet has only {rupees(self._balance)}")
        self._balance -= amount_paise
        return ChargeResult(True, f"WAL-{order_id}")


class Checkout:
    """Knows carts, totals and order ids. Knows nothing about how money moves."""

    def __init__(self) -> None:
        self._next = 1

    def pay(self, items: list[tuple[str, int, int]], method: PaymentMethod) -> str:
        if not items:
            raise ValueError("cart is empty")
        total = sum(price * qty for _, price, qty in items)
        order_id, self._next = f"ORD-{self._next:03d}", self._next + 1
        r = method.charge(total, order_id)
        status = f"PAID     ref {r.detail}" if r.ok else f"DECLINED {r.detail}"
        return f"{order_id}  {rupees(total)}  {method.label:<18} {status}"


def rupees(paise: int) -> str:
    return f"₹{paise // 100}.{paise % 100:02d}"


cart = [("Notebook", 12_000, 2), ("Pen", 2_500, 3)]
checkout = Checkout()
for m in [CardPayment("4111111111111111", 50_000), UpiPayment("asha@okbank"), WalletPayment(20_000)]:
    print(checkout.pay(cart, m))
try:
    PaymentMethod()  # the abstraction itself cannot be instantiated
except TypeError as e:
    print(f"PaymentMethod() -> {type(e).__name__}")
ORD-001  ₹315.00  Card ••••1111      PAID     ref CRD-ORD-001
ORD-002  ₹315.00  UPI asha@okbank    PAID     ref UPI-ORD-002
ORD-003  ₹315.00  Wallet             DECLINED wallet has only ₹200.00
PaymentMethod() -> TypeError

Tested: Python 3.13.18 — python3 main.py, 2026-11-13.

Practice: extend it yourself

  1. Add a NetBankingPayment that declines with “bank under maintenance” when a constructor flag is set. Expected behaviour: no edits to Checkout; the receipt shows DECLINED bank under maintenance.
  2. Card payments now carry a 2% surcharge. Decide whether the fee belongs on the interface or in a wrapper class. Expected behaviour: a ₹315.00 card order charges ₹321.30; other methods are unchanged.
  3. Make charge() return a Promise and add a pending result. Expected behaviour: the same lines print in the same order, plus one PENDING receipt from a test double.
  • Abstraction in OOP means a caller depends on what an object does, never on how a specific class does it.
  • TypeScript checks interfaces structurally: any object with the right members fits, with or without implements.
  • TypeScript interfaces are erased at compile time, so instanceof cannot check them.
  • Keep the abstraction small: checkout needs only a label and charge().
  • With the abstraction in place, a new payment method is one new class and zero checkout edits.

Frequently Asked Questions

What is abstraction in OOP in simple words?

Abstraction in OOP means a caller works with what an object does, not how it does it. You define a small contract, such as an interface with a charge() method, and the caller depends only on that. A new implementation does not change the caller.

What is the difference between abstraction and encapsulation?

Encapsulation protects an object's state behind methods that keep its rules true. Abstraction decides what a caller needs to know at all: a contract, not a concrete class. Here the interface is the abstraction, and the private # fields in each payment class are the encapsulation.

Is an interface the only way to achieve abstraction?

No. An abstract class, a function type, or a module that exports a few functions are abstractions too. In TypeScript an interface is the lightest choice when implementations share no code; an abstract class fits when they share real logic.

Do TypeScript interfaces exist at runtime?

No. TypeScript erases interfaces when it compiles to JavaScript, so instanceof cannot check them. Type checking is structural: any object with the right members fits. For a runtime check, use a class or a discriminating property.

When is abstraction overkill?

When there is one implementation and no realistic second one. The interface then adds a name and an extra jump without buying anything. Extract it when a second case or a needed test double appears.

References