Showing posts with label Core Java. Show all posts
Showing posts with label Core Java. Show all posts

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.

Visitor Design Pattern in Java with Real-World E-Commerce Example

Visitor Design Pattern in Java: Complete Guide with Real-World E-Commerce Example

Imagine you are developing an e-commerce application. An order can contain physical products, digital products, and shipping charges.

Initially, your application only needs to calculate the order total. Later, the business team asks you to generate invoices. After that, they need tax reports, product summaries, and export reports.

One common approach is to keep adding new methods to every product class. As the application grows, these classes become responsible for too many operations.

The Visitor Design Pattern in Java solves this problem. Instead of placing every new operation inside your existing classes, you can create separate visitor classes. Each visitor performs a particular operation on the objects it visits.

In this guide, we will understand the pattern from the basics and implement it using a practical e-commerce order example with complete, runnable Java 8 code, original diagrams, double dispatch explained, a Spring Boot use case, and interview questions.

1. What Is the Visitor Design Pattern?

The Visitor pattern is a behavioral design pattern that lets you add new operations to a group of existing object types without modifying those classes every time a new operation is required.

In simple words, Visitor separates the operations from the objects on which those operations are performed.

For example, consider these classes in an e-commerce application:

  • PhysicalProduct
  • DigitalProduct
  • ShippingCharge

Now imagine that you need three different operations:

  • Generate an invoice.
  • Calculate tax.
  • Generate an order summary.

Without Visitor, you may add these operations to every product class. With Visitor, each operation can be implemented in a separate visitor class.

In simple words: Visitor lets you add operations to an object structure without changing the element classes.

Key participants

  • Element: Defines an accept() method that accepts a visitor.
  • Concrete Element: Represents the actual objects the visitor can visit.
  • Visitor: Declares an operation for each concrete element type.
  • Concrete Visitor: Implements the operations for different element types.
  • Object Structure: Holds or provides access to the elements being visited.
  • Client: Creates visitors and passes them to the object structure.

2. Real-World Example: E-Commerce Order Processing

Suppose a customer places an order containing a laptop (physical product), an online Java course (digital product), and a shipping charge (delivery service).

All these objects belong to an order, but they have different characteristics. A physical product has a quantity and may require delivery. A digital product is delivered electronically. A shipping charge represents a service fee.

Now the business wants to perform different operations on these objects.

Object Invoice Tax Report Order Summary
Physical Product Product price × quantity Calculate applicable tax Show product details
Digital Product Product price × quantity Calculate applicable tax Show digital item details
Shipping Charge Shipping amount Calculate applicable tax Show delivery charge

Notice something important: the objects remain the same, but the operations are different. That is exactly the problem the Visitor Design Pattern helps solve.

3. How the Visitor Pattern Works

The Visitor pattern generally consists of the following components:

  1. Element: Defines an accept() method that accepts a visitor.
  2. Concrete Element: Represents actual objects that the visitor can visit.
  3. Visitor: Declares an operation for each concrete element type.
  4. Concrete Visitor: Implements the operations for different element types.
  5. Object Structure: Holds or provides access to the elements being visited.

The important point is that the elements do not implement the operation themselves. They delegate to the appropriate visit() method on the visitor that has been passed in.

What changes after introducing a visitor?

The element classes stop accumulating operation-specific methods. New operations become new visitor classes, and the element classes stay focused on their data and accept behavior.

4. Visitor Pattern Class Diagram

This original diagram shows how the e-commerce classes interact with the Visitor pattern.

Visitor Design Pattern UML Class Diagram OrderItem defines accept(visitor). PhysicalProduct, DigitalProduct, and ShippingCharge implement OrderItem and call the matching visit() method. OrderVisitor declares visit() methods for each element type. InvoiceVisitor, TaxReportVisitor, and OrderSummaryVisitor implement OrderVisitor with their own operation logic. OrderItem + accept(visitor) Element interface PhysicalProduct name, price, quantity accept(visitor) DigitalProduct name, price, quantity accept(visitor) ShippingCharge description, amount accept(visitor) OrderVisitor visit(PhysicalProduct) visit(DigitalProduct), visit(ShippingCharge) InvoiceVisitor Generates invoice for each item type TaxReportVisitor Calculates tax for each item type OrderSummaryVisitor Generates order summary Original conceptual diagram created for this tutorial
Figure: Elements expose accept(), visitors declare visit() per element type, and concrete visitors implement each operation.

Each concrete element implements the accept() method. When a visitor is passed to an element, the element calls the matching visit() method.

5. Understanding Double Dispatch

One of the most important concepts behind the Visitor Design Pattern is double dispatch.

In normal method overriding, Java selects the overridden method based primarily on the runtime type of the object receiving the method call.

Visitor uses two object types to select the operation:

  • The actual element type, such as PhysicalProduct or DigitalProduct.
  • The visitor type, such as InvoiceVisitor or TaxReportVisitor.

Consider this code:

OrderItem item = new PhysicalProduct("Laptop", 50000, 1);
OrderVisitor visitor = new InvoiceVisitor();

item.accept(visitor);

The actual object is PhysicalProduct. Its accept() method calls:

visitor.visit(this);

Because this refers to a PhysicalProduct, Java selects:

visit(PhysicalProduct product)

That is how Visitor achieves double dispatch using Java's method overloading and runtime method dispatch.

6. Complete Running Java Example

Let's build the complete example. We will create an e-commerce order containing physical products, digital products, and shipping charges.

We will then perform three operations using three different visitors:

  • InvoiceVisitor — generates the invoice.
  • TaxReportVisitor — calculates an illustrative tax report.
  • OrderSummaryVisitor — generates an order summary.

Project structure

For simplicity, all classes are placed in a single Java file. This makes the example easy to copy and run.

VisitorPatternDemo.java

Complete Java code

import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

// 1. Element interface
interface OrderItem {
    void accept(OrderVisitor visitor);
}

// 2. Visitor interface
interface OrderVisitor {
    void visit(PhysicalProduct product);

    void visit(DigitalProduct product);

    void visit(ShippingCharge shippingCharge);
}

// 3. Concrete element: Physical product
class PhysicalProduct implements OrderItem {

    private final String name;
    private final BigDecimal price;
    private final int quantity;

    public PhysicalProduct(String name, BigDecimal price, int quantity) {
        this.name = name;
        this.price = price;
        this.quantity = quantity;
    }

    public String getName() {
        return name;
    }

    public BigDecimal getPrice() {
        return price;
    }

    public int getQuantity() {
        return quantity;
    }

    public BigDecimal getTotal() {
        return price.multiply(BigDecimal.valueOf(quantity));
    }

    @Override
    public void accept(OrderVisitor visitor) {
        visitor.visit(this);
    }
}

// 4. Concrete element: Digital product
class DigitalProduct implements OrderItem {

    private final String name;
    private final BigDecimal price;
    private final int quantity;

    public DigitalProduct(String name, BigDecimal price, int quantity) {
        this.name = name;
        this.price = price;
        this.quantity = quantity;
    }

    public String getName() {
        return name;
    }

    public BigDecimal getPrice() {
        return price;
    }

    public int getQuantity() {
        return quantity;
    }

    public BigDecimal getTotal() {
        return price.multiply(BigDecimal.valueOf(quantity));
    }

    @Override
    public void accept(OrderVisitor visitor) {
        visitor.visit(this);
    }
}

// 5. Concrete element: Shipping charge
class ShippingCharge implements OrderItem {

    private final String description;
    private final BigDecimal amount;

    public ShippingCharge(String description, BigDecimal amount) {
        this.description = description;
        this.amount = amount;
    }

    public String getDescription() {
        return description;
    }

    public BigDecimal getAmount() {
        return amount;
    }

    @Override
    public void accept(OrderVisitor visitor) {
        visitor.visit(this);
    }
}

// 6. Object structure
class Order {

    private final List<OrderItem> items;

    public Order(List<OrderItem> items) {
        this.items = new ArrayList<OrderItem>(items);
    }

    public void accept(OrderVisitor visitor) {
        for (OrderItem item : items) {
            item.accept(visitor);
        }
    }
}

// 7. Concrete visitor: Invoice generation
class InvoiceVisitor implements OrderVisitor {

    private BigDecimal total = BigDecimal.ZERO;

    @Override
    public void visit(PhysicalProduct product) {

        BigDecimal amount = product.getTotal();
        total = total.add(amount);

        System.out.println(
            "Physical Product: " + product.getName()
            + " | Amount: " + amount
        );
    }

    @Override
    public void visit(DigitalProduct product) {

        BigDecimal amount = product.getTotal();
        total = total.add(amount);

        System.out.println(
            "Digital Product: " + product.getName()
            + " | Amount: " + amount
        );
    }

    @Override
    public void visit(ShippingCharge shippingCharge) {

        BigDecimal amount = shippingCharge.getAmount();
        total = total.add(amount);

        System.out.println(
            "Shipping: " + shippingCharge.getDescription()
            + " | Amount: " + amount
        );
    }

    public BigDecimal getTotal() {
        return total;
    }
}

// 8. Concrete visitor: Tax calculation
class TaxReportVisitor implements OrderVisitor {

    private BigDecimal totalTax = BigDecimal.ZERO;

    // Demonstration rates only.
    // Actual tax rates depend on applicable laws and product classification.
    private static final BigDecimal PRODUCT_TAX_RATE =
        new BigDecimal("0.18");

    private static final BigDecimal SHIPPING_TAX_RATE =
        new BigDecimal("0.05");

    @Override
    public void visit(PhysicalProduct product) {

        calculateTax(
            product.getName(),
            product.getTotal(),
            PRODUCT_TAX_RATE
        );
    }

    @Override
    public void visit(DigitalProduct product) {

        calculateTax(
            product.getName(),
            product.getTotal(),
            PRODUCT_TAX_RATE
        );
    }

    @Override
    public void visit(ShippingCharge shippingCharge) {

        calculateTax(
            shippingCharge.getDescription(),
            shippingCharge.getAmount(),
            SHIPPING_TAX_RATE
        );
    }

    private void calculateTax(
        String itemName,
        BigDecimal amount,
        BigDecimal taxRate
    ) {

        BigDecimal tax = amount.multiply(taxRate);
        totalTax = totalTax.add(tax);

        System.out.println(
            itemName + " | Tax: " + tax
        );
    }

    public BigDecimal getTotalTax() {
        return totalTax;
    }
}

// 9. Concrete visitor: Order summary
class OrderSummaryVisitor implements OrderVisitor {

    private int physicalProductCount;
    private int digitalProductCount;
    private int shippingChargeCount;

    @Override
    public void visit(PhysicalProduct product) {

        physicalProductCount++;

        System.out.println(
            "Physical: " + product.getName()
            + " | Quantity: " + product.getQuantity()
        );
    }

    @Override
    public void visit(DigitalProduct product) {

        digitalProductCount++;

        System.out.println(
            "Digital: " + product.getName()
            + " | Quantity: " + product.getQuantity()
        );
    }

    @Override
    public void visit(ShippingCharge shippingCharge) {

        shippingChargeCount++;

        System.out.println(
            "Service: " + shippingCharge.getDescription()
        );
    }

    public void printCounts() {

        System.out.println(
            "Physical product entries: " + physicalProductCount
        );

        System.out.println(
            "Digital product entries: " + digitalProductCount
        );

        System.out.println(
            "Shipping entries: " + shippingChargeCount
        );
    }
}

// 10. Main class
public class VisitorPatternDemo {

    public static void main(String[] args) {

        // Create order items
        PhysicalProduct laptop = new PhysicalProduct(
            "Laptop",
            new BigDecimal("50000.00"),
            1
        );

        DigitalProduct javaCourse = new DigitalProduct(
            "Java Course",
            new BigDecimal("2000.00"),
            1
        );

        ShippingCharge shipping = new ShippingCharge(
            "Standard Delivery",
            new BigDecimal("500.00")
        );

        // Create order
        Order order = new Order(
            Arrays.<OrderItem>asList(
                laptop,
                javaCourse,
                shipping
            )
        );

        // Operation 1: Generate invoice
        System.out.println("===== INVOICE =====");

        InvoiceVisitor invoiceVisitor = new InvoiceVisitor();
        order.accept(invoiceVisitor);

        System.out.println(
            "Invoice Total: " + invoiceVisitor.getTotal()
        );

        // Operation 2: Calculate tax
        System.out.println();
        System.out.println("===== TAX REPORT =====");

        TaxReportVisitor taxVisitor = new TaxReportVisitor();
        order.accept(taxVisitor);

        System.out.println(
            "Total Tax: " + taxVisitor.getTotalTax()
        );

        // Operation 3: Generate order summary
        System.out.println();
        System.out.println("===== ORDER SUMMARY =====");

        OrderSummaryVisitor summaryVisitor =
            new OrderSummaryVisitor();

        order.accept(summaryVisitor);
        summaryVisitor.printCounts();
    }
}

How to run the program

Step 1: Install JDK 8 or above.

Step 2: Create a file named VisitorPatternDemo.java.

Step 3: Copy the complete code into the file.

Step 4: Open a terminal in the file's directory and compile it:

javac VisitorPatternDemo.java

Step 5: Run the program:

java VisitorPatternDemo

7. Expected Output

===== INVOICE =====
Physical Product: Laptop | Amount: 50000.00
Digital Product: Java Course | Amount: 2000.00
Shipping: Standard Delivery | Amount: 500.00
Invoice Total: 52500.00

===== TAX REPORT =====
Laptop | Tax: 9000.0000
Java Course | Tax: 360.0000
Standard Delivery | Tax: 25.0000
Total Tax: 9385.0000

===== ORDER SUMMARY =====
Physical: Laptop | Quantity: 1
Digital: Java Course | Quantity: 1
Service: Standard Delivery
Physical product entries: 1
Digital product entries: 1
Shipping entries: 1

Note: The tax percentages in this program are illustrative examples to demonstrate the Visitor pattern. They are not a statement of actual GST rates or tax treatment. A production billing system must use the applicable tax rules and classifications.

How the responsibilities are separated

Component Responsibility
OrderItem Element interface exposing accept(visitor).
PhysicalProduct / DigitalProduct / ShippingCharge Concrete elements that hold data and dispatch to the correct visitor method.
OrderVisitor Declares one visit() method per element type.
InvoiceVisitor / TaxReportVisitor / OrderSummaryVisitor Concrete visitors that implement specific operations.
Order Object structure that iterates its items and passes the visitor along.
VisitorPatternDemo Client that builds the order and runs each visitor.

8. Understanding the Code Step by Step

Step 1: OrderItem interface

interface OrderItem {
    void accept(OrderVisitor visitor);
}

This is the Element interface. Every item in the order implements this interface. The accept() method allows a visitor to perform an operation on the current object.

Step 2: OrderVisitor interface

interface OrderVisitor {
    void visit(PhysicalProduct product);
    void visit(DigitalProduct product);
    void visit(ShippingCharge shippingCharge);
}

This interface defines one visit() method for each supported element type. When you introduce a new visitor, it must implement the operations for all these element types.

Step 3: Concrete elements

PhysicalProduct, DigitalProduct, and ShippingCharge implement OrderItem. Each class implements accept() by calling visitor.visit(this).

This is an important part of the pattern because the actual element type determines which overloaded visit() method is selected.

Step 4: Concrete visitors

InvoiceVisitor, TaxReportVisitor, and OrderSummaryVisitor each implement a different business operation. Notice that we did not add invoice, tax, or summary methods to the product classes. We added new visitor classes instead.

Step 5: Order object structure

public void accept(OrderVisitor visitor) {
    for (OrderItem item : items) {
        item.accept(visitor);
    }
}

The Order class maintains the collection of items. It passes the same visitor to each item, allowing the visitor to process the entire order.

9. Execution Flow Diagram

This diagram shows what happens when the invoice visitor processes an order.

Visitor Design Pattern Execution Flow Order.accept(visitor) loops through each OrderItem. Each element calls accept(visitor), which triggers the matching visit() method on the InvoiceVisitor. The visitor executes the operation and produces the invoice result. Order.accept(visitor) Loop through OrderItems PhysicalProduct accept(visitor) DigitalProduct accept(visitor) ShippingCharge accept(visitor) InvoiceVisitor.visit(...) Executes the matching operation Invoice result is generated
Figure: The object structure loops through elements; each element dispatches to the matching visit() method on the visitor.

The execution sequence is:

  1. The application creates an InvoiceVisitor.
  2. The visitor is passed to the Order.
  3. The Order iterates through its items.
  4. Each item calls accept(visitor).
  5. The correct visit() method executes for that item type.
  6. The visitor builds the invoice.

10. Why Not Just Use If-Else or instanceof?

You may wonder why we need a design pattern for this. Can't we simply use instanceof?

Yes, we can write code like this:

for (OrderItem item : items) {

    if (item instanceof PhysicalProduct) {
        // Calculate physical product amount
    } else if (item instanceof DigitalProduct) {
        // Calculate digital product amount
    } else if (item instanceof ShippingCharge) {
        // Calculate shipping amount
    }
}

This approach works for small applications. However, as the number of operations grows, similar type-checking logic may be repeated in multiple places.

For example, invoice generation, tax calculation, reporting, and exporting may all contain their own instanceof checks. Visitor gives these operations a consistent structure and keeps them separate from the element classes.

11. When Should You Use the Visitor Pattern?

Visitor is useful when the object structure is relatively stable but you frequently need to add new operations to it.

1. E-commerce applications

Generate invoices, tax reports, product summaries, and exports for different order item types.

2. Compiler development

Compilers often use visitors to traverse abstract syntax trees and perform operations such as type checking, code generation, and validation.

3. Reporting systems

Different visitors can generate PDF reports, CSV exports, statistics, and summaries from the same object structure.

4. File system operations

A visitor can perform operations on files and directories, such as calculating sizes, validating files, or generating reports.

5. Document processing

Different visitors can export the same document structure to HTML, XML, or another format.

12. Visitor Pattern in Spring Boot

Visitor is not limited to standalone Java applications. It can also be useful in Spring Boot projects when a stable domain model needs several independent operations.

For example, imagine an order service that needs to generate different reports for an order. A simplified Spring Boot architecture could look like this:

Controller
    |
    v
OrderService
    |
    v
Order
    |
    +---- PhysicalProduct
    |
    +---- DigitalProduct
    |
    +---- ShippingCharge
    |
    v
OrderVisitor
    |
    +---- InvoiceVisitor
    |
    +---- TaxReportVisitor
    |
    +---- OrderSummaryVisitor

You could expose separate endpoints for different operations:

GET /api/orders/101/invoice
GET /api/orders/101/tax-report
GET /api/orders/101/summary

The controller would call an application service. The service would load the order, select the appropriate visitor, and execute it.

Example: a focused Spring Boot service

import org.springframework.stereotype.Service;
import java.math.BigDecimal;

@Service
public class OrderReportService {

    public BigDecimal calculateTax(Order order) {

        TaxReportVisitor visitor = new TaxReportVisitor();

        order.accept(visitor);

        return visitor.getTotalTax();
    }
}

This is a simplified integration example. In a real Spring Boot application, you would also need to consider dependency injection, DTO mapping, transaction boundaries, validation, authorization, and how visitors are registered or selected.

Is this always the best design?

No. Avoid forcing Visitor into every service. If your object model changes frequently or the operations are simple, ordinary service classes or polymorphism may be easier to maintain.

13. Advantages and Disadvantages of the Visitor Pattern

Advantages

  • Easy to add new operations: Add another visitor without adding operation methods to every existing element class.
  • Separation of concerns: Business operations stay separate from the domain objects.
  • Centralized operations: All behavior for a particular operation is grouped in one visitor.
  • Supports double dispatch: The operation can depend on both the visitor type and the concrete element type.
  • Useful for complex object structures: A visitor can process many related element types consistently.

Disadvantages

  • Adding a new element is difficult: Every visitor must be updated with a new visit() method.
  • More classes: Each new operation may require another visitor implementation.
  • Encapsulation concerns: Visitors may need access to element data, which can encourage exposing getters or internal details.
  • Can become complex: For a small object model, Visitor may introduce unnecessary complexity.
  • Not ideal for frequently changing object structures: Adding new element types affects the visitor interface and its implementations.

The best Visitor setup is stable in its element types but still expanding in operations. If the element structure changes frequently, the pattern will slow you down.

14. Visitor vs Strategy Pattern

Both Visitor and Strategy are behavioral design patterns, but they solve different problems.

Feature Visitor Strategy
Main purpose Add operations to an object structure Choose between interchangeable algorithms
Focus Different element types Different ways to perform a task
Typical methods visit() and accept() execute() or calculate()
Adding a new operation Usually add a new visitor Usually add a new strategy implementation
Adding a new element type Requires updating visitors Usually does not require updating strategies
Example Generate invoices for different order items Choose UPI, card, or wallet payment processing

Strategy changes how an operation is performed. Visitor adds different operations across a group of element types.

15. Visitor Pattern and SOLID Principles

Single Responsibility Principle (SRP)

Each visitor can have one focused responsibility. InvoiceVisitor generates invoices, while TaxReportVisitor calculates tax.

Open/Closed Principle (OCP)

You can add new operations by creating new visitors without modifying the existing element classes. However, adding a new element type still requires changes to the visitor interface and its implementations. Visitor does not make every kind of extension closed to modification.

Dependency Inversion Principle (DIP)

Concrete elements depend on the OrderVisitor abstraction rather than directly depending on InvoiceVisitor or TaxReportVisitor.

Practical takeaway

Visitor promotes clear separation between data and behavior, but it does not make a design fully SOLID on its own. Apply it where its trade-offs match your project's stability profile.

16. Best Practices

  1. Use Visitor when your element types are stable and new operations are expected.
  2. Keep each visitor focused on one business operation. Avoid mixing unrelated responsibilities.
  3. Use meaningful visitor and element names. The visit() overloads should be obvious from the class names.
  4. Avoid exposing unnecessary internal fields just to support visitors. Expose only what the operation truly needs.
  5. Use immutable domain objects where appropriate. This makes double dispatch and visitors easier to reason about.
  6. Keep business rules, especially financial and tax rules, in well-tested components.
  7. Do not use Visitor simply because it is a recognized design pattern. Favor clarity and simplicity.
  8. Write unit tests for every visitor and every supported element type. This ensures each visit() method behaves correctly.

17. Java Interview Questions and Answers

Q1. What is the Visitor Design Pattern?

Visitor is a behavioral design pattern that allows new operations to be added to an existing object structure without modifying the element classes for every new operation.

Q2. Why is Visitor called a behavioral design pattern?

Because it focuses on how operations are performed on a group of objects and how responsibilities are distributed between visitors and elements.

Q3. What are the main components of Visitor?

The main components are Element, Concrete Element, Visitor, Concrete Visitor, and Object Structure.

Q4. What is double dispatch?

Double dispatch is a technique in which the operation selected depends on both the runtime type of the element and the visitor. In Java, Visitor commonly achieves this using method overriding and overloaded visit() methods.

Q5. Why do we use the accept() method?

The accept() method allows an element to receive a visitor and call the appropriate visit() method using its own concrete type.

Q6. What is the difference between Visitor and Strategy?

Visitor adds operations to a stable object structure. Strategy provides interchangeable algorithms for performing a particular task.

Q7. What is the biggest disadvantage of Visitor?

Adding a new element type is difficult because the visitor interface and all concrete visitors generally need to be updated.

Q8. Can Visitor violate encapsulation?

Yes. If visitors need access to internal fields, developers may expose getters or other access methods. The design should expose only the information required by the operations.

Q9. Is Visitor useful when new element types are added frequently?

Usually not. Visitor works best when the element structure is stable and new operations are added more frequently than new element types.

Q10. Can we have multiple visitors for the same object?

Yes. For example, the same Order can be processed by InvoiceVisitor, TaxReportVisitor, and OrderSummaryVisitor.

Q11. Does Visitor require inheritance?

It requires a common element abstraction or another way to expose the accept() operation. Concrete elements commonly implement a shared interface.

Q12. Can Visitor work with collections?

Yes. An object structure can contain a collection of elements and pass the same visitor to each element, as demonstrated in the e-commerce example.

Q13. How does Visitor support the Open/Closed Principle?

It allows new operations to be added through new visitor implementations without changing existing element classes. However, new element types still require visitor updates.

Q14. Can Visitor be used in Spring Boot?

Yes. It can be used in service-layer workflows for reporting, exporting, validation, and processing different domain object types. It should be used only when its structure provides a real benefit.

Q15. What is the difference between Visitor and Iterator?

Iterator provides a way to traverse a collection. Visitor defines operations to perform on different element types. They can work together, as the Order example demonstrates.

Q16. Why does every concrete visitor implement every visit() method?

The visitor interface defines the supported element types. A concrete visitor must implement the operation for each of those types, even if some operations do nothing for a particular type.

Q17. Can a visitor return a value?

Yes. The visit() methods can return values instead of void. For example, a visitor can return a calculated amount, a validation result, or a generated report.

Q18. Is Visitor suitable for payment reversal in a payment gateway?

Visitor is not a payment reversal mechanism. It can perform different operations on payment-related domain objects, but reversing an actual payment requires a separate business workflow involving provider APIs, transaction state, idempotency, and reconciliation.

Q19. What happens when a new product type is introduced?

You add a new element class and a corresponding visit() method to the visitor interface. All concrete visitors must then implement that new method.

Q20. When should you avoid the Visitor Design Pattern?

Avoid it when the object structure changes frequently, when there are only a few simple operations, or when ordinary polymorphism and service classes are easier to understand and maintain.

18. Frequently Asked Questions

Is Visitor a creational, structural, or behavioral pattern?

Visitor is a behavioral design pattern.

Can Visitor be implemented in Java 8?

Yes. The implementation in this article uses standard Java features available in Java 8 and does not require external libraries.

Does Visitor follow the Open/Closed Principle?

It supports adding new operations without changing existing element classes. However, adding new element types requires updating the visitor interface and concrete visitors.

Is Visitor used in real-world software?

Yes. It is useful in compiler implementations, document processing, reporting systems, and applications that perform multiple operations on a stable object structure.

Can Visitor replace all instanceof checks?

No. Visitor is a design choice for a particular object structure. It is not a universal replacement for instanceof or ordinary polymorphism.

Is the Visitor pattern part of the Gang of Four patterns?

Yes. Visitor is one of the behavioral patterns in the Gang of Four design-pattern catalog.

19. Quick Revision Checklist

  • Visitor is a behavioral design pattern.
  • It adds new operations without modifying existing element classes.
  • Elements expose accept(); visitors declare visit().
  • Visitor uses double dispatch to select the correct visit() method.
  • Adding a new operation means adding a new visitor, not editing elements.
  • Adding a new element type means updating the visitor interface and all visitors.
  • Visitor is best when element types are stable and operations grow.
  • It can expose element internals, so control what visitors can access.
  • Spring Boot services can use Visitor for reporting and export workflows.
  • Avoid Visitor when the object structure changes frequently or operations are simple.

Conclusion

The Visitor Design Pattern is useful when you have a stable group of object types but need to perform different operations on them. In our e-commerce example, PhysicalProduct, DigitalProduct, and ShippingCharge remained unchanged while we introduced invoice generation, tax calculation, and order summary operations through separate visitors.

The most important thing to remember is this: when your object structure stays stable but new operations keep coming, Visitor can help keep your code organized.

But don't use it everywhere. If your object types change frequently, the Visitor pattern can make maintenance harder rather than easier. Understand the problem first, then choose the design pattern that makes your code simpler.

If you are preparing for Java interviews, practise implementing Visitor alongside Strategy, Command, and Template Method. Understanding when each pattern is useful matters more than memorizing its definition.