7 Java Features That Make You Better at Low-Level Design Interviews
Low-Level Design (LLD) interviews aren’t about writing code fast. They’re about demonstrating design thinking — how you structure systems…
7 Java Features That Make You Better at Low-Level Design Interviews
Low-Level Design (LLD) interviews aren’t about writing code fast. They’re about demonstrating design thinking — how you structure systems, manage dependencies, ensure maintainability, and handle edge cases.

Low Level Design (LLD) in Java
Here’s the challenge: Most students approach LLD interviews with DSA mindset. They focus on algorithms and optimization. But LLD interviews test something different: Can you architect clean, maintainable, extensible systems?
And here’s what most guides miss: Language choice matters. Not because one language is “better” than another, but because some languages make design decisions explicit while others hide them.
Java, often criticized for verbosity, has a unique advantage in LLD interviews. Its “verbose” features — interfaces, access modifiers, explicit typing — force you to make design decisions visible. When an interviewer reads your code, they can immediately see your abstraction choices, encapsulation strategy, and type safety approach.
This isn’t about memorizing syntax. It’s about understanding how Java’s features align with fundamental design principles. When you use interfaces, you’re applying dependency inversion. When you choose access modifiers, you’re demonstrating encapsulation mastery. When you use enums instead of strings, you’re showing type safety awareness.
This guide breaks down 7 Java features that directly improve your LLD interview performance. For each feature, you’ll learn:
- What the feature is (technical foundation)
- Why it matters for design (the principle it enforces)
- How to use it (complete code examples)
- When to apply it (which LLD problems test this)
- What mistakes to avoid (common pitfalls)
By the end, you’ll have a systematic framework for leveraging Java’s design features in any LLD interview.
Feature 1: Interfaces (Design by Contract)
What It Is
Interfaces define contracts without implementation. They specify what a class can do without dictating how it does it. This separation is the foundation of abstraction and dependency inversion.
public interface PaymentProcessor {
boolean processPayment(double amount, String currency);
void refund(String transactionId);
PaymentStatus getStatus(String transactionId);
}
Any class implementing PaymentProcessor must provide these methods. Consumers of this interface depend on the contract, not the implementation.
Why It Matters for Design
Interfaces solve the dependency problem. In LLD interviews, you’re often asked to design systems that work with multiple implementations: different payment gateways, various notification channels, multiple storage backends.
Without interfaces, you’d write:
public class OrderService {
private StripePayment stripePayment;
public void checkout(Order order) {
stripePayment.charge(order.getTotal());
}
}
This tight coupling means:
- Can’t swap payment providers without modifying
OrderService - Can’t test
OrderServicewithout real Stripe calls - Can’t add new payment methods without changing existing code
With interfaces:
public class OrderService {
private PaymentProcessor paymentProcessor;
public OrderService(PaymentProcessor paymentProcessor) {
this.paymentProcessor = paymentProcessor;
}
public void checkout(Order order) {
boolean success = paymentProcessor.processPayment(
order.getTotal(),
order.getCurrency()
);
if (success) {
order.markAsPaid();
}
}
}
// Multiple implementations
public class StripePaymentProcessor implements PaymentProcessor {
@Override
public boolean processPayment(double amount, String currency) {
// Stripe API integration
return true;
}
@Override
public void refund(String transactionId) {
// Stripe refund logic
}
@Override
public PaymentStatus getStatus(String transactionId) {
// Query Stripe API
return PaymentStatus.COMPLETED;
}
}
public class PayPalPaymentProcessor implements PaymentProcessor {
@Override
public boolean processPayment(double amount, String currency) {
// PayPal API integration
return true;
}
@Override
public void refund(String transactionId) {
// PayPal refund logic
}
@Override
public PaymentStatus getStatus(String transactionId) {
// Query PayPal API
return PaymentStatus.PENDING;
}
}
Now OrderService works with any payment processor. You've applied dependency inversion: high-level modules (OrderService) don't depend on low-level modules (StripePayment), both depend on abstractions (PaymentProcessor interface).
In interviews, this demonstrates:
- You understand abstraction
- You design for flexibility
- You know how to decouple systems
LLD Problems Where This Shines
- Parking Lot System:
ParkingStrategyinterface for different pricing strategies (hourly, daily, flat-rate) - Notification System:
NotificationChannelinterface for email, SMS, push notifications - Logger System:
LogDestinationinterface for file, console, remote logging - Payment Gateway:
PaymentProcessorinterface for multiple providers - Database Layer:
Repositoryinterface for different storage backends
Complete Example: Notification System
// Contract
public interface NotificationChannel {
boolean send(String recipient, String message);
boolean verify(String recipient);
}
// Implementations
public class EmailNotification implements NotificationChannel {
@Override
public boolean send(String recipient, String message) {
// Validate email
if (!recipient.contains("@")) {
return false;
}
// Send via SMTP
System.out.println("Email sent to " + recipient + ": " + message);
return true;
}
@Override
public boolean verify(String recipient) {
return recipient.contains("@") && recipient.contains(".");
}
}
public class SMSNotification implements NotificationChannel {
@Override
public boolean send(String recipient, String message) {
// Validate phone number
if (recipient.length() < 10) {
return false;
}
// Send via SMS gateway
System.out.println("SMS sent to " + recipient + ": " + message);
return true;
}
@Override
public boolean verify(String recipient) {
return recipient.matches("\\d{10,}");
}
}
// Service using interface
public class NotificationService {
private List<NotificationChannel> channels;
public NotificationService(List<NotificationChannel> channels) {
this.channels = channels;
}
public void notifyUser(String recipient, String message) {
for (NotificationChannel channel : channels) {
if (channel.verify(recipient)) {
channel.send(recipient, message);
}
}
}
}
// Usage
public class Main {
public static void main(String[] args) {
List<NotificationChannel> channels = Arrays.asList(
new EmailNotification(),
new SMSNotification()
);
NotificationService service = new NotificationService(channels);
service.notifyUser("user@example.com", "Your order is shipped!");
service.notifyUser("1234567890", "OTP: 123456");
}
}
Common Mistakes
Mistake 1: Over-abstracting
// Too generic - provides no value
public interface Thing {
void doSomething();
}
Mistake 2: Leaking implementation details
// Interface shouldn't expose implementation-specific methods
public interface PaymentProcessor {
boolean processPayment(double amount);
void setStripeAPIKey(String key); // Wrong! Stripe-specific
}
Mistake 3: Not using interfaces when multiple implementations exist
// If you have StripePayment, PayPalPayment, RazorpayPayment
// but no PaymentProcessor interface, you've missed abstraction
Feature 2: Abstract Classes vs Interfaces (Choosing Abstraction)
What It Is
Abstract classes provide partial implementation with abstract methods. They sit between concrete classes and interfaces.
public abstract class Vehicle {
protected String licensePlate;
protected VehicleType type;
public Vehicle(String licensePlate, VehicleType type) {
this.licensePlate = licensePlate;
this.type = type;
}
// Concrete method - shared logic
public String getLicensePlate() {
return licensePlate;
}
// Abstract method - subclass responsibility
public abstract int getParkingSpaceRequired();
public abstract double getParkingRate();
}
Why It Matters for Design
The choice between abstract class and interface reveals your understanding of “is-a” vs “can-do” relationships:
- Interface: Capability (“can-do”). A class can do multiple things (implement multiple interfaces)
- Abstract Class: Identity (“is-a”). A class has one primary identity (single inheritance)
In interviews, this distinction matters:
Use Interface when:
- Defining a capability that unrelated classes might share
- Need multiple inheritance (a class can implement many interfaces)
- No shared state or implementation
Use Abstract Class when:
- Modeling an inheritance hierarchy with shared state
- Providing default implementations that subclasses can reuse
- Need constructors or instance variables
Complete Example: Parking Lot System
// Abstract class for vehicle hierarchy
public abstract class Vehicle {
protected String licensePlate;
protected VehicleType type;
protected LocalDateTime entryTime;
public Vehicle(String licensePlate, VehicleType type) {
this.licensePlate = licensePlate;
this.type = type;
}
// Shared implementation
public void markEntry() {
this.entryTime = LocalDateTime.now();
}
public long getParkedDurationMinutes() {
return Duration.between(entryTime, LocalDateTime.now()).toMinutes();
}
// Subclass responsibility
public abstract int getParkingSpaceRequired();
public abstract double getHourlyRate();
public VehicleType getType() {
return type;
}
}
// Concrete vehicles
public class Car extends Vehicle {
public Car(String licensePlate) {
super(licensePlate, VehicleType.CAR);
}
@Override
public int getParkingSpaceRequired() {
return 1;
}
@Override
public double getHourlyRate() {
return 20.0;
}
}
public class Truck extends Vehicle {
public Truck(String licensePlate) {
super(licensePlate, VehicleType.TRUCK);
}
@Override
public int getParkingSpaceRequired() {
return 2;
}
@Override
public double getHourlyRate() {
return 50.0;
}
}
public class Motorcycle extends Vehicle {
public Motorcycle(String licensePlate) {
super(licensePlate, VehicleType.MOTORCYCLE);
}
@Override
public int getParkingSpaceRequired() {
return 1;
}
@Override
public double getHourlyRate() {
return 10.0;
}
}
// Interface for pricing strategy (capability)
public interface PricingStrategy {
double calculatePrice(Vehicle vehicle);
}
// Multiple pricing implementations
public class HourlyPricing implements PricingStrategy {
@Override
public double calculatePrice(Vehicle vehicle) {
long hours = (vehicle.getParkedDurationMinutes() + 59) / 60; // Round up
return hours * vehicle.getHourlyRate();
}
}
public class FlatRatePricing implements PricingStrategy {
private double flatRate;
public FlatRatePricing(double flatRate) {
this.flatRate = flatRate;
}
@Override
public double calculatePrice(Vehicle vehicle) {
return flatRate;
}
}
// Parking lot using both abstractions
public class ParkingLot {
private PricingStrategy pricingStrategy;
private List<Vehicle> parkedVehicles;
public ParkingLot(PricingStrategy pricingStrategy) {
this.pricingStrategy = pricingStrategy;
this.parkedVehicles = new ArrayList<>();
}
public void parkVehicle(Vehicle vehicle) {
vehicle.markEntry();
parkedVehicles.add(vehicle);
}
public double checkout(Vehicle vehicle) {
double price = pricingStrategy.calculatePrice(vehicle);
parkedVehicles.remove(vehicle);
return price;
}
}
Decision Framework
ScenarioChoiceReasonVehicle types (Car, Truck, Motorcycle)Abstract ClassShared state (licensePlate, entryTime), “is-a” relationshipPricing strategies (Hourly, Flat, Weekend)InterfaceDifferent implementations, no shared state, “can-do”Payment methods (Card, Cash, UPI)InterfaceUnrelated implementations, might add more laterUser types (Admin, Customer, Guest)Abstract ClassShared authentication logic, role hierarchyNotification channels (Email, SMS, Push)InterfaceIndependent implementations, no shared code
LLD Problems Where This Matters
- Elevator System: Abstract
Requestclass, InterfaceElevatorStrategy - Library Management: Abstract
LibraryItemclass, InterfaceSearchable - Hotel Booking: Abstract
Roomclass, InterfaceBookable - Chess Game: Abstract
Piececlass, InterfaceMovable - File System: Abstract
FileSystemNodeclass, InterfaceCompressible
Common Mistakes
Mistake 1: Using abstract class for capabilities
// Wrong - Flyable is a capability, should be interface
public abstract class Flyable {
public abstract void fly();
}
// Now Bird can't extend both Animal and Flyable
Mistake 2: Using interface when shared state is needed
// Wrong - Can't share entryTime across implementations
public interface Vehicle {
LocalDateTime getEntryTime();
void setEntryTime(LocalDateTime time);
}
Mistake 3: Forgetting that abstract classes can have concrete methods
// Missed opportunity - markEntry could be in abstract class
public abstract class Vehicle {
public abstract void markEntry(); // Every subclass repeats same logic
}
Feature 3: Access Modifiers (Encapsulation Mastery)
What It Is
Java has four access levels: private, protected, public, and package-private (default). They control who can access class members.
public class User {
private String password; // Only this class
protected String email; // This class + subclasses
public String username; // Everyone
String internalId; // Package-private (same package)
}
Why It Matters for Design
Access modifiers enforce information hiding, a core OOP principle. In LLD interviews, proper encapsulation demonstrates:
- You understand data protection
- You design clear APIs
- You prevent misuse
Poor encapsulation:
public class BankAccount {
public double balance; // Anyone can modify!
}
// Disaster waiting to happen
BankAccount account = new BankAccount();
account.balance = 1000000; // Bypassed all validation
Proper encapsulation:
public class BankAccount {
private double balance;
private final String accountNumber;
public BankAccount(String accountNumber) {
this.accountNumber = accountNumber;
this.balance = 0.0;
}
public boolean deposit(double amount) {
if (amount <= 0) {
return false;
}
this.balance += amount;
return true;
}
public boolean withdraw(double amount) {
if (amount <= 0 || amount > balance) {
return false;
}
this.balance -= amount;
return true;
}
public double getBalance() {
return balance;
}
public String getAccountNumber() {
return accountNumber;
}
}
Now all modifications go through validated methods. Balance can’t be manipulated directly.
Complete Example: ATM System
public class ATM {
private String atmId;
private double cashAvailable;
private ATMState currentState;
public ATM(String atmId, double initialCash) {
this.atmId = atmId;
this.cashAvailable = initialCash;
this.currentState = ATMState.IDLE;
}
// Public API - what users can do
public boolean authenticateCard(String cardNumber, String pin) {
if (currentState != ATMState.IDLE) {
return false;
}
// Validate card and PIN
boolean valid = validateCredentials(cardNumber, pin);
if (valid) {
currentState = ATMState.AUTHENTICATED;
}
return valid;
}
public boolean withdrawCash(double amount) {
if (currentState != ATMState.AUTHENTICATED) {
return false;
}
if (!canDispenseCash(amount)) {
return false;
}
dispenseCash(amount);
return true;
}
public void endSession() {
currentState = ATMState.IDLE;
}
// Private helpers - internal implementation
private boolean validateCredentials(String cardNumber, String pin) {
// Complex validation logic hidden from users
return cardNumber.length() == 16 && pin.length() == 4;
}
private boolean canDispenseCash(double amount) {
return amount > 0 && amount <= cashAvailable && amount % 100 == 0;
}
private void dispenseCash(double amount) {
cashAvailable -= amount;
logTransaction(amount);
}
private void logTransaction(double amount) {
System.out.println("ATM " + atmId + " dispensed: $" + amount);
}
// Protected - for potential subclasses (different ATM types)
protected void refillCash(double amount) {
if (amount > 0) {
cashAvailable += amount;
}
}
// Package-private - for admin tools in same package
ATMState getCurrentState() {
return currentState;
}
double getCashAvailable() {
return cashAvailable;
}
}
enum ATMState {
IDLE, AUTHENTICATED, DISPENSING, OUT_OF_SERVICE
}
// In same package - admin class
class ATMAdmin {
public void checkATMStatus(ATM atm) {
// Can access package-private methods
System.out.println("State: " + atm.getCurrentState());
System.out.println("Cash: $" + atm.getCashAvailable());
}
}
Access Modifier Decision Guide
Use private when:
- Internal state that should never be exposed
- Helper methods used only within the class
- Implementation details that might change
- Default choice — start private, widen only if needed
Use protected when:
- Methods/fields that subclasses need
- Template method pattern implementations
- Inheritance hierarchies with shared protected state
Use public when:
- Part of the class’s API
- Must be accessible to external users
- Interface implementations (must match public interface)
Use package-private (no modifier) when:
- Collaboration between classes in same package
- Test classes need access
- Internal package APIs
LLD Problems Where This Matters
- ATM System: Private cash handling, public user operations
- Hotel Booking: Private pricing logic, public booking API
- Vending Machine: Private inventory management, public purchase interface
- Library Management: Private fine calculation, public checkout/return
- Ride Sharing: Private driver matching algorithm, public booking API
Common Mistakes
Mistake 1: Everything public
public class Order {
public double price;
public OrderStatus status;
public List<Item> items;
}
// No validation, no control, anyone can break invariants
Mistake 2: Getters/setters for everything
public class User {
private String password;
public String getPassword() { return password; } // Exposing password!
public void setPassword(String password) {
this.password = password; // No validation!
}
}
Better approach:
public class User {
private String hashedPassword;
public boolean verifyPassword(String password) {
return BCrypt.checkpw(password, hashedPassword);
}
public void updatePassword(String oldPassword, String newPassword) {
if (!verifyPassword(oldPassword)) {
throw new IllegalArgumentException("Invalid current password");
}
if (newPassword.length() < 8) {
throw new IllegalArgumentException("Password too short");
}
this.hashedPassword = BCrypt.hashpw(newPassword, BCrypt.gensalt());
}
}
Mistake 3: Using protected carelessly
public abstract class BankAccount {
protected double balance; // Subclasses can break invariants!
}
public class SavingsAccount extends BankAccount {
public void cheat() {
balance = 1000000; // Bypassed all validation
}
}
Better:
public abstract class BankAccount {
private double balance; // Only parent can modify
protected void modifyBalance(double amount) {
// Controlled modification point for subclasses
if (balance + amount >= 0) {
balance += amount;
}
}
}
Feature 4: Enums (Type Safety and State Machines)
What It Is
Enums define a fixed set of constants with type safety. Unlike strings or integers, enums prevent invalid values at compile time.
public enum OrderStatus {
PENDING, CONFIRMED, SHIPPED, DELIVERED, CANCELLED
}
Why It Matters for Design
String-based states:
public class Order {
private String status; // What values are valid? Who knows!
public void updateStatus(String newStatus) {
this.status = newStatus;
}
}
// Runtime disaster
order.updateStatus("SHIPPD"); // Typo - no compile error
order.updateStatus("delivered"); // Wrong case
order.updateStatus("UNKNOWN_STATUS"); // Invalid state
Enum-based states:
public class Order {
private OrderStatus status;
public void updateStatus(OrderStatus newStatus) {
// Only valid OrderStatus values allowed
this.status = newStatus;
}
}
// Compile-time safety
order.updateStatus(OrderStatus.SHIPPED); // ✓ Works
order.updateStatus("SHIPPED"); // ✗ Compile error
Enums provide:
- Type safety: Invalid states caught at compile time
- Autocomplete: IDE shows all valid options
- Refactoring: Rename propagates everywhere
- Switch exhaustiveness: Compiler ensures all cases handled
Complete Example: Vending Machine
public enum VendingMachineState {
IDLE,
ACCEPTING_MONEY,
DISPENSING_PRODUCT,
RETURNING_CHANGE,
OUT_OF_ORDER;
public boolean canAcceptMoney() {
return this == IDLE || this == ACCEPTING_MONEY;
}
public boolean canDispense() {
return this == ACCEPTING_MONEY;
}
}
public enum Product {
COKE(50, "Coke"),
PEPSI(45, "Pepsi"),
WATER(30, "Water"),
CHIPS(40, "Chips"),
CANDY(25, "Candy");
private final int price;
private final String displayName;
Product(int price, String displayName) {
this.price = price;
this.displayName = displayName;
}
public int getPrice() {
return price;
}
public String getDisplayName() {
return displayName;
}
}
public class VendingMachine {
private VendingMachineState state;
private int currentAmount;
private Map<Product, Integer> inventory;
public VendingMachine() {
this.state = VendingMachineState.IDLE;
this.currentAmount = 0;
this.inventory = new EnumMap<>(Product.class);
// Initialize inventory
for (Product product : Product.values()) {
inventory.put(product, 10);
}
}
public boolean insertMoney(int amount) {
if (!state.canAcceptMoney()) {
System.out.println("Cannot accept money in state: " + state);
return false;
}
currentAmount += amount;
state = VendingMachineState.ACCEPTING_MONEY;
System.out.println("Current balance: $" + currentAmount);
return true;
}
public boolean selectProduct(Product product) {
if (!state.canDispense()) {
System.out.println("Cannot dispense in state: " + state);
return false;
}
// Check inventory
if (inventory.get(product) == 0) {
System.out.println(product.getDisplayName() + " is out of stock");
return false;
}
// Check sufficient money
if (currentAmount < product.getPrice()) {
System.out.println("Insufficient money. Need $" +
(product.getPrice() - currentAmount) + " more");
return false;
}
// Dispense product
dispenseProduct(product);
return true;
}
private void dispenseProduct(Product product) {
state = VendingMachineState.DISPENSING_PRODUCT;
// Update inventory
inventory.put(product, inventory.get(product) - 1);
// Calculate change
int change = currentAmount - product.getPrice();
currentAmount = 0;
System.out.println("Dispensing: " + product.getDisplayName());
if (change > 0) {
returnChange(change);
}
state = VendingMachineState.IDLE;
}
private void returnChange(int change) {
state = VendingMachineState.RETURNING_CHANGE;
System.out.println("Returning change: $" + change);
}
public void refund() {
if (currentAmount > 0) {
System.out.println("Refunding: $" + currentAmount);
currentAmount = 0;
}
state = VendingMachineState.IDLE;
}
public void displayInventory() {
System.out.println("\n=== Inventory ===");
for (Product product : Product.values()) {
System.out.println(product.getDisplayName() +
" ($" + product.getPrice() + "): " +
inventory.get(product) + " units");
}
}
}
Enum Best Practices
1. Use EnumMap/EnumSet for enum-keyed collections
// Better performance than HashMap
Map<Product, Integer> inventory = new EnumMap<>(Product.class);
Set<OrderStatus> terminalStates = EnumSet.of(
OrderStatus.DELIVERED,
OrderStatus.CANCELLED
);
2. Add methods to enums
public enum PaymentMethod {
CREDIT_CARD {
@Override
public boolean requiresVerification() {
return true;
}
},
CASH {
@Override
public boolean requiresVerification() {
return false;
}
},
UPI {
@Override
public boolean requiresVerification() {
return true;
}
};
public abstract boolean requiresVerification();
}
3. Use enums for strategy pattern
public enum DiscountStrategy {
NONE {
@Override
public double apply(double price) {
return price;
}
},
PERCENTAGE_10 {
@Override
public double apply(double price) {
return price * 0.9;
}
},
FLAT_50 {
@Override
public double apply(double price) {
return Math.max(0, price - 50);
}
};
public abstract double apply(double price);
}
LLD Problems Where Enums Shine
- Vending Machine: States, products, coin denominations
- Chess Game: Piece types, colors, move directions
- Elevator System: Directions, states, door status
- Traffic Signal: Signal colors, states
- Card Game: Suits, ranks, game phases
Common Mistakes
Mistake 1: Using strings instead of enums
// Bad
if (order.getStatus().equals("SHIPPED")) { }
// Good
if (order.getStatus() == OrderStatus.SHIPPED) { }
Mistake 2: Not handling all enum cases in switch
// Missing CANCELLED case - compiler doesn't warn with default
switch (status) {
case PENDING: break;
case CONFIRMED: break;
case SHIPPED: break;
case DELIVERED: break;
default: break; // Hides missing CANCELLED
}
// Better - no default, compiler enforces exhaustiveness
switch (status) {
case PENDING: break;
case CONFIRMED: break;
case SHIPPED: break;
case DELIVERED: break;
case CANCELLED: break; // Compiler error if missing
}
Mistake 3: Using ordinal() for logic
// Fragile - breaks if enum order changes
if (status.ordinal() > OrderStatus.CONFIRMED.ordinal()) { }
// Better - explicit comparison
if (status == OrderStatus.SHIPPED || status == OrderStatus.DELIVERED) { }
Feature 5: Optional and Exception Handling (Defensive Design)
What It Is
Optional<T> represents a value that may or may not be present, eliminating null pointer exceptions.
Optional<User> user = userRepository.findById(userId);
Exceptions handle error cases explicitly, with checked exceptions forcing error handling.
public void withdraw(double amount) throws InsufficientFundsException {
if (amount > balance) {
throw new InsufficientFundsException("Balance: " + balance);
}
balance -= amount;
}
Why It Matters for Design
Null-based design:
public User getUserById(String userId) {
// Returns null if not found - caller must remember to check
return database.get(userId);
}
// Disaster waiting to happen
User user = getUserById("123");
String email = user.getEmail(); // NullPointerException!
Optional-based design:
public Optional<User> getUserById(String userId) {
return Optional.ofNullable(database.get(userId));
}
// Forced to handle absence
Optional<User> userOpt = getUserById("123");
String email = userOpt
.map(User::getEmail)
.orElse("no-reply@example.com");
The type system now documents that a user might not exist. The compiler won’t let you forget.
Complete Example: Hotel Booking System
// Custom exceptions
public class RoomNotAvailableException extends Exception {
public RoomNotAvailableException(String message) {
super(message);
}
}
public class InvalidBookingException extends Exception {
public InvalidBookingException(String message) {
super(message);
}
}
public class PaymentFailedException extends Exception {
public PaymentFailedException(String message) {
super(message);
}
}
// Room class
public class Room {
private String roomNumber;
private RoomType type;
private double pricePerNight;
private boolean isAvailable;
public Room(String roomNumber, RoomType type, double pricePerNight) {
this.roomNumber = roomNumber;
this.type = type;
this.pricePerNight = pricePerNight;
this.isAvailable = true;
}
public String getRoomNumber() { return roomNumber; }
public RoomType getType() { return type; }
public double getPricePerNight() { return pricePerNight; }
public boolean isAvailable() { return isAvailable; }
public void markBooked() { this.isAvailable = false; }
public void markAvailable() { this.isAvailable = true; }
}
public enum RoomType {
SINGLE, DOUBLE, SUITE
}
// Booking system with defensive design
public class HotelBookingSystem {
private Map<String, Room> rooms;
private Map<String, Booking> bookings;
public HotelBookingSystem() {
this.rooms = new HashMap<>();
this.bookings = new HashMap<>();
}
public void addRoom(Room room) {
rooms.put(room.getRoomNumber(), room);
}
// Returns Optional - room might not exist
public Optional<Room> findRoomByNumber(String roomNumber) {
return Optional.ofNullable(rooms.get(roomNumber));
}
// Returns Optional - might not find available room
public Optional<Room> findAvailableRoom(RoomType type) {
return rooms.values().stream()
.filter(room -> room.getType() == type && room.isAvailable())
.findFirst();
}
// Throws exceptions for error cases
public Booking createBooking(String guestName, RoomType type,
int nights, PaymentMethod paymentMethod)
throws RoomNotAvailableException, InvalidBookingException,
PaymentFailedException {
// Validation
if (nights <= 0) {
throw new InvalidBookingException("Nights must be positive");
}
if (guestName == null || guestName.trim().isEmpty()) {
throw new InvalidBookingException("Guest name required");
}
// Find available room
Optional<Room> roomOpt = findAvailableRoom(type);
if (!roomOpt.isPresent()) {
throw new RoomNotAvailableException(
"No " + type + " rooms available"
);
}
Room room = roomOpt.get();
double totalAmount = room.getPricePerNight() * nights;
// Process payment (may fail)
boolean paymentSuccess = processPayment(totalAmount, paymentMethod);
if (!paymentSuccess) {
throw new PaymentFailedException(
"Payment of $" + totalAmount + " failed"
);
}
// Create booking
room.markBooked();
Booking booking = new Booking(
generateBookingId(),
guestName,
room,
nights,
totalAmount
);
bookings.put(booking.getBookingId(), booking);
return booking;
}
private boolean processPayment(double amount, PaymentMethod method) {
// Simulate payment processing
return amount > 0 && method != null;
}
private String generateBookingId() {
return "BK" + System.currentTimeMillis();
}
// Returns Optional - booking might not exist
public Optional<Booking> getBooking(String bookingId) {
return Optional.ofNullable(bookings.get(bookingId));
}
// Throws exception if booking doesn't exist
public void cancelBooking(String bookingId)
throws InvalidBookingException {
Optional<Booking> bookingOpt = getBooking(bookingId);
if (!bookingOpt.isPresent()) {
throw new InvalidBookingException(
"Booking " + bookingId + " not found"
);
}
Booking booking = bookingOpt.get();
booking.getRoom().markAvailable();
bookings.remove(bookingId);
}
}
public class Booking {
private String bookingId;
private String guestName;
private Room room;
private int nights;
private double totalAmount;
public Booking(String bookingId, String guestName, Room room,
int nights, double totalAmount) {
this.bookingId = bookingId;
this.guestName = guestName;
this.room = room;
this.nights = nights;
this.totalAmount = totalAmount;
}
public String getBookingId() { return bookingId; }
public String getGuestName() { return guestName; }
public Room getRoom() { return room; }
public int getNights() { return nights; }
public double getTotalAmount() { return totalAmount; }
}
enum PaymentMethod {
CREDIT_CARD, DEBIT_CARD, CASH, UPI
}
// Usage demonstrating defensive design
public class Main {
public static void main(String[] args) {
HotelBookingSystem hotel = new HotelBookingSystem();
// Add rooms
hotel.addRoom(new Room("101", RoomType.SINGLE, 100));
hotel.addRoom(new Room("201", RoomType.DOUBLE, 150));
hotel.addRoom(new Room("301", RoomType.SUITE, 300));
try {
// Successful booking
Booking booking = hotel.createBooking(
"John Doe",
RoomType.DOUBLE,
3,
PaymentMethod.CREDIT_CARD
);
System.out.println("Booking successful: " + booking.getBookingId());
// Try to book same type again - will fail
Booking booking2 = hotel.createBooking(
"Jane Smith",
RoomType.DOUBLE,
2,
PaymentMethod.CASH
);
} catch (RoomNotAvailableException e) {
System.out.println("Room not available: " + e.getMessage());
} catch (InvalidBookingException e) {
System.out.println("Invalid booking: " + e.getMessage());
} catch (PaymentFailedException e) {
System.out.println("Payment failed: " + e.getMessage());
}
// Using Optional
Optional<Room> room = hotel.findRoomByNumber("101");
room.ifPresent(r ->
System.out.println("Found room: " + r.getRoomNumber())
);
Optional<Room> nonExistent = hotel.findRoomByNumber("999");
String status = nonExistent
.map(r -> "Available")
.orElse("Not found");
System.out.println("Room 999: " + status);
}
}
When to Use Optional vs Exceptions
Use Optional when:
- Absence is a normal, expected outcome
- “Not found” is not an error
- Example:
findUserByEmail()- user might not exist
Use Exceptions when:
- Absence is exceptional, unexpected
- Error condition that needs handling
- Example:
getUserById()where ID should always be valid
LLD Problems Where This Matters
- Hotel Booking: Room not available, payment failed
- ATM System: Insufficient funds, invalid PIN
- Library Management: Book not found, overdue fines
- Parking Lot: No space available, invalid ticket
- Ride Sharing: No drivers nearby, payment failed
Common Mistakes
Mistake 1: Calling get() without checking
Optional<User> user = findUser("123");
String name = user.get().getName(); // Throws if absent!
// Better
String name = user
.map(User::getName)
.orElse("Unknown");
Mistake 2: Using Optional for fields
// Bad
public class User {
private Optional<String> middleName; // Don't do this
}
// Good - just use null or provide a default
public class User {
private String middleName; // Can be null
}
Mistake 3: Catching all exceptions
// Too broad - hides bugs
try {
booking.process();
} catch (Exception e) {
// Catches NullPointerException, programming bugs, everything
}
// Better - catch specific exceptions
try {
booking.process();
} catch (PaymentFailedException | InvalidBookingException e) {
// Handle expected errors
}
Feature 6: Generics (Reusable, Type-Safe Design)
What It Is
Generics allow classes and methods to operate on typed parameters, providing compile-time type safety with reusability.
public class Box<T> {
private T content;
public void put(T item) {
this.content = item;
}
public T get() {
return content;
}
}
Why It Matters for Design
Without generics:
// Type-unsafe - uses Object
public class Cache {
private Map<String, Object> data = new HashMap<>();
public void put(String key, Object value) {
data.put(key, value);
}
public Object get(String key) {
return data.get(key);
}
}
// Requires casting, no type safety
Cache cache = new Cache();
cache.put("age", 25);
Integer age = (Integer) cache.get("age"); // Runtime risk
cache.put("name", "John");
Integer name = (Integer) cache.get("name"); // Runtime crash!
With generics:
public class Cache<K, V> {
private Map<K, V> data = new HashMap<>();
public void put(K key, V value) {
data.put(key, value);
}
public V get(K key) {
return data.get(key);
}
}
// Type-safe, no casting
Cache<String, Integer> ageCache = new Cache<>();
ageCache.put("john", 25);
Integer age = ageCache.get("john"); // No cast needed
// ageCache.put("john", "twenty"); // Compile error!
Complete Example: LRU Cache
public class LRUCache<K, V> {
private final int capacity;
private final Map<K, Node<K, V>> cache;
private final DoublyLinkedList<K, V> list;
public LRUCache(int capacity) {
this.capacity = capacity;
this.cache = new HashMap<>();
this.list = new DoublyLinkedList<>();
}
public Optional<V> get(K key) {
Node<K, V> node = cache.get(key);
if (node == null) {
return Optional.empty();
}
// Move to front (most recently used)
list.moveToFront(node);
return Optional.of(node.value);
}
public void put(K key, V value) {
Node<K, V> existingNode = cache.get(key);
if (existingNode != null) {
// Update existing
existingNode.value = value;
list.moveToFront(existingNode);
} else {
// Add new
if (cache.size() >= capacity) {
// Evict least recently used
Node<K, V> lru = list.removeLast();
cache.remove(lru.key);
}
Node<K, V> newNode = new Node<>(key, value);
list.addFirst(newNode);
cache.put(key, newNode);
}
}
public int size() {
return cache.size();
}
// Generic node class
private static class Node<K, V> {
K key;
V value;
Node<K, V> prev;
Node<K, V> next;
Node(K key, V value) {
this.key = key;
this.value = value;
}
}
// Generic doubly linked list
private static class DoublyLinkedList<K, V> {
private Node<K, V> head;
private Node<K, V> tail;
DoublyLinkedList() {
head = new Node<>(null, null);
tail = new Node<>(null, null);
head.next = tail;
tail.prev = head;
}
void addFirst(Node<K, V> node) {
node.next = head.next;
node.prev = head;
head.next.prev = node;
head.next = node;
}
void remove(Node<K, V> node) {
node.prev.next = node.next;
node.next.prev = node.prev;
}
Node<K, V> removeLast() {
Node<K, V> last = tail.prev;
remove(last);
return last;
}
void moveToFront(Node<K, V> node) {
remove(node);
addFirst(node);
}
}
}
// Usage - works with any types
public class Main {
public static void main(String[] args) {
// String keys, Integer values
LRUCache<String, Integer> ageCache = new LRUCache<>(3);
ageCache.put("Alice", 30);
ageCache.put("Bob", 25);
ageCache.put("Charlie", 35);
System.out.println(ageCache.get("Alice")); // Optional[30]
ageCache.put("David", 40); // Evicts Bob (least recently used)
System.out.println(ageCache.get("Bob")); // Optional.empty
// Integer keys, String values
LRUCache<Integer, String> userCache = new LRUCache<>(2);
userCache.put(1, "User1");
userCache.put(2, "User2");
// Custom object types
LRUCache<UserId, User> userObjectCache = new LRUCache<>(100);
}
}
Advanced Generics: Bounded Type Parameters
// Only accept types that implement Comparable
public class MinHeap<T extends Comparable<T>> {
private List<T> heap;
public MinHeap() {
this.heap = new ArrayList<>();
}
public void insert(T element) {
heap.add(element);
heapifyUp(heap.size() - 1);
}
public Optional<T> extractMin() {
if (heap.isEmpty()) {
return Optional.empty();
}
T min = heap.get(0);
T last = heap.remove(heap.size() - 1);
if (!heap.isEmpty()) {
heap.set(0, last);
heapifyDown(0);
}
return Optional.of(min);
}
private void heapifyUp(int index) {
while (index > 0) {
int parent = (index - 1) / 2;
if (heap.get(index).compareTo(heap.get(parent)) >= 0) {
break;
}
swap(index, parent);
index = parent;
}
}
private void heapifyDown(int index) {
while (true) {
int smallest = index;
int left = 2 * index + 1;
int right = 2 * index + 2;
if (left < heap.size() &&
heap.get(left).compareTo(heap.get(smallest)) < 0) {
smallest = left;
}
if (right < heap.size() &&
heap.get(right).compareTo(heap.get(smallest)) < 0) {
smallest = right;
}
if (smallest == index) {
break;
}
swap(index, smallest);
index = smallest;
}
}
private void swap(int i, int j) {
T temp = heap.get(i);
heap.set(i, heap.get(j));
heap.set(j, temp);
}
}
// Works with any Comparable type
MinHeap<Integer> intHeap = new MinHeap<>();
MinHeap<String> stringHeap = new MinHeap<>();
MinHeap<Task> taskHeap = new MinHeap<>(); // If Task implements Comparable
LLD Problems Where Generics Shine
- LRU Cache: Generic key-value storage
- Priority Queue/Heap: Generic comparable elements
- Object Pool: Generic resource pooling
- Event Bus: Generic event types and handlers
- Repository Pattern: Generic CRUD operations
Common Mistakes
Mistake 1: Using raw types
// Bad - loses type safety
List list = new ArrayList();
list.add("string");
list.add(123);
Integer num = (Integer) list.get(0); // Runtime crash
// Good
List<String> list = new ArrayList<>();
Mistake 2: Incorrect bounded types
// Wrong - too restrictive
public class Container<T extends ArrayList> { } // Don't extend concrete class
// Right - use interface bounds
public class Container<T extends List> { }
Mistake 3: Type erasure confusion
// Won't work - type info erased at runtime
public class Cache<T> {
public boolean isInstanceOf(Object obj) {
return obj instanceof T; // Compile error
}
}
// Workaround - pass Class<T>
public class Cache<T> {
private Class<T> type;
public Cache(Class<T> type) {
this.type = type;
}
public boolean isInstanceOf(Object obj) {
return type.isInstance(obj);
}
}
Feature 7: Inner Classes and Nested Types (Encapsulation and Cohesion)
What It Is
Java allows defining classes inside other classes. Inner classes have access to the outer class’s members, enabling tight encapsulation.
public class LinkedList<T> {
private Node head;
// Inner class - only LinkedList needs to know about Node
private class Node {
T data;
Node next;
Node(T data) {
this.data = data;
}
}
}
Why It Matters for Design
Inner classes prevent implementation details from leaking into your public API:
Without inner classes:
// Node is public - anyone can create/manipulate nodes
public class Node<T> {
public T data;
public Node<T> next;
}
public class LinkedList<T> {
private Node<T> head;
// Node exposed to everyone
}
// Clients can break LinkedList invariants
Node<Integer> rogue = new Node<>(999);
rogue.next = myList.head; // Corrupted list
With inner classes:
public class LinkedList<T> {
private Node head;
// Fully encapsulated - only LinkedList can create nodes
private class Node {
T data;
Node next;
Node(T data) {
this.data = data;
}
}
public void add(T data) {
Node newNode = new Node(data); // Only we can create nodes
// Safe implementation
}
}
Complete Example: Binary Search Tree with Iterator
public class BinarySearchTree<T extends Comparable<T>> {
private Node root;
private int size;
public BinarySearchTree() {
this.root = null;
this.size = 0;
}
public void insert(T value) {
root = insertRec(root, value);
size++;
}
private Node insertRec(Node node, T value) {
if (node == null) {
return new Node(value);
}
int cmp = value.compareTo(node.data);
if (cmp < 0) {
node.left = insertRec(node.left, value);
} else if (cmp > 0) {
node.right = insertRec(node.right, value);
}
return node;
}
public boolean contains(T value) {
return containsRec(root, value);
}
private boolean containsRec(Node node, T value) {
if (node == null) {
return false;
}
int cmp = value.compareTo(node.data);
if (cmp == 0) {
return true;
} else if (cmp < 0) {
return containsRec(node.left, value);
} else {
return containsRec(node.right, value);
}
}
public Iterator<T> inorderIterator() {
return new InorderIterator();
}
public int size() {
return size;
}
// Private inner class - Node
private class Node {
T data;
Node left;
Node right;
Node(T data) {
this.data = data;
this.left = null;
this.right = null;
}
}
// Private inner class - Iterator
// Has access to BST's private members
private class InorderIterator implements Iterator<T> {
private Stack<Node> stack;
InorderIterator() {
stack = new Stack<>();
pushLeft(root);
}
private void pushLeft(Node node) {
while (node != null) {
stack.push(node);
node = node.left;
}
}
@Override
public boolean hasNext() {
return !stack.isEmpty();
}
@Override
public T next() {
if (!hasNext()) {
throw new NoSuchElementException();
}
Node node = stack.pop();
T result = node.data;
if (node.right != null) {
pushLeft(node.right);
}
return result;
}
}
}
// Usage
public class Main {
public static void main(String[] args) {
BinarySearchTree<Integer> bst = new BinarySearchTree<>();
bst.insert(50);
bst.insert(30);
bst.insert(70);
bst.insert(20);
bst.insert(40);
System.out.println("Contains 30: " + bst.contains(30));
System.out.println("Contains 60: " + bst.contains(60));
System.out.print("Inorder: ");
Iterator<Integer> iterator = bst.inorderIterator();
while (iterator.hasNext()) {
System.out.print(iterator.next() + " ");
}
// Output: 20 30 40 50 70
}
}
Types of Inner Classes
1. Non-static inner class (instance inner class)
public class Outer {
private int outerField = 10;
// Can access outer instance members
class Inner {
void display() {
System.out.println("Outer field: " + outerField);
}
}
}
// Usage requires outer instance
Outer outer = new Outer();
Outer.Inner inner = outer.new Inner();
2. Static nested class
public class Outer {
private static int staticField = 20;
private int instanceField = 10;
// Can only access static outer members
static class StaticNested {
void display() {
System.out.println("Static field: " + staticField);
// System.out.println(instanceField); // Error!
}
}
}
// Usage doesn't require outer instance
Outer.StaticNested nested = new Outer.StaticNested();
3. Local inner class (inside method)
public class Outer {
public Iterator<String> createIterator(List<String> items) {
// Local class inside method
class LocalIterator implements Iterator<String> {
private int index = 0;
@Override
public boolean hasNext() {
return index < items.size();
}
@Override
public String next() {
return items.get(index++);
}
}
return new LocalIterator();
}
}
4. Anonymous inner class
// Creating interface implementation on the fly
Comparator<String> lengthComparator = new Comparator<String>() {
@Override
public int compare(String s1, String s2) {
return Integer.compare(s1.length(), s2.length());
}
};
// Modern Java - use lambda instead
Comparator<String> lengthComparator =
(s1, s2) -> Integer.compare(s1.length(), s2.length());
LLD Problems Where Inner Classes Help
- Data Structure Implementation: LinkedList, BST, HashMap (Node classes)
- Iterator Pattern: Custom iterators for collections
- Builder Pattern: Static nested Builder class
- State Pattern: Private state classes inside context
- Observer Pattern: Anonymous inner classes for listeners
When to Use Inner vs Nested
Use non-static inner class when:
- Need access to outer instance members
- Each inner instance logically belongs to outer instance
- Example: Iterator that traverses specific tree instance
Use static nested class when:
- Don’t need outer instance access
- Logical grouping only
- Example: Builder for immutable class
Common Mistakes
Mistake 1: Making implementation details public
// Bad - Node should be inner class
public class Node {
public int data;
public Node next;
}
public class LinkedList {
public Node head; // Exposed!
}
Mistake 2: Using non-static when static would work
// Wastes memory - holds reference to outer instance unnecessarily
public class Container {
class Helper { // Should be static
static int count = 0; // Error! Non-static can't have static members
}
}
// Better
public class Container {
static class Helper {
static int count = 0; // Works
}
}
Mistake 3: Anonymous classes when lambda works
// Verbose
button.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
System.out.println("Clicked");
}
});
// Better
button.addActionListener(e -> System.out.println("Clicked"));
Practice Framework: Applying These Features
Now that you understand the 7 features, here’s how to practice them systematically:
8 LLD Problems Organized by Features
Problem 1: Parking Lot System
- Features: Interfaces (PricingStrategy), Abstract Classes (Vehicle), Enums (VehicleType, SpotSize), Access Modifiers
- Focus: Abstraction hierarchies, strategy pattern
Problem 2: Library Management System
- Features: Abstract Classes (LibraryItem), Interfaces (Searchable), Optional (finding books), Exception Handling (overdue fines)
- Focus: Search operations, optional returns, date handling
Problem 3: Hotel Booking System
- Features: Enums (RoomType, BookingStatus), Exception Handling (availability, payment), Optional (finding rooms)
- Focus: State management, defensive design
Problem 4: ATM System
- Features: Enums (state machine), Access Modifiers (cash handling), Exception Handling (insufficient funds), Inner Classes (Transaction records)
- Focus: State transitions, security through encapsulation
Problem 5: Vending Machine
- Features: Enums (products, states), State Pattern, Access Modifiers
- Focus: State machine design, product management
Problem 6: LRU Cache
- Features: Generics (key-value types), Inner Classes (Node), Optional (get operations)
- Focus: Generic data structures, encapsulation
Problem 7: Notification System
- Features: Interfaces (NotificationChannel), Generics (event types), Exception Handling (delivery failures)
- Focus: Multiple implementations, extensibility
Problem 8: Elevator System
- Features: Enums (Direction, State), Interfaces (RequestStrategy), Abstract Classes (Request types)
- Focus: Complex state management, scheduling algorithms
Common Design Mistakes (and How Java Features Prevent Them)
Mistake 1: Tight Coupling
// Bad - directly depends on concrete class
public class OrderService {
private StripePayment payment = new StripePayment();
}
// Good - depends on interface
public class OrderService {
private PaymentProcessor payment;
public OrderService(PaymentProcessor payment) {
this.payment = payment;
}
}
Prevention: Use interfaces (Feature 1)
Mistake 2: God Classes
// Bad - one class does everything
public class UserManager {
public void createUser() { }
public void authenticateUser() { }
public void sendEmailNotification() { }
public void processPayment() { }
}
// Good - separate responsibilities
public class UserService {
private AuthenticationService auth;
private NotificationService notifications;
private PaymentService payments;
}
Prevention: Composition with interfaces (Feature 1)
Mistake 3: Broken Encapsulation
// Bad - internal state exposed
public class BankAccount {
public double balance;
}
// Good - controlled access
public class BankAccount {
private double balance;
public boolean withdraw(double amount) {
if (amount > balance) return false;
balance -= amount;
return true;
}
}
Prevention: Access modifiers (Feature 3)
Mistake 4: Magic Strings
// Bad
if (order.getStatus().equals("SHIPPD")) { // Typo!
// Good
if (order.getStatus() == OrderStatus.SHIPPED) {
Prevention: Enums (Feature 4)
Mistake 5: Null Pointer Exceptions
// Bad
User user = findUser(id);
String email = user.getEmail(); // Crashes if null
// Good
Optional<User> user = findUser(id);
String email = user.map(User::getEmail).orElse("unknown");
Prevention: Optional (Feature 5)
Mistake 6: Type Unsafe Collections
// Bad
List cache = new ArrayList();
cache.add("string");
cache.add(123);
Integer num = (Integer) cache.get(0); // Runtime crash
// Good
List<String> cache = new ArrayList<>();
Prevention: Generics (Feature 6)
Mistake 7: Leaking Implementation Details
// Bad - Node is public
public class Node {
public int data;
public Node next;
}
// Good - Node is inner class
public class LinkedList {
private class Node { }
}
Prevention: Inner classes (Feature 7)
Resources and Next Steps
Books for Deeper Understanding
- Effective Java by Joshua Bloch — Best practices for all 7 features
- Head First Design Patterns — Design patterns using Java features
- Clean Code by Robert Martin — Principles behind good design
Online Resources
- Java Documentation: Official Oracle docs for each feature
- LeetCode Design: Practice LLD problems
- GitHub: Study open-source Java projects (Spring, Guava, Apache Commons)
Key Takeaway
These 7 Java features aren’t just language syntax — they’re tools for expressing design principles:
- Interfaces → Abstraction & Dependency Inversion
- Abstract Classes → Template Method & Inheritance
- Access Modifiers → Encapsulation & Information Hiding
- Enums → Type Safety & State Machines
- Optional & Exceptions → Defensive Design
- Generics → Reusability & Type Safety
- Inner Classes → Encapsulation & Cohesion
Master these features, and you’ll write LLD solutions that are clean, maintainable, extensible, and type-safe. Your interviewer will see your design thinking in every line of code.
What’s your experience with these Java features? Which one has improved your design thinking the most? Share your thoughts in the comments!
If you found this guide helpful, bookmark it for your next LLD interview preparation.
Happy coding, and good luck with your LLD interviews!
메타데이터
- post_id
- 5398f221a3b7
- slug
- 7-java-features-that-make-you-better-at-low-level-design-interviews-5398f221a3b7
- url
- https://medium.com/javaguides/7-java-features-that-make-you-better-at-low-level-design-interviews-5398f221a3b7
- canonical_url
- https://medium.com/javaguides/7-java-features-that-make-you-better-at-low-level-design-interviews-5398f221a3b7
- author_url
- https://medium.com/@ramogh2404
- status
- ok
- fetched_at
- 2026-09-10 03:16:28