Showing posts with label Java Interview Questions. Show all posts
Showing posts with label Java Interview Questions. 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.

Spring Boot Logging Complete Guide: SLF4J, Logback, MDC, and Real-World Examples

Spring Boot Logging Complete Guide: From Basics to Production-Level Logging

Imagine you are working on a payment gateway that processes thousands of transactions every minute.

A customer reports that ₹5,000 was deducted from their wallet, but the merchant never received the payment confirmation.

You open the application, check the database, inspect the payment service, and search through multiple microservice logs. But there is one problem: you cannot easily identify which logs belong to that particular transaction.

Now think about another situation. Your application is running perfectly in development, but after deployment, a payment API starts returning HTTP 500. You have no idea which method failed, what the request was doing, or where the exception originated.

This is where proper application logging becomes extremely important.

Logging is not simply about printing messages in the console. It is one of the most important observability practices in production-grade Java applications.

In this tutorial, we will build a practical understanding of Spring Boot logging using a real-world payment processing example. We will start with the fundamentals and gradually move towards the techniques used by experienced backend developers.

1. What Is Logging in Java?

Logging is the process of recording application events, execution details, warnings, and errors while an application is running.

It helps developers understand what happened inside an application without attaching a debugger to a production server.

Consider a simple payment operation:

public void processPayment() {
    System.out.println("Payment processing started");

    // Payment business logic

    System.out.println("Payment processing completed");
}

This approach works for a small demonstration. But it is not suitable for production applications.

Why?

  • You cannot easily control different log levels.
  • You cannot configure proper log formatting.
  • You cannot easily manage log files and rotation.
  • You cannot integrate a consistent logging framework.
  • You have limited control over filtering and monitoring.

Professional Java applications use logging frameworks to solve these problems.

2. Understanding SLF4J and Logback

One common interview question is: What is the difference between SLF4J and Logback?

Let's understand them separately.

SLF4J

SLF4J stands for Simple Logging Facade for Java.

It provides a common logging API that allows your application code to communicate with a logging implementation without being tightly coupled to it.

For example:

private static final Logger log =
        LoggerFactory.getLogger(PaymentService.class);

log.info("Payment processing started");

Your application uses the SLF4J API, while the underlying logging provider handles the actual logging operations.

Logback

Logback is a logging implementation that works with SLF4J.

It is the default logging implementation in a standard Spring Boot starter configuration.

Logback provides:

  • Console logging.
  • File-based logging.
  • Rolling log files.
  • Log level configuration.
  • Custom log patterns.
  • Logger configuration.

How do they work together?

Spring Boot Application SLF4J API Logback Implementation Console Files Monitoring
Figure: SLF4J is the logging abstraction, whereas Logback is one possible logging implementation.

Important: SLF4J is the logging abstraction, whereas Logback is one possible logging implementation.

3. Add Logging Dependencies in Spring Boot

If you are using Spring Boot with the standard starter, logging support is already included through spring-boot-starter-logging, which is brought in by spring-boot-starter-web.

Maven dependency

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

Gradle dependency

implementation 'org.springframework.boot:spring-boot-starter-web'

You generally do not need to add SLF4J or Logback separately when using the standard Spring Boot starter.

To verify your logging dependencies in Maven:

mvn dependency:tree

For Gradle:

./gradlew dependencies --configuration runtimeClasspath

4. Understanding Different Logging Levels

Logging levels help us decide which messages should be recorded and how important they are.

Spring Boot commonly uses these logging levels:

Level Purpose Real-world example
TRACE Very detailed execution information. Tracing internal calculation steps.
DEBUG Detailed diagnostic information. Checking payment validation decisions.
INFO Important application events. Payment successfully processed.
WARN Unexpected situations that do not necessarily stop execution. External vendor response is slow.
ERROR Failures requiring investigation. Payment processing failed.
OFF Disables logging for a logger. Disabling a specific logger.

Example: Choosing the right logging level

log.trace("Entering payment calculation");

log.debug("Payment amount: {}", amount);

log.info("Payment request received");

log.warn("Payment vendor response is delayed");

log.error("Payment processing failed", exception);

Senior developer tip: Do not log everything as INFO or ERROR. If every normal event is an ERROR, your production monitoring becomes noisy and important failures become harder to identify.

5. Create a Real-World Spring Boot Payment Logging Project

Let's build a simple payment processing flow.

Our application will contain:

  • PaymentController: Receives the payment request.
  • PaymentService: Executes the business logic.
  • PaymentRepository: Represents the persistence layer.
  • SLF4J Logger: Records application events.

Our expected flow is:

Client Request Payment Controller Payment Service Payment Repository Database
Figure: A typical payment request flow through controller, service, repository, and database.

Step 1: Create PaymentRequest DTO

public class PaymentRequest {

    private String transactionId;
    private BigDecimal amount;
    private String currency;

    // Getters and setters
}

Step 2: Create PaymentController

@RestController
@RequestMapping("/api/payments")
public class PaymentController {

    private static final Logger log =
            LoggerFactory.getLogger(PaymentController.class);

    private final PaymentService paymentService;

    public PaymentController(PaymentService paymentService) {
        this.paymentService = paymentService;
    }

    @PostMapping
    public ResponseEntity<String> processPayment(
            @RequestBody PaymentRequest request) {

        log.info("Payment API request received. TransactionId={}",
                request.getTransactionId());

        paymentService.processPayment(request);

        log.info("Payment API completed. TransactionId={}",
                request.getTransactionId());

        return ResponseEntity.ok("Payment processed successfully");
    }
}

Notice that we are using parameterized logging with placeholders instead of string concatenation.

Prefer this:

log.info("Payment amount: {}", amount);

Instead of:

log.info("Payment amount: " + amount);

Parameterized logging avoids unnecessary string construction when the corresponding log level is disabled.

Step 3: Create PaymentService

@Service
public class PaymentService {

    private static final Logger log =
            LoggerFactory.getLogger(PaymentService.class);

    public void processPayment(PaymentRequest request) {

        log.info("Starting payment processing. TransactionId={}",
                request.getTransactionId());

        try {

            validatePayment(request);

            // Call payment vendor here.

            log.info("Payment successfully processed. TransactionId={}",
                    request.getTransactionId());

        } catch (IllegalArgumentException ex) {

            log.warn("Payment validation failed. TransactionId={}",
                    request.getTransactionId());

            throw ex;

        } catch (Exception ex) {

            log.error("Unexpected payment processing failure. TransactionId={}",
                    request.getTransactionId(), ex);

            throw ex;
        }
    }

    private void validatePayment(PaymentRequest request) {

        if (request.getAmount() == null ||
                request.getAmount().compareTo(BigDecimal.ZERO) <= 0) {

            throw new IllegalArgumentException(
                    "Payment amount must be greater than zero");
        }
    }
}

In a real application, you should also use a global exception handler and define clear exception types for validation, vendor failures, and unexpected system errors.

Step 4: What does the output look like?

2026-11-08 10:30:10 INFO  PaymentController
Payment API request received. TransactionId=TXN10001

2026-11-08 10:30:10 INFO  PaymentService
Starting payment processing. TransactionId=TXN10001

2026-11-08 10:30:11 INFO  PaymentService
Payment successfully processed. TransactionId=TXN10001

2026-11-08 10:30:11 INFO  PaymentController
Payment API completed. TransactionId=TXN10001

Now we can understand the execution flow by reading the logs.

6. Configure Logging Using application.properties

Spring Boot allows us to configure logging without writing Java code.

Open your application.properties file.

# Root logging level
logging.level.root=INFO

# Application package logging
logging.level.com.example.payment=DEBUG

# Spring framework logging
logging.level.org.springframework=WARN

# Hibernate logging
logging.level.org.hibernate=ERROR

# Console log pattern
logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} %-5level [%thread] %logger{36} - %msg%n

# Log file
logging.file.name=logs/payment-service.log

Let's understand the important properties.

Property Purpose
logging.level.root Sets the default logging level.
logging.level.package Sets logging level for a particular package.
logging.file.name Writes logs to a specified file.
logging.pattern.console Customizes console output.
logging.pattern.file Customizes file output.

7. Different Logging Levels for Different Environments

A common mistake is using DEBUG logging everywhere, including production.

Development and production environments have different requirements.

application-dev.properties

logging.level.root=DEBUG
logging.level.com.example.payment=DEBUG

application-prod.properties

logging.level.root=INFO
logging.level.com.example.payment=INFO
logging.level.org.springframework=WARN

Activate the production profile using:

spring.profiles.active=prod

Or set the environment variable:

SPRING_PROFILES_ACTIVE=prod

This keeps unnecessary diagnostic logs out of the production environment.

8. Configure Logback Using logback-spring.xml

When an application becomes larger, application.properties may not provide enough control over logging configuration.

For more advanced requirements, we can use logback-spring.xml.

Create this file inside:

src/main/resources/logback-spring.xml

Example production-oriented configuration:

<?xml version="1.0" encoding="UTF-8"?>

<configuration>

    <property name="LOG_DIR" value="logs"/>

    <appender name="CONSOLE"
              class="ch.qos.logback.core.ConsoleAppender">

        <encoder>
            <pattern>
                %d{yyyy-MM-dd HH:mm:ss.SSS}
                %-5level [%thread] %logger{36} - %msg%n
            </pattern>
        </encoder>

    </appender>

    <appender name="FILE"
              class="ch.qos.logback.core.rolling.RollingFileAppender">

        <file>${LOG_DIR}/payment-service.log</file>

        <rollingPolicy
            class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">

            <fileNamePattern>
                ${LOG_DIR}/archive/payment-%d{yyyy-MM-dd}.%i.log.gz
            </fileNamePattern>

            <maxFileSize>20MB</maxFileSize>
            <maxHistory>30</maxHistory>
            <totalSizeCap>1GB</totalSizeCap>

        </rollingPolicy>

        <encoder>
            <pattern>
                %d{yyyy-MM-dd HH:mm:ss.SSS}
                %-5level [%thread] %logger{36} - %msg%n
            </pattern>
        </encoder>

    </appender>

    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
        <appender-ref ref="FILE"/>
    </root>

</configuration>

This configuration provides:

  • Console logging.
  • File-based logging.
  • Daily log file rollover.
  • File rollover when the configured size is reached.
  • Compressed archived logs.
  • Maximum history and total archive size limits.

Production note: Log rotation does not automatically make logs durable or searchable. In containerized environments, consider writing structured logs to stdout and letting the infrastructure collect, retain, and forward them to a centralized logging platform.

9. MDC: The Most Important Logging Concept for Microservices

Now let's discuss something that separates basic logging from production-level logging.

Imagine your payment gateway contains these microservices:

  • API Gateway
  • Wallet Service
  • Payment Service
  • Notification Service
  • Vendor Integration Service

A single transaction may travel through all five services.

Without a common identifier, identifying all log entries belonging to the same request can be difficult.

This is where MDC comes into the picture.

What is MDC?

MDC stands for Mapped Diagnostic Context.

It allows us to store contextual information, such as transaction IDs, request IDs, and user identifiers, so that the logging framework can automatically include them in log messages.

Step 1: Create a correlation ID filter

@Component
public class CorrelationIdFilter extends OncePerRequestFilter {

    private static final String HEADER_NAME = "X-Correlation-ID";

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain)
            throws ServletException, IOException {

        String correlationId = request.getHeader(HEADER_NAME);

        if (correlationId == null || correlationId.isBlank()) {
            correlationId = UUID.randomUUID().toString();
        }

        try {

            MDC.put("correlationId", correlationId);

            response.setHeader(HEADER_NAME, correlationId);

            filterChain.doFilter(request, response);

        } finally {

            MDC.remove("correlationId");
        }
    }
}

We generate a correlation ID if the incoming request does not contain one.

The finally block is important because it prevents stale MDC data from leaking into another request when threads are reused.

Step 2: Update the logging pattern

logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [correlationId=%X{correlationId}] %logger{36} - %msg%n

Step 3: Write normal logs

log.info("Payment request received");

log.info("Wallet balance validated");

log.info("Vendor payment response received");

Now the output can look like this:

INFO [correlationId=TXN-10001]
Payment request received

INFO [correlationId=TXN-10001]
Wallet balance validated

INFO [correlationId=TXN-10001]
Vendor payment response received

All these logs can be searched using the same correlation ID.

Important senior-level consideration: MDC is normally thread-local. When work moves to another thread through @Async, executors, or asynchronous processing, the context does not automatically follow in every setup. You must explicitly propagate it using an appropriate TaskDecorator, context-propagation mechanism, or tracing library.

For Kafka and other messaging systems, propagate the correlation ID through message headers and establish MDC context in the consumer before processing.

10. How to Log Exceptions Correctly

One common mistake made by Java developers is logging only the exception message.

Consider:

try {

    processPayment();

} catch (Exception ex) {

    log.error(ex.getMessage());
}

This is not sufficient for debugging production failures because it does not include the complete stack trace.

Prefer:

try {

    processPayment();

} catch (Exception ex) {

    log.error("Payment processing failed", ex);

}

Passing the exception object allows the logging framework to record the stack trace.

Also avoid logging the same exception at every layer. Usually, handle it at the appropriate boundary, preserving the original cause when rethrowing or wrapping exceptions.

11. Logging Sensitive Information: A Critical Security Practice

Payment applications process sensitive information. Logging complete request and response objects without checking their contents can create serious security problems.

Never log:

  • Passwords.
  • OTP values.
  • PINs.
  • Authentication tokens.
  • Full card numbers or CVV.
  • Bank account credentials.
  • Encryption keys.
  • Unmasked personal information.

Bad logging practice

log.info("Payment request: {}", request);

This might expose sensitive information if the DTO contains credentials or personal data.

Better logging practice

log.info(
    "Payment request received. transactionId={}, amount={}, currency={}",
    request.getTransactionId(),
    request.getAmount(),
    request.getCurrency()
);

Log only the fields required for troubleshooting, subject to your organization's security and data-retention policies.

Example: Masking a phone number

public static String maskPhoneNumber(String phone) {

    if (phone == null || phone.length() < 4) {
        return "****";
    }

    return "******" + phone.substring(phone.length() - 4);
}

Output:

Original: 4234567890
Masked:   ******7890

Masking should be applied before information enters the logging pipeline. It is not a substitute for avoiding sensitive data in logs in the first place.

12. JSON Logging for Microservices

In modern distributed systems, plain text logs can become difficult to search and analyze.

Structured logging solves this problem by recording events as JSON fields.

For example:

{
  "timestamp": "2026-11-08T10:30:10.120Z",
  "level": "INFO",
  "service": "payment-service",
  "correlationId": "TXN-10001",
  "transactionId": "TXN-10001",
  "message": "Payment processed successfully"
}

These fields can be searched independently in centralized log platforms.

Common tools used for log collection and analysis include:

  • Elasticsearch
  • Logstash
  • Kibana
  • Grafana Loki
  • Fluent Bit
  • Splunk

For Spring Boot applications, JSON logging can be configured using an appropriate Logback JSON encoder or a structured logging solution compatible with your Spring Boot version.

Do not assume that adding a JSON logging dependency automatically produces safe or useful logs. Define the fields, masking rules, retention policy, and ingestion pipeline deliberately.

13. Dynamic Logging Level Using Spring Boot Actuator

Sometimes production debugging requires temporarily enabling DEBUG logs for one package without restarting the application.

Spring Boot Actuator provides a loggers endpoint for this purpose.

Add Actuator dependency

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

Configure application.properties

management.endpoints.web.exposure.include=health,info,loggers

To inspect a logger:

GET /actuator/loggers/com.example.payment

To change its logging level:

POST /actuator/loggers/com.example.payment
Content-Type: application/json

{
  "configuredLevel": "DEBUG"
}

To restore the inherited logging level:

POST /actuator/loggers/com.example.payment
Content-Type: application/json

{
  "configuredLevel": null
}

Security warning: Never expose management endpoints publicly without proper authentication and authorization. Restrict access to trusted operators and internal networks.

14. Common Logging Mistakes in Spring Boot

Mistake Better approach
Using System.out.println() Use SLF4J.
Logging every message at INFO Choose meaningful log levels.
Logging complete request objects Log only approved fields.
Ignoring stack traces Pass the exception object.
Using DEBUG in production everywhere Enable targeted diagnostic logging.
No correlation ID Use request IDs and distributed tracing.
No log rotation Configure retention and archival.
Logging the same exception repeatedly Log at the appropriate handling boundary.

15. Logging vs Monitoring vs Distributed Tracing

These three concepts are related, but they solve different problems.

Concept Purpose Example
Logging Records individual application events. Payment failed due to vendor timeout.
Monitoring Tracks system health and metrics. Payment API error rate is 5%.
Distributed tracing Tracks requests across multiple services. Gateway → Wallet → Payment → Vendor.

For a mature microservices architecture, these capabilities should work together.

OpenTelemetry is a widely used option for collecting traces, metrics, and related telemetry across distributed systems.

16. Important Spring Boot Logging Interview Questions and Answers

Q1. What is the difference between SLF4J and Logback?

SLF4J is a logging abstraction that provides a common API. Logback is a logging implementation that processes and writes the log messages.

Q2. What is the default logging framework in Spring Boot?

Logback is the default logging implementation when using Spring Boot's standard logging starter configuration.

Q3. Why should we use SLF4J instead of System.out.println()?

SLF4J supports configurable log levels, structured logging integrations, multiple logging providers, and production-friendly logging practices.

Q4. What is MDC in Java logging?

Mapped Diagnostic Context stores contextual information such as correlation IDs in the logging context. Logback can include these values in log output automatically.

Q5. What is the difference between DEBUG and INFO?

DEBUG is intended for diagnostic details, while INFO records significant normal application events.

Q6. What is the difference between ERROR and WARN?

ERROR indicates a failure that requires attention. WARN indicates an unexpected condition that may require investigation but does not necessarily mean the operation has failed.

Q7. How do you configure logging in Spring Boot?

Logging can be configured using application.properties, application.yml, or a Logback configuration file such as logback-spring.xml.

Q8. What is the purpose of logback-spring.xml?

It provides advanced Logback configuration, including appenders, patterns, rolling policies, filters, and profile-specific logging configuration.

Q9. What is a RollingFileAppender?

It writes logs to files and rolls over to new files based on configured time or size policies, helping control disk usage.

Q10. Why should we avoid logging passwords and OTPs?

Logs may be accessed by multiple systems and retained for long periods. Sensitive information in logs can cause security and compliance issues.

Q11. How do you trace a request across multiple microservices?

Use correlation IDs or distributed tracing context. Propagate the identifiers through HTTP headers and messaging headers, and include them in application logs.

Q12. Does MDC automatically propagate to another thread?

No. Standard MDC implementations are generally thread-local. For asynchronous processing, context propagation must be configured explicitly.

Q13. How can you change logging levels without restarting Spring Boot?

Spring Boot Actuator provides a loggers endpoint that can change the configured logging level dynamically, provided the endpoint is enabled and secured.

Q14. What is the difference between logging and distributed tracing?

Logging records individual events, whereas distributed tracing follows a request across services and records the timing and relationships between operations.

Q15. How would you design logging for a high-volume payment gateway?

I would use asynchronous or appropriately managed logging where suitable, structured JSON output, correlation IDs, controlled log levels, sensitive-data masking, centralized log aggregation, retention policies, and monitoring. I would also avoid excessive logging in high-throughput paths and measure the performance impact.

Q16. What happens if logging itself fails?

Logging failures should not normally bring down the payment business operation. I would configure appropriate appenders, monitor logging infrastructure, manage disk usage, and ensure the application has a reliable telemetry delivery strategy.

Q17. Why should we avoid duplicate exception logging?

Logging an exception at every layer creates noisy logs, duplicates stack traces, and makes production troubleshooting difficult. Log it at the appropriate handling boundary and preserve its cause.

Q18. Can we use Log4j2 instead of Logback in Spring Boot?

Yes. Spring Boot supports alternative logging implementations. We can exclude the default logging starter and configure the appropriate Log4j2 starter, ensuring that only the intended logging provider is active.

Q19. How do you investigate a slow payment API using logs?

I would search using the correlation ID, inspect the timestamps of each operation, identify the slow dependency, compare application and vendor response times, and correlate the logs with metrics and distributed traces.

Q20. Should we log every database query in production?

No. Logging every query can generate enormous volumes of data, expose sensitive values, and affect performance. I would enable targeted database diagnostics when needed and use appropriate database monitoring tools.

17. Senior Java Developer Production Logging Checklist

Before deploying a Spring Boot microservice, I would review the following points:

  • Use SLF4J with a properly configured logging implementation.
  • Configure appropriate log levels for each environment.
  • Use correlation IDs for request tracking.
  • Propagate diagnostic context across asynchronous operations.
  • Mask sensitive information before logging.
  • Include complete exception details when appropriate.
  • Configure log rotation and retention.
  • Prefer structured logs for centralized analysis.
  • Protect Actuator management endpoints.
  • Monitor logging volume, disk usage, and ingestion failures.
  • Integrate logging with monitoring and distributed tracing.
  • Avoid unnecessary logging inside high-throughput loops.

18. Final Thoughts

Logging is one of those engineering practices that developers often underestimate until something goes wrong in production.

Writing a few log statements is easy. Designing a logging strategy that helps a team investigate a failed payment across multiple microservices is a completely different challenge.

As a Java backend developer, you should understand not only how to write logs but also how to make them useful, searchable, secure, and reliable.

My recommendation is to start with SLF4J and Logback, then learn MDC, correlation IDs, structured logging, and distributed tracing. These concepts will make a significant difference when you start working on enterprise-level Spring Boot applications.

Remember: Good logging does not mean generating more logs. It means generating the right logs at the right time with the right context.

Test Your Spring Boot Logging Knowledge

Let's finish with one practical interview question.

Question: Your Spring Boot application processes 10,000 requests per minute. You are using MDC to store correlation IDs. What is the biggest concern when the application processes requests using a reusable thread pool?

  1. MDC automatically generates duplicate IDs.
  2. MDC values may leak between requests if they are not cleared.
  3. SLF4J cannot work with multiple threads.
  4. Logback disables all INFO messages.

Correct Answer: B

Explanation: MDC is generally thread-local. Since thread pools reuse threads, failing to clear MDC values in a finally block can cause diagnostic context from one request to appear in another request's logs.