Low-Level Design (LLD) in Java: Complete Guide with Real-World Example

Low-Level Design (LLD) in Java: A Complete Guide with a Real-World Example

Writing Java code that works is one thing. Designing Java code that remains understandable, maintainable, testable, and extensible as an application grows is another.

Imagine building software for an EV charging station. A driver arrives, selects a charging port, connects a vehicle, starts charging, monitors the session, and eventually stops charging. The application must also validate connector compatibility, prevent two vehicles from occupying the same port, calculate charging costs, and handle unexpected failures.

At first, this might look like a simple collection of Java classes. But as soon as requirements grow, important questions appear:

  • Which class should be responsible for starting a charging session?
  • How should a charging port prevent multiple active sessions?
  • How can we support different connector types?
  • Where should electricity pricing logic live?
  • How do we prevent invalid state transitions?
  • What happens if two drivers try to reserve the same port simultaneously?
  • How can we introduce new pricing policies without rewriting existing code?

These are exactly the kinds of questions Low-Level Design helps us answer.

In this guide, we will understand LLD step by step and apply the concepts to one practical Java application: an EV Charging Station Management System.

Prerequisite: Basic knowledge of Java classes, methods, objects, interfaces, and collections is helpful. You do not need previous system design experience.


1. What Is Low-Level Design (LLD)?

Low-Level Design is the process of converting application requirements into a detailed object-oriented design that explains how individual components of a software system will work together.

It describes the internal structure of an application, including:

  • Classes and interfaces
  • Objects and their responsibilities
  • Attributes and methods
  • Relationships between classes
  • Business rules and validations
  • Object lifecycle and state transitions
  • Error handling
  • Interaction between components
  • Extensibility and maintainability

In simple words:

LLD answers the question: How should we design and implement the individual components of a software application?

For our EV charging application, LLD defines how a ChargingStation, ChargingPort, Vehicle, ChargingSession, and TariffPolicy interact with one another.

LLD is not just class creation

A common mistake is thinking that LLD means creating Java classes with fields, constructors, getters, and setters.

That is only a small part of the process.

A good design must also answer:

  • Who owns a particular responsibility?
  • Which objects are allowed to change another object's state?
  • What conditions must be true before an operation executes?
  • Can an object enter an invalid state?
  • Can two threads execute conflicting operations?
  • How will future requirements affect the existing design?

Creating classes without answering these questions can produce code that works initially but becomes difficult to maintain.

2. Why Is LLD Important?

Consider two implementations of an EV charging application.

Implementation A: One large service class handles port availability, connector validation, charging calculations, session management, and error handling.

Implementation B: Each component has a clearly defined responsibility. The charging service coordinates operations, the port protects its availability, the session manages its lifecycle, and the pricing policy calculates the cost.

Both implementations might produce the same output today. However, the second design is easier to test, extend, and maintain.

Benefits of good LLD

Benefit How It Helps in Software Development
Maintainability Makes code easier to understand, modify, debug, and maintain as requirements evolve.
Reusability Encourages reusable components that reduce duplicated logic.
Testability Allows individual components and business rules to be tested independently.
Extensibility Makes it easier to introduce new features with minimal changes to existing code.
Readability Defines clear responsibilities and relationships, helping developers understand the codebase.
Reliability Helps enforce business rules and prevent invalid object states and inconsistent behavior.

When should we think about LLD?

LLD becomes especially important when:

  • An application contains multiple business entities.
  • Business rules are becoming complex.
  • Several developers are working on the same codebase.
  • Requirements are expected to change.
  • Concurrency or object lifecycle matters.
  • Testing and maintainability are important.

3. Difference Between HLD and LLD

High-Level Design and Low-Level Design solve different parts of the design problem.

High-Level Design (HLD) Low-Level Design (LLD)
Defines the overall system architecture. Defines the internal design of components.
Focuses on services and system boundaries. Focuses on classes, objects, interfaces, and methods.
Discusses databases, messaging, gateways, and deployment. Discusses object relationships, responsibilities, and business logic.
Explains communication between services. Explains collaboration between objects.
Often includes architecture and infrastructure diagrams. Often includes UML class and sequence diagrams.

EV charging example:

HLD might describe mobile applications, station-management services, databases, payment integrations, and communication with charging hardware.

LLD focuses on how ChargingStation, ChargingPort, Vehicle, ChargingSession, ChargingService, and TariffPolicy are designed and interact.

Both are important, but this article focuses on LLD.

4. How to Approach an LLD Problem

Before writing code, follow a structured approach. Jumping directly into classes often leads to missing requirements and unnecessary complexity.

Step 1: Understand the requirements

First, understand what the application must do.

For the EV charging system, the functional requirements are:

  1. A station can contain multiple charging ports.
  2. Each port supports a particular connector type.
  3. A vehicle has a supported connector type.
  4. A driver can start charging using an available compatible port.
  5. A port cannot have multiple active sessions.
  6. The system records charging sessions.
  7. A driver can stop an active session.
  8. The system calculates the charging cost based on energy consumed.
  9. The system supports different electricity pricing policies.
  10. A failed session must not leave its port permanently occupied.

Step 2: Identify non-functional requirements

Functional requirements describe what the system does. Non-functional requirements describe qualities and operational expectations.

  • Consistency: A port must not be assigned to two active sessions.
  • Concurrency: Simultaneous requests must not create conflicting reservations.
  • Extensibility: New connector types and pricing policies should be supported.
  • Testability: Business logic should be independently testable.
  • Reliability: Failed operations must not leave inconsistent state.
  • Security: Production APIs must authenticate users and authorize station operations.
  • Auditability: Important charging and session events should be recorded.

Do not confuse these requirements with implementation decisions. For example, "prevent double booking" is a requirement. Using a synchronized method, a database transaction, or a distributed lock is an implementation choice.

Step 3: Define the scope and assumptions

Every LLD exercise needs a clear scope. Otherwise, the design can expand indefinitely.

For this educational implementation, we assume:

  • One application instance runs in a single JVM.
  • Stations and ports are configured in memory.
  • Each port supports one connector type.
  • Each vehicle has one connector type for this simplified model.
  • Energy consumption is provided when a session stops.
  • A session uses the tariff selected when it starts.
  • Charging hardware, authentication, payment collection, and persistence are outside the executable example.

These assumptions are intentional. They keep the example focused on object design without pretending that a small Java program is a complete charging platform.

Step 4: Identify the main entities

Now identify the important business concepts.

Entity Responsibility
ChargingStation Represents a station and owns its configured ports.
ChargingPort Controls port availability and active reservation.
Vehicle Represents a vehicle and its connector compatibility.
ChargingSession Tracks an individual charging operation and its lifecycle.
TariffPolicy Calculates the charging cost according to a pricing rule.
ChargingService Coordinates starting, stopping, and failing sessions.

Step 5: Assign responsibilities

Each class should have a clear reason to change.

For example, the ChargingPort should protect its own reservation state. The ChargingSession should control its lifecycle. The TariffPolicy should calculate cost. ChargingService should coordinate the workflow.

Avoid putting all business logic into ChargingService simply because it is convenient.

Step 6: Define relationships

Determine how the objects are connected:

  • One ChargingStation contains multiple ChargingPort objects.
  • A Vehicle participates in a ChargingSession.
  • A ChargingPort can be associated with one active ChargingSession.
  • A ChargingSession uses a TariffPolicy.
  • ChargingService coordinates these objects.

Step 7: Define states and business rules

Before implementing methods, decide which state transitions are valid.

For example:

  • An available port can become occupied by a charging session.
  • An occupied port cannot be reserved again.
  • An active session can be completed.
  • An active session can fail.
  • A completed or failed session cannot be stopped again.

Step 8: Design, implement, and test

Now create the UML diagram, implement the classes, and test normal and abnormal scenarios.

A useful LLD process is:

  1. Clarify requirements.
  2. Identify entities and use cases.
  3. Define responsibilities.
  4. Define relationships.
  5. Model state transitions.
  6. Choose suitable abstractions and design patterns.
  7. Implement business logic.
  8. Test edge cases and concurrent operations.
  9. Review extensibility and trade-offs.

5. EV Charging Station: Use Cases and Business Rules

Let's convert the requirements into concrete use cases.

Use case 1: Start charging

  1. The driver selects a station and port.
  2. The application identifies the vehicle.
  3. The system checks whether the port exists.
  4. The system checks connector compatibility.
  5. The system checks port availability.
  6. The system reserves the port.
  7. The system creates a charging session.
  8. The session becomes active.

Use case 2: Stop charging

  1. The driver requests to stop an active session.
  2. The system verifies that the session exists.
  3. The system verifies that the session is active.
  4. The system validates the consumed energy.
  5. The system calculates the charging cost.
  6. The session becomes completed.
  7. The port is released.

Use case 3: Charging failure

  1. The system detects a charging failure.
  2. The application identifies the active session.
  3. The session transitions to a failed state.
  4. The port reservation is released.
  5. The failure reason is recorded in production systems.

Important business rules

Rule Expected behavior
Port availability Only an available port can be reserved.
Connector compatibility A vehicle cannot use an incompatible port.
Session lifecycle Only an active session can be stopped or failed.
Energy validation Consumed energy must be greater than zero.
Pricing Charging cost must be calculated using the session's selected tariff.
Port release Only the session holding a port can release it.
Duplicate operations A completed or failed session cannot be completed again.

6. Designing the Class Structure

Now we can translate the domain into Java classes.

ChargingStation

A station contains multiple ports. It provides access to its configured ports and ensures that a port belongs to the requested station.

ChargingPort

A port knows its connector type and reservation state. It is responsible for accepting or rejecting a reservation.

It should not calculate electricity charges or manage the entire charging workflow.

Vehicle

A vehicle contains the information required for compatibility validation. In a real application, it may also contain manufacturer, model, battery capacity, and supported charging speeds.

ChargingSession

A session represents one charging operation. It stores the vehicle, port, selected tariff, start time, status, energy consumed, and final cost.

TariffPolicy

Pricing should be replaceable. A flat-rate tariff is sufficient for the first implementation, but real systems may need time-based pricing, peak-hour pricing, location-based pricing, and membership discounts.

ChargingService

The service coordinates the complete use case. It validates the requested station and port, creates sessions, and manages the interaction between the domain objects.

7. UML Class Diagram

The following simplified UML-style diagram shows the main relationships. It represents the educational model, not every infrastructure component required by a production platform.

ChargingStation ChargingPort ChargingService Vehicle ChargingSession TariffPolicy stationId, location ports portId, connectorType status, activeSessionId startCharging() stopCharging() registrationNumber connectorType sessionId, status energy, cost, startedAt calculateCost() contains coordinates uses creates manages uses
Figure 1: Simplified UML class relationship diagram for the EV charging station model.

Diagram note: The actual Java implementation below is the authoritative definition of the model. The diagram is intentionally simplified to keep the relationships readable.

8. Complete Java 17 Implementation

Let's implement a runnable version of the EV Charging Station Management System.

This implementation demonstrates:

  • Encapsulation and object responsibilities
  • Enums for controlled states
  • Interface-based pricing abstraction
  • Composition between station and ports
  • Validation of business rules
  • Session lifecycle management
  • BigDecimal for monetary calculations
  • Thread-safe port reservation inside one JVM
  • Safe release of a port by its owning session

Project setup

You only need Java 17 or later. No external library is required.

Create a file named EVChargingLLDDemo.java and paste the following complete source code.

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Instant;
import java.util.Collection;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;

public class EVChargingLLDDemo {

    public enum ConnectorType {
        CCS2, TYPE2, CHADEMO
    }

    public enum PortStatus {
        AVAILABLE, OCCUPIED
    }

    public enum SessionStatus {
        ACTIVE, COMPLETED, FAILED
    }

    public static final class Vehicle {
        private final String registrationNumber;
        private final ConnectorType connectorType;

        public Vehicle(String registrationNumber,
                       ConnectorType connectorType) {
            if (registrationNumber == null ||
                    registrationNumber.isBlank()) {
                throw new IllegalArgumentException(
                        "Registration number is required");
            }

            this.registrationNumber = registrationNumber;
            this.connectorType =
                    Objects.requireNonNull(
                            connectorType, "Connector type is required");
        }

        public String getRegistrationNumber() {
            return registrationNumber;
        }

        public ConnectorType getConnectorType() {
            return connectorType;
        }
    }

    public interface TariffPolicy {
        BigDecimal calculateCost(BigDecimal energyConsumedKwh);
    }

    public static final class FlatRateTariff
            implements TariffPolicy {

        private final BigDecimal ratePerKwh;

        public FlatRateTariff(BigDecimal ratePerKwh) {
            if (ratePerKwh == null ||
                    ratePerKwh.compareTo(BigDecimal.ZERO) <= 0) {
                throw new IllegalArgumentException(
                        "Tariff rate must be greater than zero");
            }

            this.ratePerKwh = ratePerKwh;
        }

        @Override
        public BigDecimal calculateCost(
                BigDecimal energyConsumedKwh) {

            if (energyConsumedKwh == null ||
                    energyConsumedKwh.compareTo(BigDecimal.ZERO) <= 0) {
                throw new IllegalArgumentException(
                        "Energy consumed must be greater than zero");
            }

            return ratePerKwh
                    .multiply(energyConsumedKwh)
                    .setScale(2, RoundingMode.HALF_UP);
        }
    }

    public static final class ChargingPort {

        private final String portId;
        private final ConnectorType connectorType;

        private String activeSessionId;

        public ChargingPort(String portId,
                            ConnectorType connectorType) {

            if (portId == null || portId.isBlank()) {
                throw new IllegalArgumentException(
                        "Port ID is required");
            }

            this.portId = portId;
            this.connectorType =
                    Objects.requireNonNull(
                            connectorType, "Connector type is required");
        }

        public synchronized void reserve(
                Vehicle vehicle, String sessionId) {

            Objects.requireNonNull(vehicle, "Vehicle is required");

            if (sessionId == null || sessionId.isBlank()) {
                throw new IllegalArgumentException(
                        "Session ID is required");
            }

            if (activeSessionId != null) {
                throw new IllegalStateException(
                        "Port is already occupied: " + portId);
            }

            if (vehicle.getConnectorType() != connectorType) {
                throw new IllegalArgumentException(
                        "Vehicle connector is incompatible with port");
            }

            activeSessionId = sessionId;
        }

        public synchronized void release(String sessionId) {

            if (activeSessionId == null ||
                    !activeSessionId.equals(sessionId)) {
                throw new IllegalStateException(
                        "This session does not own the port");
            }

            activeSessionId = null;
        }

        public synchronized PortStatus getStatus() {
            return activeSessionId == null
                    ? PortStatus.AVAILABLE
                    : PortStatus.OCCUPIED;
        }

        public String getPortId() {
            return portId;
        }

        public ConnectorType getConnectorType() {
            return connectorType;
        }
    }

    public static final class ChargingStation {

        private final String stationId;
        private final String location;
        private final Map<String, ChargingPort> ports;

        public ChargingStation(String stationId,
                               String location,
                               Collection<ChargingPort> ports) {

            if (stationId == null || stationId.isBlank()) {
                throw new IllegalArgumentException(
                        "Station ID is required");
            }

            if (location == null || location.isBlank()) {
                throw new IllegalArgumentException(
                        "Station location is required");
            }

            Objects.requireNonNull(ports, "Ports are required");

            this.stationId = stationId;
            this.location = location;
            this.ports = new ConcurrentHashMap<>();

            for (ChargingPort port : ports) {
                Objects.requireNonNull(port, "Port cannot be null");

                ChargingPort previous =
                        this.ports.putIfAbsent(
                                port.getPortId(), port);

                if (previous != null) {
                    throw new IllegalArgumentException(
                            "Duplicate port ID: " + port.getPortId());
                }
            }
        }

        public ChargingPort getPort(String portId) {

            ChargingPort port = ports.get(portId);

            if (port == null) {
                throw new IllegalArgumentException(
                        "Port not found: " + portId);
            }

            return port;
        }

        public String getStationId() {
            return stationId;
        }

        public String getLocation() {
            return location;
        }
    }

    public static final class ChargingSession {

        private final String sessionId;
        private final Vehicle vehicle;
        private final ChargingPort port;
        private final TariffPolicy tariffPolicy;
        private final Instant startedAt;

        private SessionStatus status;
        private BigDecimal energyConsumedKwh;
        private BigDecimal totalCost;
        private String failureReason;
        private Instant endedAt;

        public ChargingSession(String sessionId,
                               Vehicle vehicle,
                               ChargingPort port,
                               TariffPolicy tariffPolicy) {

            this.sessionId =
                    Objects.requireNonNull(sessionId, "Session ID required");

            this.vehicle =
                    Objects.requireNonNull(vehicle, "Vehicle required");

            this.port =
                    Objects.requireNonNull(port, "Port required");

            this.tariffPolicy =
                    Objects.requireNonNull(
                            tariffPolicy, "Tariff policy required");

            this.startedAt = Instant.now();
            this.status = SessionStatus.ACTIVE;
        }

        public synchronized BigDecimal stop(
                BigDecimal energyConsumedKwh) {

            ensureActive();

            if (energyConsumedKwh == null ||
                    energyConsumedKwh.compareTo(BigDecimal.ZERO) <= 0) {
                throw new IllegalArgumentException(
                        "Energy consumed must be greater than zero");
            }

            BigDecimal calculatedCost =
                    tariffPolicy.calculateCost(energyConsumedKwh);

            this.energyConsumedKwh = energyConsumedKwh;
            this.totalCost = calculatedCost;
            this.endedAt = Instant.now();
            this.status = SessionStatus.COMPLETED;

            return totalCost;
        }

        public synchronized void fail(String reason) {

            ensureActive();

            if (reason == null || reason.isBlank()) {
                throw new IllegalArgumentException(
                        "Failure reason is required");
            }

            this.failureReason = reason;
            this.endedAt = Instant.now();
            this.status = SessionStatus.FAILED;
        }

        private void ensureActive() {
            if (status != SessionStatus.ACTIVE) {
                throw new IllegalStateException(
                        "Session is not active: " + sessionId);
            }
        }

        public String getSessionId() {
            return sessionId;
        }

        public synchronized SessionStatus getStatus() {
            return status;
        }

        public synchronized BigDecimal getTotalCost() {
            return totalCost;
        }

        public synchronized BigDecimal getEnergyConsumedKwh() {
            return energyConsumedKwh;
        }

        public synchronized String getFailureReason() {
            return failureReason;
        }

        public Instant getStartedAt() {
            return startedAt;
        }

        public synchronized Instant getEndedAt() {
            return endedAt;
        }

        public Vehicle getVehicle() {
            return vehicle;
        }

        public ChargingPort getPort() {
            return port;
        }
    }

    public static final class ChargingService {

        private final Map<String, ChargingSession> sessions =
                new ConcurrentHashMap<>();

        private final TariffPolicy tariffPolicy;

        public ChargingService(TariffPolicy tariffPolicy) {
            this.tariffPolicy =
                    Objects.requireNonNull(
                            tariffPolicy, "Tariff policy required");
        }

        public ChargingSession startCharging(
                ChargingStation station,
                String portId,
                Vehicle vehicle) {

            Objects.requireNonNull(station, "Station is required");
            Objects.requireNonNull(vehicle, "Vehicle is required");

            ChargingPort port = station.getPort(portId);

            String sessionId = UUID.randomUUID().toString();

            // Reserve first. If reservation fails, no session is created.
            port.reserve(vehicle, sessionId);

            try {
                ChargingSession session =
                        new ChargingSession(
                                sessionId,
                                vehicle,
                                port,
                                tariffPolicy);

                sessions.put(sessionId, session);

                return session;

            } catch (RuntimeException exception) {
                // Roll back the reservation if session creation fails.
                port.release(sessionId);
                throw exception;
            }
        }

        public BigDecimal stopCharging(
                String sessionId,
                BigDecimal energyConsumedKwh) {

            ChargingSession session = getSession(sessionId);

            BigDecimal cost =
                    session.stop(energyConsumedKwh);

            session.getPort().release(sessionId);

            return cost;
        }

        public void failCharging(
                String sessionId,
                String reason) {

            ChargingSession session = getSession(sessionId);

            session.fail(reason);

            session.getPort().release(sessionId);
        }

        public ChargingSession getSession(String sessionId) {

            if (sessionId == null || sessionId.isBlank()) {
                throw new IllegalArgumentException(
                        "Session ID is required");
            }

            ChargingSession session = sessions.get(sessionId);

            if (session == null) {
                throw new IllegalArgumentException(
                        "Session not found: " + sessionId);
            }

            return session;
        }
    }

    public static void main(String[] args) {

        ChargingPort port1 =
                new ChargingPort("PORT-101", ConnectorType.CCS2);

        ChargingPort port2 =
                new ChargingPort("PORT-102", ConnectorType.TYPE2);

        ChargingStation station =
                new ChargingStation(
                        "STATION-01",
                        "Noida Sector 62",
                        java.util.List.of(port1, port2));

        Vehicle vehicle =
                new Vehicle(
                        "UP16AB1234",
                        ConnectorType.CCS2);

        TariffPolicy tariff =
                new FlatRateTariff(new BigDecimal("8.50"));

        ChargingService chargingService =
                new ChargingService(tariff);

        ChargingSession session =
                chargingService.startCharging(
                        station,
                        "PORT-101",
                        vehicle);

        System.out.println("Session started: "
                + session.getSessionId());

        System.out.println("Session status: "
                + session.getStatus());

        BigDecimal cost =
                chargingService.stopCharging(
                        session.getSessionId(),
                        new BigDecimal("25.50"));

        System.out.println("Energy consumed: "
                + session.getEnergyConsumedKwh() + " kWh");

        System.out.println("Total cost: Rs. " + cost);

        System.out.println("Session status: "
                + session.getStatus());

        System.out.println("Port status: "
                + port1.getStatus());
    }
}

Expected output

Session started: [generated UUID]
Session status: ACTIVE
Energy consumed: 25.50 kWh
Total cost: Rs. 216.75
Session status: COMPLETED
Port status: AVAILABLE

The session ID is randomly generated, so its value will be different every time the program runs.

How to execute

javac EVChargingLLDDemo.java
java EVChargingLLDDemo

9. Understanding the Important Design Decisions

Writing the code is only part of LLD. We must also understand why it was designed this way.

9.1 Why use enums?

ConnectorType, PortStatus, and SessionStatus have a limited set of valid values.

Using strings such as "available", "occupied", or "charging" throughout the application can introduce spelling mistakes and invalid states.

Enums provide type safety and make the allowed values explicit.

9.2 Why use an interface for TariffPolicy?

The charging service should not depend directly on a particular pricing algorithm.

Today we use a flat rate. Tomorrow the business may introduce peak-hour pricing.

We can add another implementation:

public class PeakHourTariff implements TariffPolicy {

    private final BigDecimal normalRate;
    private final BigDecimal peakRate;

    public PeakHourTariff(
            BigDecimal normalRate,
            BigDecimal peakRate) {
        this.normalRate = normalRate;
        this.peakRate = peakRate;
    }

    @Override
    public BigDecimal calculateCost(BigDecimal energy) {
        // A real implementation would need time-based
        // energy breakdowns to calculate peak and normal usage.
        return energy.multiply(normalRate)
                .setScale(2, RoundingMode.HALF_UP);
    }
}

This is an illustrative starting point, not a complete peak-hour pricing implementation. Real time-of-use billing needs the energy consumed in each tariff period, rather than applying one rate to the entire session.

The important design decision is that ChargingService depends on the TariffPolicy abstraction, not a concrete pricing class.

9.3 Why use BigDecimal instead of double?

Money should not normally be calculated using binary floating-point types such as float or double.

Floating-point representation can introduce precision errors in decimal calculations.

For monetary calculations, BigDecimal gives us explicit decimal arithmetic and rounding control.

9.4 Why is ChargingPort responsible for reservation?

The port owns its reservation state. It should be responsible for protecting that state from invalid operations.

The reserve() method checks both availability and connector compatibility.

This avoids allowing unrelated classes to directly change the port's internal state.

9.5 Why is release tied to the session ID?

Imagine a bug where one session accidentally releases a port reserved by another session.

By checking the active session ID, the port ensures that only the session that owns the reservation can release it.

This is an example of protecting an important domain invariant.

9.6 Why use synchronized?

Suppose two threads try to reserve PORT-101 at nearly the same time.

Without synchronization, both threads could read the port as available before either one updates its state.

The synchronized reservation method ensures that only one thread at a time can execute the reservation logic on that port object.

Important limitation: synchronized protects only threads sharing the same Java object inside the same JVM. It does not prevent two application instances from reserving the same database record.

For a distributed production application, reservation should be protected using an atomic database update, transaction, unique constraint, or another appropriate distributed coordination mechanism.

10. Applying OOP Principles in LLD

Object-Oriented Programming provides the building blocks used in many LLD problems.

Encapsulation

Encapsulation means keeping internal state protected and exposing controlled operations.

In our example, ChargingPort does not expose a public setter for its active session. Other objects must use reserve() and release().

Abstraction

Abstraction exposes what an object can do while hiding unnecessary implementation details.

TariffPolicy exposes the cost calculation operation without forcing ChargingService to know how each pricing algorithm works.

Inheritance

Inheritance allows one class to reuse or specialize behavior from another class.

It should be used when there is a genuine is-a relationship. Do not introduce inheritance just to avoid writing a few repeated lines.

Polymorphism

Polymorphism allows different implementations to be used through a common abstraction.

For example, FlatRateTariff and a future TimeOfUseTariff can both implement TariffPolicy.

11. SOLID Principles in the EV Charging Design

Single Responsibility Principle (SRP)

A class should have one primary responsibility and one main reason to change.

  • ChargingPort protects port reservation.
  • ChargingSession manages session state.
  • FlatRateTariff calculates flat-rate cost.
  • ChargingService coordinates the use case.

Open/Closed Principle (OCP)

Software entities should be open for extension but closed for unnecessary modification.

We can add another TariffPolicy implementation without changing the ChargingService's pricing logic.

Liskov Substitution Principle (LSP)

Implementations of an abstraction should be usable wherever that abstraction is expected without breaking its contract.

Every TariffPolicy implementation must accept valid energy values and return a valid cost according to its documented pricing rules.

Interface Segregation Principle (ISP)

Clients should not be forced to depend on methods they do not need.

For a larger system, we might separate read-only station queries from session-management operations rather than exposing one oversized interface to every client.

Dependency Inversion Principle (DIP)

High-level business logic should depend on abstractions rather than concrete implementation details.

ChargingService depends on TariffPolicy instead of directly creating a FlatRateTariff.

12. Design Patterns Used and Possible Extensions

Design patterns are reusable approaches to common design problems. They should be applied when they solve an actual problem, not added simply to make an LLD diagram look impressive.

Pattern Possible use
Strategy Different tariff calculation algorithms.
Factory Creating different types of charging connectors or session objects.
State Managing complex charging session transitions.
Observer Notifying monitoring systems when a session starts, stops, or fails.
Repository Separating persistence from domain and application logic.

Strategy Pattern

Our TariffPolicy abstraction is an example of the Strategy Pattern. Different algorithms can be supplied through the same interface.

State Pattern

Our example uses an enum and explicit validation for session states. That is sufficient for a small lifecycle.

If the lifecycle becomes more complex—for example, CREATED, AUTHORIZED, CONNECTING, CHARGING, STOPPING, COMPLETED, FAILED, and RECOVERING—the State Pattern may become useful.

Observer Pattern

When a session changes state, production systems may need to notify several independent components:

  • Monitoring service
  • Notification service
  • Billing service
  • Audit service

An event-based design can prevent the charging domain from directly depending on every notification or monitoring component.

13. Important Edge Cases and Failure Scenarios

A design that only handles the happy path is incomplete. Let's examine the scenarios that must be considered.

Scenario Expected behavior
Unknown station Reject the request with a clear not-found error.
Unknown port Reject the request.
Null vehicle Reject the request before creating a session.
Incompatible connector Reject the reservation.
Port already occupied Reject the second reservation.
Duplicate port ID Reject invalid station configuration.
Invalid tariff Reject zero, negative, or missing rates.
Zero energy Reject session completion.
Negative energy Reject session completion.
Unknown session Reject the stop or failure request.
Stop completed session Reject the repeated operation.
Fail completed session Reject the invalid state transition.
Release by another session Reject the release request.
Concurrent reservation Only one reservation should succeed for a port.

A subtle failure-handling issue

In the example, session state changes to COMPLETED before the port is released. The release operation is designed to succeed because the session ID is checked against the port's reservation.

However, a production system must consider failures between these operations. If state is persisted in a database, changing the session and releasing the port should be coordinated using an appropriate transaction or recoverable workflow.

Similarly, hardware communication can fail after the database records a state change. Production systems need explicit recovery rules, idempotent commands, and reconciliation with the charging device.

14. How Would We Improve This Design for Production?

The Java example is deliberately an in-memory domain demonstration. It is not a complete EV charging platform.

A production system needs additional components and safeguards.

14.1 Database persistence

Stations, ports, vehicles, sessions, tariffs, and charging events must be persisted.

A simplified relational model might include:

Table Example columns
charging_station station_id, name, location, status
charging_port port_id, station_id, connector_type, status
vehicle vehicle_id, registration_number, connector_type
charging_session session_id, port_id, vehicle_id, status, started_at, ended_at
tariff tariff_id, pricing_type, effective_from, effective_to
charging_meter_reading reading_id, session_id, energy_kwh, recorded_at

The database schema should enforce appropriate foreign keys, uniqueness, and integrity constraints. The exact design depends on the database and business requirements.

14.2 Distributed concurrency

In a multi-instance deployment, Java synchronization is insufficient.

A reservation can use an atomic conditional update such as:

UPDATE charging_port
SET status = 'OCCUPIED'
WHERE port_id = ?
  AND status = 'AVAILABLE';

The application must check the affected row count. Exactly one successful update means the reservation was acquired. Zero affected rows means the port was not available.

The reservation and session creation should be coordinated within a database transaction where possible.

14.3 Hardware integration

A real charging station communicates with charging equipment. The application must handle connection status, meter readings, device errors, remote start/stop commands, and communication timeouts.

The hardware integration should be isolated behind a suitable interface so that the domain model does not depend on a particular hardware vendor.

14.4 Idempotency

Networks fail. A driver may retry a request because the previous response was lost.

Without idempotency, a repeated start or stop request may create duplicate operations.

Production APIs should use request identifiers or idempotency keys and define how duplicate requests are handled.

14.5 Session recovery

What happens if an application instance restarts while a vehicle is charging?

The system must recover active sessions from persistent data and reconcile them with the charging hardware. In-memory maps alone cannot provide this behavior.

14.6 Billing accuracy

Real charging costs may depend on meter readings, tariff time windows, taxes, service fees, discounts, and rounding rules.

Billing should use validated meter data and retain enough information to explain how a final amount was calculated.

14.7 Security and authorization

Production APIs must validate the identity of the driver or operator and verify permissions before allowing sensitive actions.

For example, a driver should not be able to stop another driver's session simply by guessing a session ID.

14.8 Observability and audit

Record important events such as session creation, reservation failure, charging failure, meter readings, session completion, and billing outcomes.

Logs, metrics, traces, and audit records help diagnose operational issues.

15. Testing the LLD

Testing should verify business rules, not just whether methods return values.

For the EV charging system, create tests for:

  • Creating a station with multiple valid ports.
  • Rejecting duplicate port IDs.
  • Starting a session with a compatible vehicle.
  • Rejecting an incompatible connector.
  • Rejecting a second reservation on an occupied port.
  • Calculating the correct charging cost.
  • Rejecting zero and negative energy.
  • Releasing a port after a successful stop.
  • Releasing a port after a failure.
  • Rejecting repeated stop and failure operations.
  • Ensuring that a session cannot release another session's port.
  • Testing concurrent attempts to reserve one port.

Example test scenario

Given:

  • A port supports CCS2.
  • Vehicle A supports CCS2.
  • Vehicle B supports TYPE2.

When Vehicle A reserves the port, the operation should succeed. When Vehicle B tries to reserve the same port, the operation should fail because the port is already occupied.

Even if Vehicle A completes its session, Vehicle B must still be rejected if the port's connector type is incompatible.

This test checks both reservation state and connector compatibility.

16. Common LLD Mistakes to Avoid

  1. Starting with code: Understand requirements and business rules first.
  2. Creating one large service: Give domain objects meaningful responsibilities.
  3. Using unnecessary inheritance: Prefer composition when it represents the relationship more accurately.
  4. Exposing mutable state: Protect important fields behind controlled methods.
  5. Ignoring invalid states: Define legal transitions before implementing operations.
  6. Ignoring concurrency: Identify shared resources and protect their invariants.
  7. Overusing design patterns: Apply patterns when they solve a real problem.
  8. Mixing persistence with business logic: Keep domain rules independent of database details where practical.
  9. Using floating-point types for money: Use appropriate decimal arithmetic.
  10. Ignoring failure recovery: Consider partial failures, retries, and application restarts.
  11. Overengineering: Do not introduce abstractions without a meaningful reason.
  12. Confusing LLD with HLD: Keep the focus on component responsibilities and interactions.

17. LLD Interview Questions and Answers

Q1. What is Low-Level Design?

Answer: LLD is the detailed design of software components. It defines classes, interfaces, object relationships, methods, business rules, and interactions required to implement a system.

Q2. What is the difference between HLD and LLD?

Answer: HLD describes the overall system architecture and major components. LLD describes the internal structure and behavior of those components.

Q3. How do you identify classes in an LLD problem?

Answer: Start with the requirements and use cases. Identify important domain concepts, their responsibilities, their state, and their relationships. Avoid converting every noun into a class automatically.

Q4. What is the Single Responsibility Principle?

Answer: A class should have one primary responsibility and one main reason to change. For example, charging cost calculation should not be mixed with port reservation logic.

Q5. What is the difference between composition and inheritance?

Answer: Inheritance represents an is-a relationship. Composition represents a has-a relationship. A station has ports, so composition is appropriate. A station is not a port, so inheritance would be incorrect.

Q6. Why should we prefer interfaces in LLD?

Answer: Interfaces define contracts and allow different implementations to be used through the same abstraction. This reduces unnecessary coupling and improves testability.

Q7. How do you prevent two threads from reserving the same port?

Answer: In a single JVM, synchronization or suitable atomic operations can protect the reservation. In a distributed system, use database transactions, atomic conditional updates, uniqueness constraints, or another reliable coordination strategy.

Q8. Is synchronized sufficient for distributed applications?

Answer: No. It only coordinates threads sharing the same object inside one JVM. Multiple application instances require distributed consistency mechanisms.

Q9. Why use BigDecimal for money?

Answer: BigDecimal provides decimal arithmetic with explicit precision and rounding behavior. Binary floating-point types can produce unexpected decimal representation errors.

Q10. What is the Strategy Pattern?

Answer: It defines a family of algorithms behind a common interface, allowing the application to select or replace an algorithm without tightly coupling the caller to a particular implementation. TariffPolicy demonstrates this idea.

Q11. When would you use the State Pattern?

Answer: Use it when an object's behavior changes significantly depending on its state and the number of transitions becomes difficult to manage with conditional statements.

Q12. How do you handle failures during a charging session?

Answer: Define explicit failure states, record failure information, release or reconcile the port reservation, and ensure the operation is safe to retry. Production implementations also need persistence and hardware reconciliation.

Q13. What is idempotency?

Answer: An operation is idempotent when repeating the same request does not create additional unintended effects. It is particularly important for APIs that may be retried after network failures.

Q14. Why should domain logic be separated from controllers?

Answer: Controllers should handle transport concerns. Domain logic should remain reusable and testable without depending on HTTP request and response objects.

Q15. How do you decide whether to introduce a design pattern?

Answer: Identify the problem first. Introduce a pattern when it reduces coupling, simplifies changing behavior, or clarifies responsibilities. Avoid patterns that add complexity without solving a real requirement.

18. Practice Exercises

Try extending the same EV charging application. These exercises help develop actual design skills.

Beginner level

  1. Add a method to retrieve all ports of a station.
  2. Add a vehicle model and manufacturer.
  3. Introduce a maximum charging capacity for each port.
  4. Reject a vehicle if its supported charging capacity exceeds a defined limit.

Intermediate level

  1. Implement a time-of-use tariff.
  2. Introduce a membership discount policy.
  3. Store charging sessions using a repository abstraction.
  4. Add session search by vehicle registration number.
  5. Introduce a State Pattern for session lifecycle management.

Advanced level

  1. Implement database-level atomic port reservation.
  2. Handle simultaneous requests from multiple application instances.
  3. Introduce idempotency keys for start and stop APIs.
  4. Design recovery for sessions interrupted by application restarts.
  5. Integrate a charging hardware adapter.
  6. Use domain events to notify billing and monitoring components.
  7. Design an audit trail for every important session transition.

19. A Practical LLD Learning Roadmap

If you are preparing for Java interviews or want to improve your software design skills, follow this learning sequence.

  1. Core Java: Classes, objects, interfaces, abstract classes, collections, generics, exceptions, and concurrency.
  2. OOP: Encapsulation, abstraction, inheritance, and polymorphism.
  3. Relationships: Association, aggregation, and composition.
  4. SOLID: Understand each principle and recognize when it applies.
  5. UML: Class diagrams, sequence diagrams, and state diagrams.
  6. Design Patterns: Strategy, Factory, Builder, Observer, State, Adapter, and Decorator.
  7. LLD Practice: Start with requirements, model entities, assign responsibilities, and implement business rules.
  8. Advanced Topics: Concurrency, transactions, idempotency, failure handling, and persistence.
  9. Testing: Unit tests, integration tests, and concurrency tests.

Do not memorize class diagrams. Practice explaining why a class exists, why it owns a particular responsibility, and what would happen if a requirement changes.

20. Final Thoughts

Low-Level Design is not about writing the maximum number of classes or using every design pattern you know. It is about making thoughtful decisions that keep software understandable and adaptable as requirements evolve.

Our EV Charging Station example demonstrates how a seemingly simple workflow involves multiple domain objects, business rules, state transitions, pricing decisions, and concurrency concerns.

The most important habit is to think beyond the happy path. Ask what happens when a port is already occupied, a connector is incompatible, a session fails, a request is repeated, or two users perform an operation simultaneously.

When you can explain these decisions clearly and translate them into clean, testable Java code, you are developing practical LLD skills.

Remember: Good LLD is not measured by how complicated the design looks. It is measured by how well the design solves the actual problem without introducing unnecessary complexity.