Low Level Design: Fundamentals

December 1, 2025

Role of OOP in Low-Level Design

Object-Oriented Programming (OOP) is the foundation of Low-Level Design (LLD). OOP helps model real-world systems into classes, objects, behaviors, and relationships. This is essential for building scalable and maintainable software.

NOTE: LLD is essentially applying OOP principles to design maintainable software.


What is an Inner Class?

Inner classes in Java are simply classes defined inside another class, used to group related logic and improve encapsulation. The inner class is tied to outer and usually represents something that makes sense within it.

Types of inner classes include:

Non-static Inner Class (Member Inner Class)

A non-static inner class is tied to an instance of the outer class and can access all its members.

class Outer {
    int x = 10;

    class Inner {
        void display() {
            System.out.println(x); // can access directly
        }
    }
}

Outer o = new Outer();
Outer.Inner i = o.new Inner();

Static Nested Class

A static nested class does not require an outer instance and can only access static members.

class Outer {
    static class Inner {
        void display() {
            System.out.println("Inner class");
        }
    }
}


What is an Immutable class?

In multi-threaded applications, bugs usually happen when two different threads try to modify the exact same piece of data at the same time, leading to "race conditions" or data corruption.

Because an immutable object cannot be changed after it is created, it can be shared across dozens of threads simultaneously without any synchronization, locks, or fear of corruption.

How to create an Immutable class?

Before Java 17:

  1. Make the class final so it cannot be inherited / overridden.
  2. Make all fields private and final.
  3. Initialize all fields via a constructor performing a deep copy.
  4. Provide only getter methods. Do not provide setters.
  5. Return copy for mutable fields.

// Rule 1: Make the class final so it cannot be inherited / overridden
class final ImmutableUser {
  
  // Rule 2: Make all fields private and final
  private final String name;
  private final int age;
  private final List<String> roles; // mutable object
  
  // Rule 3: Initialize all fields via a constructor making deep copy
  public ImmutableUser(String name, int age, List<String> roles) {
    this.name = name;
    this.age = age;
    // don't do this.roles = roles;
    // otherwise the caller can modify the list from outside
    this.roles = new ArrayList<>(roles);
  }
  
  // Rule 4: All provide getter methods
  public String getName() {
    return name;
  }
  
  public int getAge() {
    return age;
  }
  
  // Return a copy for mutable objects
  public List<String> getRoles() {
    // Prevents the caller from modifying the internal list
    return new ArrayList<>(roles);
  }
  
}

After Java 17:

  1. Introduced Record to create a type of class that is shallowly immutable by default.
  2. If your record takes a mutable collection like a List, use compact constructor to clone it so the outside world can't modify your record's internal state.

record ImmutableUser(String name, int age, List<String> roles) {
    public ImmutableUser {
        roles = List.copyOf(roles);
    }
}

NOTE: Think of Record as Java's version of a “Data Transfer Object” (DTO) or “Tuple”. They are perfect for scenarios where you need a simple carrier for immutable data without writing a mountain of boilerplate code.


Strategy Pattern

Arrays.sort() uses the Strategy Pattern to allow different sorting algorithms to be used dynamically.


State Pattern


What is Exception Handling?


When to Define Custom Exceptions?

Only define a custom exception when your situation meets at least one of these three criteria:

The Standard Java Exceptions Don't Make Sense in Business Terms

If a user tries to transfer money but their account is frozen, throwing an IllegalStateException is technically accurate for the JVM, but it is completely meaningless to a business developer.

Instead, create: AccountFrozenException. It instantly communicates the business context of the failure.

You Need to Route the Error to a Specific HTTP Status Code or UI Message

If an error happens deep in your code, your Global Exception Handler needs a precise way to know what kind of response to send to the client.

If you throw a generic RuntimeException, your global handler can only guess and return a 500 Internal Server Error.

If you throw a custom UserNotFoundException, your global handler can explicitly catch it and map it to a clean 404 Not Found response.

You Need to Attach Custom Data (State) to the Error

Standard exceptions only hold a string message. If your error handler needs specific data to resolve the problem or format a rich error message, you need a custom exception to hold that data.

For example, an OrderValidationException that carries a List<ValidationError> object inside it so the frontend knows exactly which input fields failed validation.


How to Define Custom Exceptions?

In the past (the Java 7 and earlier eras), architects heavily favored checked exceptions (extends Exception) for business logic because they forced developers to explicitly acknowledge the error.

However, this led to massive, messy try-catch blocks and cluttered method signatures everywhere.

public void transfer() 
      throws InsufficientFundsException, 
              UserBlockedException, 
              LimitExceededException {
  ...
}

Modern Java design patterns lean overwhelmingly into unchecked exceptions to keep code clean and readable. The standard industry convention is to provide four specific constructors so your exception mimics the behavior of Java's built-in exceptions.

// 1. Extend RuntimeException to make it Unchecked
public class ResourceNotFoundException extends RuntimeException {
    
    // 2. Default constructor
    public ResourceNotFoundException() {
        super();
    }

    // 3. Constructor that accepts a custom error message
    public ResourceNotFoundException(String message) {
        super(message);
    }

    // 4. Constructor that chains another exception (Preserves the root cause stack trace)
    public ResourceNotFoundException(String message, Throwable cause) {
        super(message, cause);
    }

    // 5. Constructor that accepts just the root cause
    public ResourceNotFoundException(Throwable cause) {
        super(cause);
    }
}

On the flip side, some business logic failures are expected outcomes of a normal user interacting with a correct system. For example, a user checking their balance and trying to withdraw more than they have is a standard day-to-day occurrence. The system isn't broken, the user just made an invalid request.

Even though these are technically "recoverable" (the app can gracefully tell the user "Please choose a smaller amount"), modern Java still uses RuntimeException for them, but handles them differently.

Instead of relying on exceptions to drive your business logic (which is slow and creates messy stack traces), modern design principles dictate that you validate early and reserve exceptions strictly for the "last line of defense."

public class AccountService {

    public void withdraw(Wallet wallet, double amount) {
        // 1. Check a business rule explicitly (No exception thrown yet)
        if (!wallet.hasSufficientFunds(amount)) {
            // Actively handle the business logic failure smoothly
            ui.showErrorMessage("Insufficient funds. Your balance is " + wallet.getBalance());
            return; 
        }

        // 2. Proceed with the operation safely
        wallet.deduct(amount);
    }
}

If someone circumvents the validation (or a race condition occurs), your domain entity throws the unchecked exception as a hard stop:

public class Wallet {
    private double balance;

    public void deduct(double amount) {
        if (amount > balance) {
            // Last resort guardrail. If this fires, it means our Service layer validation failed.
            throw new InsufficientFundsException("Cannot deduct " + amount + " from balance " + balance);
        }
        this.balance -= amount;
    }
}


What is a Global Exception Handler?

Modern architecture treats exception handling as a cross-cutting concern. The goal is to separate your core business logic from your error-handling logic, ensuring that no matter where an error occurs, it is managed globally, logged securely, and translated into a standardized response for the client.

Many systems use the Front Controller pattern or interceptors to create a Global Exception Handler (often called ControllerAdvice in Java/Spring or Middleware in Node.js).

Instead of catching errors inside your controllers or service layers, you intentionally let them bubble up. The framework intercepts the exception right before it reaches the client and routes it to a dedicated handler.

@RestControllerAdvice
public class GlobalExceptionHandler {

    // 1. Catch specific business/validation failures
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(ResourceNotFoundException ex) {
        ErrorResponse error = new ErrorResponse("NOT_FOUND", ex.getMessage(), LocalDateTime.now());
        return new ResponseEntity<>(error, HttpStatus.NOT_FOUND);
    }

    // 2. Catch unexpected system bugs (Fallback)
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGeneralError(Exception ex) {
        // Log the actual trace internally, but hide it from the public user!
        logger.error("Internal system failure: ", ex); 
        
        ErrorResponse error = new ErrorResponse("INTERNAL_ERROR", "An unexpected error occurred.", LocalDateTime.now());
        return new ResponseEntity<>(error, HttpStatus.INTERNAL_SERVER_ERROR);
    }
}

What are Annotations?

The primary conceptual design pattern behind Java annotations is the Decorator Pattern, while their runtime processing heavily relies on the Proxy Pattern combined with the Strategy Pattern.

By design, annotations themselves are simply syntactic metadata and do not contain or execute code. They achieve functionality by altering or extending the behavior of existing code structures without directly modifying them

The Architectural Blueprint: Decorator Pattern

The Decorator Pattern focuses on dynamically adding new responsibilities to objects without using inheritance. Java annotations behave as structural decorators for your source code.

  • Visualizing the "Wrap": When you add @Transactional or @Deprecated over a class or method, you are metaphorically wrapping that component inside a layer of extra configuration.

  • Separation of Concerns: The original class handles its core business logic, while the annotation serves as a tag for an outer mechanism to decorate it with supplementary behaviors (e.g., logging, validation, transaction management).

The Runtime Mechanism: Proxy Pattern

When your code executes, annotations are realized through the Proxy Pattern.

  • The @interface Truth: In Java, declaring an annotation using @interface implicitly creates a sub-interface of java.lang.annotation.Annotation.Dynamic

  • Proxy Generation: Because you cannot instantiate an interface directly, the Java Virtual Machine (JVM) uses java.lang.reflect.Proxy at runtime to generate a dynamic proxy object behind the scenes.

  • Interpreting Elements: When you invoke methods on an annotation instance (like reading annotation.value()), the dynamic proxy routes the call through an internal InvocationHandler to fetch the configured metadata value.

The Execution Blueprint: Strategy Pattern

Annotations often work in tandem with reflection or reflection-based frameworks (like Spring or Hibernate) to implement the Strategy Pattern.

  • Interchangeable Behaviors: An annotation acts as a marker that tells a framework which strategy to execute at runtime.

  • Framework Processing: For instance, if an engine encounters @Validated, it swaps in a validation processing strategy. If it processes @RequestMapping, it targets routing algorithms. The annotation itself remains clean metadata, delegating the actual execution workflow to the underlying framework.