Definition
- Step 1: Clarify requirements – ask scoping questions about functional needs (transfer, balance) and non-functional needs (thread safety, latency).
- Step 2: Identify entities – scan requirements for nouns that become classes:
Account,Transaction,Customer,TransferService. - Step 3: Define relationships –
has-ais composition or aggregation;is-ais inheritance for genuine subtypes. - Step 4: Define methods – verbs in the requirement become methods:
debit,credit,transfer, each with a clear contract. - Step 5: Apply patterns – Strategy for pricing, Factory for creation, only where they genuinely reduce complexity.
- Step 6: Handle edge cases – insufficient funds, concurrent access, extensibility through abstractions.
Imagine Rajesh asks the bank to build a new money-transfer counter at National Bank of India. Before anyone lifts a hammer, Pankaj (the architect) asks what the counter must do, lists the people and forms involved (customer, teller, ledger), decides who talks to whom, writes down each step the teller performs, reuses standard blueprints the bank already trusts, and finally plans for odd cases like a frozen account. LLD is exactly that disciplined planning for code.
Understanding the Concepts
What is Low-Level Design?
Low-Level Design is the disciplined process of translating a feature requirement into concrete classes, interfaces, fields, methods, and their relationships. It follows a repeatable sequence that works for any bounded system, from a banking transfer module to a parking lot.
The goal is a cohesive, testable, extensible object model. Not a premature framework, not a God class, and not a pattern-heavy design that adds indirection without benefit.
Step 1: How do you clarify requirements?
You never start coding until you know the functional needs and the non-functional needs. Ask the interviewer scoping questions.
Functional:
- Transfer money between accounts.
- Check balance.
- View transaction history.
Non-functional:
- Thread safety for concurrent transfers.
- Latency under 100ms per transfer.
Scoping questions:
- Should transfers be synchronous or async?
- Do we support multiple currencies?
- Is there a daily transfer limit?
Writing down scope prevents you from designing the wrong thing.
Step 2: How do you identify entities?
Scan the requirement for nouns. Nouns usually become classes. In a transfer system, the nouns are Account, Transaction, Customer, and TransferService.
package com.bank.model;
import java.math.BigDecimal;
public class Account {
private final String accountId;
private final String customerName;
private BigDecimal balance;
public Account(String accountId, String customerName,
BigDecimal balance) {
this.accountId = accountId;
this.customerName = customerName;
this.balance = balance;
}
public String getAccountId() { return accountId; }
public String getCustomerName() { return customerName; }
public BigDecimal getBalance() { return balance; }
public void debit(BigDecimal amount) {
if (balance.compareTo(amount) < 0) {
throw new IllegalStateException(
"Insufficient balance in " + accountId);
}
balance = balance.subtract(amount);
}
public void credit(BigDecimal amount) {
balance = balance.add(amount);
}
}
Each entity gets its key attributes and its own state transitions. The account decides how it handles a debit; the service does not reach in and subtract directly.
Step 3: How do you define relationships?
A has-a relationship means composition or aggregation. An is-a relationship means inheritance.
// has-a: Customer owns a list of accounts (composition)
package com.bank.model;
import java.util.List;
public class Customer {
private final String customerId;
private final String name;
private final List<Account> accounts;
public Customer(String customerId, String name,
List<Account> accounts) {
this.customerId = customerId;
this.name = name;
this.accounts = accounts;
}
public List<Account> getAccounts() { return accounts; }
}
// is-a: SavingsAccount is-a Account
package com.bank.model;
import java.math.BigDecimal;
public class SavingsAccount extends Account {
private final BigDecimal interestRate;
public SavingsAccount(String accountId, String customerName,
BigDecimal balance, BigDecimal interestRate) {
super(accountId, customerName, balance);
this.interestRate = interestRate;
}
public BigDecimal getInterestRate() { return interestRate; }
}
Favour composition over inheritance. Use inheritance only for genuine is-a relationships.
Step 4: How do you define methods and behaviours?
Methods come from the verbs in the requirement: debit, credit, transfer. Each method gets a clear contract of preconditions and postconditions.
package com.bank.service;
import com.bank.model.Account;
import java.math.BigDecimal;
public class TransferService {
/**
* Transfer amount from one account to another.
* Precondition: fromAccount has sufficient balance.
* Postcondition: fromAccount debited, toAccount credited.
*/
public void transfer(Account from, Account to, BigDecimal amount) {
from.debit(amount);
to.credit(amount);
System.out.println("Transferred " + amount
+ " from " + from.getAccountId()
+ " to " + to.getAccountId());
}
}
Step 5: How do you apply design patterns?
Apply patterns only where they genuinely reduce complexity. A pattern that adds indirection without benefit is a liability.
- Strategy for multiple pricing rules.
- Factory for centralised account creation.
- Observer for event-driven notifications.
- State for entities with a lifecycle.
package com.bank.service;
import java.math.BigDecimal;
public interface FeeStrategy {
BigDecimal computeFee(BigDecimal amount);
}
public class FlatFeeStrategy implements FeeStrategy {
@Override
public BigDecimal computeFee(BigDecimal amount) {
return new BigDecimal("50");
}
}
public class PercentageFeeStrategy implements FeeStrategy {
private final BigDecimal rate;
public PercentageFeeStrategy(BigDecimal rate) { this.rate = rate; }
@Override
public BigDecimal computeFee(BigDecimal amount) {
return amount.multiply(rate);
}
}
Rajesh uses the Strategy pattern here because the fee rule genuinely varies. If there were only one rule, a plain method would suffice.
Step 6: How do you handle edge cases and extensibility?
Guard against insufficient funds, concurrent access, and invalid inputs. Design for extensibility by depending on abstractions.
package com.bank.service;
import com.bank.model.Account;
import java.math.BigDecimal;
public class SafeTransferService {
private final FeeStrategy feeStrategy;
public SafeTransferService(FeeStrategy feeStrategy) {
this.feeStrategy = feeStrategy;
}
public synchronized void transfer(Account from, Account to,
BigDecimal amount) {
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException(
"Amount must be positive");
}
BigDecimal fee = feeStrategy.computeFee(amount);
BigDecimal totalDebit = amount.add(fee);
from.debit(totalDebit); // throws if insufficient
to.credit(amount);
System.out.println("Transfer: " + amount + " + fee " + fee
+ " from " + from.getAccountId()
+ " to " + to.getAccountId());
}
}
Adding a new fee rule is a new FeeStrategy class. Adding a new account type is a new Account subclass. The existing, tested code is never changed (Open/Closed Principle).
How do you verify the design?
Trace a concrete scenario end to end to expose missing methods.
Scenario: Rajesh transfers 10,000 to Priya with a flat 50 fee 1. SafeTransferService.transfer(rajeshAccount, priyaAccount, 10000) 2. Validate amount > 0 → OK 3. FlatFeeStrategy.computeFee(10000) → 50 4. Total debit = 10050 5. rajeshAccount.debit(10050) → balance drops from 50000 to 39950 6. priyaAccount.credit(10000) → balance rises from 20000 to 30000 7. Print confirmation
What about concurrency?
Concurrent transfers touching the same account must be serialised. In the example above, the synchronized keyword on the transfer method is a simple approach. In production, Pankaj would use database-level locking or optimistic concurrency with version numbers.
What should a senior engineer know?
- Noun-verb extraction is a reliable, repeatable technique for discovering entities and methods from any requirement.
- Composition over inheritance should be the default. Only use inheritance when the relationship is genuinely is-a.
- Pattern discipline: the right answer is often the simplest cohesive model. A pattern introduced without a real need is over-engineering.
- Extensibility test: after designing, ask yourself "how would I add a new feature (new account type, new fee rule)?" If the answer requires rewriting existing classes, the design needs refactoring.
What are common interview follow-up questions?
- Draw the class diagram with relationships for this system.
- How would you add a new transfer fee rule without changing existing code?
- How would you handle concurrent transfers to the same account?
- Where would you apply the Observer pattern in a banking transfer system?
- How do you decide between composition and inheritance?
Visual Overview
Six Steps of LLD:
─────────────────
Step 1: Clarify "Sync or async? Multi-currency?"
│
▼
Step 2: Entities Account, Transaction, Customer
│
▼
Step 3: Relationships
│ Customer ──has-a──→ List<Account>
│ SavingsAccount ──is-a──→ Account
▼
Step 4: Methods debit(), credit(), transfer()
│
▼
Step 5: Patterns Strategy (fee), Factory (account)
│
▼
Step 6: Edge cases Insufficient funds, concurrency
┌───────────────────────────────────────┐
│ SafeTransferService │
│ transfer(from, to, amount) │
└────────┬──────────────┬───────────────┘
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Account │ │ FeeStrategy │
│ debit() │ │ computeFee() │
│ credit() │ └──────────────┘
└──────────────┘
Key Takeaway
When you approach a low-level design problem, follow six disciplined steps so your design is clean, testable, and extensible rather than a tangle of logic. First clarify the requirements and use cases by asking scoping questions, because you never want to design the wrong thing. Confirm the functional needs like transfer and balance, and the non-functional needs like thread safety and latency.
Second, identify the main entities by looking at the nouns in the requirement, which become your classes: Account, Transaction, Customer, and TransferService. Third, define the relationships, distinguishing has-a relationships that become composition, like a Customer having Accounts, from is-a relationships that become inheritance, like a SavingsAccount being an Account.
Fourth, define the methods and behaviours from the verbs: debit, credit, transfer. Give each method a clear contract of preconditions and postconditions. Fifth, apply design patterns only where they genuinely reduce complexity: a Factory to centralise account creation, a Strategy for fee rules, and consciously avoid over-engineering.
Finally, handle edge cases and extensibility: insufficient funds, concurrent access, invalid inputs, and designing against abstractions so a new account type or fee rule is a new class rather than a rewrite. In the banking work at National Bank of India, this exact process turned a bloated single-class transfer module into focused, independently testable components. That is what separates a maintainable system from technical debt.
Remember one sentence: nouns become classes, verbs become methods, relationships become composition or inheritance, and patterns earn their place by solving a real problem.
---