Coding questions · HubSpot
Design and implement a bank account system
This question evaluates a candidate's skills in system design, data modeling, transactional integrity, concurrency control, idempotency, API design, and algorithmic complexity for implementing a minimal banking service.
Design and implement a minimal banking service with the following capabilities:
- Create bank accounts with unique IDs and an initial balance; support deposit and withdrawal operations while preventing overdrafts.
- Maintain per-account transaction history. Each record should include a timestamp, operation type (deposit, withdrawal, transfer_pending, transfer_in, transfer_out), amount, and resulting balance. Provide APIs to fetch the full history and the most recent N entries.
- Implement inter-account transfers that execute only if the recipient explicitly accepts them. A transfer starts in PENDING and does not change balances until accepted; if rejected or expired, it is canceled. Ensure idempotent accept/reject handling, validate sufficient funds at acceptance time, and make operations safe under concurrent requests. Expose clear method signatures (e.g., createAccount, deposit, withdraw, createTransfer, acceptTransfer, rejectTransfer, getHistory). Describe chosen data structures, outline time/space complexity for core operations, and discuss edge cases (duplicate requests, invalid accounts, negative amounts, and clock/timestamp considerations).
Bank Service — Level 1: Accounts, Deposit, Withdraw (Overdraft-Safe)
Implement a minimal in-memory banking service. You are given a list of operations; replay them in order and return a list with one result per operation.
Each operation is a list whose first element is the op name:
['create', acct_id, initial_balance]— create an account with a unique id and a non-negative initial balance. ReturnTrueon success; returnFalseif the id already exists orinitial_balanceis negative.['deposit', acct_id, amount]— addamount(positive integer cents) to the account. Return the new balance, orNoneif the account does not exist oramount <= 0.['withdraw', acct_id, amount]— subtractamountfrom the account, preventing overdrafts. Return the new balance, orNoneif the account does not exist,amount <= 0, or the balance is insufficient.['balance', acct_id]— return the current balance, orNoneif the account does not exist.
Money is represented as integer cents (never floats).
Constraints
- Money is an integer number of cents; amounts for deposit/withdraw must be positive.
- Account ids are unique strings; creating a duplicate id fails (returns False).
- Withdrawals never overdraw the balance.
- initial_balance must be >= 0.
Examples
Example 1
Input: ([['create', 'A', 100], ['deposit', 'A', 50], ['withdraw', 'A', 30], ['balance', 'A']],)
Output: [True, 150, 120, 120]
Explanation: Create with 100, +50 -> 150, -30 -> 120, balance reads 120.
Example 2
Input: ([['create', 'A', 100], ['withdraw', 'A', 500]],)
Output: [True, None]
Explanation: Overdraft prevented: withdrawing 500 from 100 fails and returns None.
Hints
- Use a dict {acct_id -> balance} for O(1) access.
- Validate amount > 0 and (for withdraw) balance >= amount before mutating.
- Return None for any operation on a non-existent account.
Bank Service — Level 2: Transaction History
Extend Level 1. Every successful deposit/withdraw now appends a record to a per-account, append-only history. Each record is [op_type, amount, resulting_balance] where op_type is 'deposit' or 'withdrawal' and resulting_balance is the balance immediately AFTER the operation.
New operations (Level 1 ops still apply):
['history', acct_id]— return the full chronological history (a list of records), orNoneif the account does not exist.['recent', acct_id, n]— return the most recentnrecords (chronological order). Return[]whenn == 0,Noneif the account does not exist orn < 0. Ifnexceeds the history length, return the whole history.
History is naturally chronological (append order), so 'recent' is a slice.
Constraints
- History is append-only and chronological (no sorting needed).
- A record is [op_type, amount, resulting_balance]; op_type is 'deposit' or 'withdrawal'.
- recent(acct, 0) returns [] (do NOT return the whole list — list[-0:] is a footgun).
- history/recent on a missing account returns None; recent with n < 0 returns None.
Examples
Example 1
Input: ([['create', 'A', 100], ['deposit', 'A', 50], ['withdraw', 'A', 30], ['history', 'A']],)
Output: [True, 150, 120, [['deposit', 50, 150], ['withdrawal', 30, 120]]]
Explanation: Two mutations produce two chronological records with their resulting balances.
Example 2
Input: ([['create', 'A', 0], ['deposit', 'A', 10], ['deposit', 'A', 20], ['deposit', 'A', 30], ['recent', 'A', 2]],)
Output: [True, 10, 30, 60, [['deposit', 20, 30], ['deposit', 30, 60]]]
Explanation: recent(2) returns the last two records in chronological order.
Hints
- Record AFTER updating the balance so resulting_balance reflects the new state.
- The n==0 case must be special-cased because list[-0:] equals list[0:] (the whole list).
- Return copies of the records so callers cannot mutate internal state.
Bank Service — Level 3: Two-Phase Transfers (Accept / Reject, Idempotent)
Extend Level 2 with inter-account transfers that move money only when the recipient explicitly accepts.
Transfer ids are assigned deterministically in creation order: the first successful transfer is 't1', the second 't2', and so on. A transfer is a state machine: pending -> accepted | rejected.
New operations (Level 1 & 2 ops still apply):
['transfer', from_id, to_id, amount]— create a PENDING transfer. Validate both accounts exist,from_id != to_id, andamount > 0; otherwise returnNone. On success, append a['transfer_pending', amount, balance]record to the sender (balances do NOT change yet) and return the new transfer id (e.g.'t1').['accept', tid]— if PENDING and the sender has sufficient funds NOW, debit the sender ('transfer_out'record) and credit the recipient ('transfer_in'record), set status ACCEPTED, return'accepted'. If already ACCEPTED, return'accepted'(idempotent, no double-credit). If REJECTED, return'rejected'. If sender now lacks funds, return'insufficient_funds'and leave it PENDING. Unknown id returnsNone.['reject', tid]— if PENDING, mark REJECTED. Return the current status. Unknown id returnsNone. Re-rejecting is idempotent.['status', tid]— return the transfer's status, orNoneif unknown.
Funds are validated at ACCEPTANCE time, not at creation.
Constraints
- Transfer ids are 't1','t2',... assigned in successful-creation order.
- Creation records 'transfer_pending' but does NOT move money.
- Funds are validated at ACCEPT time; accept on insufficient funds leaves the transfer PENDING.
- accept/reject are idempotent: a terminal status (accepted/rejected) is returned unchanged with no double-apply.
- Self-transfer (from == to), non-existent accounts, and amount <= 0 all return None at creation.
Examples
Example 1
Input: ([['create', 'A', 100], ['create', 'B', 0], ['transfer', 'A', 'B', 40], ['accept', 't1'], ['balance', 'A'], ['balance', 'B']],)
Output: [True, True, 't1', 'accepted', 60, 40]
Explanation: Happy path: pending then accepted debits A (100->60) and credits B (0->40).
Example 2
Input: ([['create', 'A', 100], ['create', 'B', 0], ['transfer', 'A', 'B', 40], ['accept', 't1'], ['accept', 't1'], ['balance', 'A'], ['balance', 'B']],)
Output: [True, True, 't1', 'accepted', 'accepted', 60, 40]
Explanation: Idempotent accept: the second accept returns 'accepted' with no double-credit.
Hints
- Model the transfer as a tiny state machine: pending -> accepted | rejected; terminal states are returned unchanged.
- Check status BEFORE mutating balances so a repeated accept can't double-credit.
- Re-check the sender's balance at accept time — it may have dropped since creation.
Solution
Reading the problem
This is a classic "leveled" CodeSignal-style task (the kind HubSpot uses): you implement one class that grows across 3-4 levels, and each level adds a capability on top of the previous one. The grader runs unit tests, so correctness and exact method contracts matter more than cleverness. The winning strategy is:
- Keep state in plain in-memory data structures (no DB — it's a single process).
- Make every operation small, total (always returns or raises predictably), and easy to test.
- Treat money as integer minor units (cents), never floats.
- Add a single lock so the "safe under concurrent requests" requirement is satisfied without you having to reason about per-account locking.
I'll build it in the same order the levels appear, then give complexity and edge cases.
Core design decisions
| Decision | Choice | Why |
|---|---|---|
| Money representation | int cents | Floats lose precision (0.1 + 0.2 != 0.3); banking cannot tolerate that. The validator below enforces int strictly. |
| Account storage | dict[account_id -> Account] | O(1) lookup/insert; account IDs are unique keys. |
| Per-account history | list[Transaction] appended in order | History is append-only and naturally time-ordered; "last N" is a slice. |
| Transfers | dict[transfer_id -> Transfer] with a status enum | Transfers are first-class entities with their own lifecycle, independent of accounts. |
| Concurrency | One coarse-grained threading.RLock (the "bank lock") | Simplest correct answer; eliminates lost-update/double-spend races. Re-entrant so methods can call each other. |
| Time | Inject a clock callable (defaults to time.time) | Makes expiry testable and avoids depending on wall-clock in tests. |
A coarse lock is the right call in an interview: per-account locks introduce deadlock ordering problems (account A locks B while B locks A). Mention the trade-off; don't implement it unless asked.
Level 1 — Accounts, deposit, withdraw, overdraft protection
import threading
import itertools
import time
from enum import Enum
from dataclasses import dataclass, field
class OpType(str, Enum):
DEPOSIT = "deposit"
WITHDRAWAL = "withdrawal"
TRANSFER_PENDING = "transfer_pending" # debit-intent recorded when transfer is created
TRANSFER_OUT = "transfer_out" # sender side, on accept
TRANSFER_IN = "transfer_in" # recipient side, on accept
@dataclass
class Transaction:
timestamp: float
op_type: OpType
amount: int # cents, always positive
resulting_balance: int # account balance AFTER this op
@dataclass
class Account:
account_id: str
balance: int # cents
history: list = field(default_factory=list)
class Bank:
def __init__(self, clock=time.time):
self._accounts: dict[str, Account] = {}
self._lock = threading.RLock()
self._clock = clock
# ---- helpers -------------------------------------------------
def _require_account(self, account_id: str) -> Account:
acct = self._accounts.get(account_id)
if acct is None:
raise KeyError(f"account {account_id!r} does not exist")
return acct
@staticmethod
def _require_positive(amount: int) -> None:
# bool is a subclass of int, so reject it explicitly: deposit(acct, True)
# is a programming error, not a 1-cent deposit.
if not isinstance(amount, int) or isinstance(amount, bool):
raise TypeError("amount must be an integer number of cents")
if amount <= 0:
raise ValueError("amount must be positive")
def _record(self, acct: Account, op: OpType, amount: int) -> None:
acct.history.append(Transaction(self._clock(), op, amount, acct.balance))
# ---- Level 1 API ---------------------------------------------
def create_account(self, account_id: str, initial_balance: int = 0) -> str:
with self._lock:
if account_id in self._accounts:
raise ValueError(f"account {account_id!r} already exists")
if initial_balance < 0:
raise ValueError("initial balance cannot be negative")
self._accounts[account_id] = Account(account_id, initial_balance)
return account_id
def deposit(self, account_id: str, amount: int) -> int:
self._require_positive(amount)
with self._lock:
acct = self._require_account(account_id)
acct.balance += amount
self._record(acct, OpType.DEPOSIT, amount)
return acct.balance
def withdraw(self, account_id: str, amount: int) -> int:
self._require_positive(amount)
with self._lock:
acct = self._require_account(account_id)
if acct.balance < amount:
raise ValueError("insufficient funds") # overdraft prevented
acct.balance -= amount
self._record(acct, OpType.WITHDRAWAL, amount)
return acct.balance
def get_balance(self, account_id: str) -> int:
with self._lock:
return self._require_account(account_id).balance
Key points the graders check at this level: deposit/withdraw return the new balance, withdrawing more than the balance is rejected (no overdraft), and negative/zero amounts raise. Doing validation before taking the lock is fine for stateless checks; the balance check must be inside the lock so it reflects the committed state.
Level 2 — Transaction history
History is already being appended on every mutation via _record. We just expose two read APIs:
def get_history(self, account_id: str) -> list:
with self._lock:
# return a shallow copy so callers can't mutate internal state
return list(self._require_account(account_id).history)
def get_recent_history(self, account_id: str, n: int) -> list:
if n < 0:
raise ValueError("n must be non-negative")
with self._lock:
return list(self._require_account(account_id).history[-n:]) if n else []
Each record carries timestamp, op_type, amount, and resulting_balance, matching the spec exactly. Because we append in operation order, the list is already chronological — [-n:] gives the most recent n without sorting. The if n else [] guard is load-bearing: list[-0:] is list[0:], which returns the whole list, so n == 0 must be special-cased to return an empty slice. Returning a copy prevents callers from corrupting history.
Level 3 — Two-phase transfers with explicit accept
This is the level that fails most candidates because of the lifecycle and idempotency. Model a transfer as a small state machine:
PENDING ──accept──► ACCEPTED
│
├──reject──► REJECTED
└──expire──► EXPIRED
Once a transfer leaves PENDING it is terminal — further accept/reject calls must be idempotent (return the same outcome, never double-apply).
Critical design question: do we hold funds at creation or only check at acceptance? The spec says "validate sufficient funds at acceptance time," so I do not debit the sender on create_transfer — I only validate and move money on accept. The transfer_pending history entry records intent without changing balances. (If the prompt instead wanted an escrow hold, you'd debit on create and refund on reject/expire; call this out to the interviewer.)
class TransferStatus(str, Enum):
PENDING = "pending"
ACCEPTED = "accepted"
REJECTED = "rejected"
EXPIRED = "expired"
@dataclass
class Transfer:
transfer_id: str
from_id: str
to_id: str
amount: int
created_at: float
expires_at: float | None
status: TransferStatus = TransferStatus.PENDING
Add to Bank.__init__:
self._transfers: dict[str, Transfer] = {}
self._transfer_seq = itertools.count(1) # monotonic id generator
Methods:
def create_transfer(self, from_id, to_id, amount, ttl_seconds=None) -> str:
self._require_positive(amount)
with self._lock:
sender = self._require_account(from_id)
recipient = self._require_account(to_id) # validate recipient now
if from_id == to_id:
raise ValueError("cannot transfer to the same account")
now = self._clock()
tid = f"t{next(self._transfer_seq)}"
self._transfers[tid] = Transfer(
transfer_id=tid, from_id=from_id, to_id=to_id, amount=amount,
created_at=now,
expires_at=(now + ttl_seconds) if ttl_seconds is not None else None,
)
# record intent on the sender, without moving money
self._record(sender, OpType.TRANSFER_PENDING, amount)
return tid
def _resolve_if_expired(self, t: Transfer) -> None:
"""Lazily expire on read/write — no background timer needed."""
if (t.status is TransferStatus.PENDING and t.expires_at is not None
and self._clock() >= t.expires_at):
t.status = TransferStatus.EXPIRED
def accept_transfer(self, transfer_id: str) -> TransferStatus:
with self._lock:
t = self._transfers.get(transfer_id)
if t is None:
raise KeyError("unknown transfer")
self._resolve_if_expired(t)
# ---- idempotency: terminal states are returned unchanged ----
if t.status is TransferStatus.ACCEPTED:
return t.status # duplicate accept: no double-credit
if t.status in (TransferStatus.REJECTED, TransferStatus.EXPIRED):
return t.status # cannot accept a dead transfer
sender = self._accounts[t.from_id]
recipient = self._accounts[t.to_id]
if sender.balance < t.amount: # funds checked AT ACCEPT TIME
raise ValueError("insufficient funds at acceptance")
sender.balance -= t.amount
recipient.balance += t.amount
self._record(sender, OpType.TRANSFER_OUT, t.amount)
self._record(recipient, OpType.TRANSFER_IN, t.amount)
t.status = TransferStatus.ACCEPTED
return t.status
def reject_transfer(self, transfer_id: str) -> TransferStatus:
with self._lock:
t = self._transfers.get(transfer_id)
if t is None:
raise KeyError("unknown transfer")
self._resolve_if_expired(t)
if t.status is TransferStatus.PENDING:
t.status = TransferStatus.REJECTED
# ACCEPTED stays ACCEPTED; REJECTED/EXPIRED are already terminal
return t.status
def get_transfer_status(self, transfer_id: str) -> TransferStatus:
with self._lock:
t = self._transfers.get(transfer_id)
if t is None:
raise KeyError("unknown transfer")
self._resolve_if_expired(t)
return t.status
Why this is idempotent and concurrency-safe:
- The status check and the balance mutation happen atomically inside one lock acquisition. Two threads calling
accept_transferon the same id can't both pass thestatus is PENDINGgate — the first flips it toACCEPTED, the second seesACCEPTEDand returns without re-crediting. - The same holds for accept-vs-reject races: whichever grabs the lock first sets the terminal state; the loser observes it and no-ops.
- Expiry is lazy (checked on every read/write) rather than via a background thread, which keeps the system deterministic and avoids timer/race complexity. It's also exactly what tests want: advance the injected clock, then assert the transfer is
EXPIRED.
A small, deliberate design choice worth stating: on reject/expire I do not append a compensating "cancel" record to the sender's history, so a reader of get_history still sees the original transfer_pending intent for a transfer that never moved money. Balances and every resulting_balance stay correct (nothing was debited), and the spec only mandates the five listed op-types — but if the grader expects pending intents to be visibly retracted, I'd add a transfer_cancelled op-type and write it in reject_transfer and _resolve_if_expired.
Complexity
Let $h$ = history length of the relevant account.
| Operation | Time | Space |
|---|---|---|
create_account | $O(1)$ | $O(1)$ |
deposit / withdraw | $O(1)$ | $O(1)$ per appended record |
get_history | $O(h)$ (copy) | $O(h)$ |
get_recent_history(n) | $O(n)$ | $O(n)$ |
create_transfer | $O(1)$ | $O(1)$ |
accept / reject / status | $O(1)$ | $O(1)$ |
Total space is $O(A + T + H)$ for $A$ accounts, $T$ transfers, and $H$ total history records. get_history returns a copy for safety; if that copy cost matters, return a read-only view or a paginated slice instead.
Edge cases (call these out explicitly — graders test them)
- Duplicate requests / idempotency. Re-accepting an accepted transfer returns
ACCEPTEDwithout double-crediting; re-rejecting returns the existing terminal status. Account creation with an existing id raises rather than silently overwriting. - Invalid accounts. Every account-touching call routes through
_require_account, raisingKeyErrorfor missing accounts — including the recipient at transfer-creation time. - Negative / zero / non-integer amounts.
_require_positiverejects all of these uniformly, and rejectsboolexplicitly (sinceTrue/Falseareintinstances) sodeposit(acct, True)is a type error rather than a 1-cent deposit.create_accountseparately rejects a negative initial balance. - Overdraft. Withdraw and accept both verify
balance >= amountinside the lock against committed state; a transfer that was fundable at creation can still be rejected at accept time if the sender drained the account in between — which is exactly why funds are validated at acceptance, not creation. - Self-transfer. Rejected explicitly at
create_transfer: it's a no-op move that produces confusing duplicate history (atransfer_pending, then on accept atransfer_outandtransfer_inon the same account that net to zero), so it's cleaner to forbid it outright. (Under the coarse global lock shown here it can't deadlock; the deadlock concern only arises in the per-account-locking design discussed above, which is one more reason that design is harder to get right.) - Clock / timestamp considerations. Time is injected, so tests are deterministic and don't flake on wall-clock. Expiry uses
>=so a transfer is dead exactly atexpires_at. Don't assume monotonic timestamps fromtime.time(NTP can step it backwards) — if strict ordering matters, pair each record with a monotonic sequence counter, since history order here comes from append order, not from comparing timestamps. - Concurrency. The single re-entrant lock makes every mutation a critical section, so no lost updates, no double-spend, no partial transfer where one side is credited but the other isn't.
What I'd say out loud in the interview
"I'm storing money as integer cents to avoid float errors, keeping accounts and transfers in dicts for O(1) access, and appending to a per-account history list so 'last N' is just a slice. Transfers are a small state machine — PENDING to one of three terminal states — and I get idempotency and concurrency safety from a single coarse lock plus the rule that terminal states are never re-applied. I validate funds at acceptance time per the spec, and I expire lazily off an injected clock so the behavior is testable. If you want higher concurrency I'd move to per-account locks with a global ordering to avoid deadlocks, or push this into a transactional store — but for a single-process service the coarse lock is correct and simple."
That sequencing — minimal correct core, then explicit trade-offs — is what separates a pass from a fail on this problem.