How HLD Translates to LLD
In software engineering interviews and real-world system development, candidates frequently treat High-Level Design (HLD) and Low-Level Design (LLD) as isolated disciplines. They can easily sketch out an HLD containing load balancers, caching clusters, and databases, but fail when asked to write the actual Java, Go, or C++ classes that execute the logic within those boxes.
Staff Engineers know that HLD and LLD are simply different focal lengths of the same unified architectural model. A box in an HLD diagram represents a microservice or an isolated component. The microservice itself is a composition of LLD code patterns (DTOs, Domain Models, Repositories, Adapters, and Factories).
This guide details the exact architectural bridge between the macro system design and the micro code execution.
Requirements and System Goals
Successfully translating HLD concepts to clean LLD structures requires aligning system scale budgets with clean-code design patterns.
1. Functional Requirements
- Consistent Architectural Mapping: Map high-level service boundaries (e.g., Bounded Contexts in Domain-Driven Design) directly to low-level compile-time isolation structures (Java modules, packages, or namespaces).
- Deterministic Contract Translation: Map network API endpoints (REST JSON schemas, gRPC protobufs) directly to strongly typed class interfaces, DTOs, and programming language method signatures.
- Infrastructure Inversion: Ensure business logic classes remain isolated from underlying database configurations, messaging brokers, and third-party vendor clients.
- Configurable Extensibility: Support adding new integrations (e.g., new payment gateways, notification channels) by writing new LLD implementations rather than modifying core business engines.
2. Non-Functional Requirements
- Zero Abstraction Performance Tax: Class abstractions, polymorphism, and decorators must introduce negligible runtime latency (less than 1 microsecond).
- Bounded Object Allocation Rate: Design domain classes to avoid high object creation rates under heavy throughput (e.g., 50,000 QPS), preventing JVM garbage collection (GC) pauses.
- Explicit Thread Safety: Ensure all singleton-scoped controller and manager classes are strictly stateless and thread-safe under parallel runtime execution.
- Observability Interceptors: Embed metrics collection (Prometheus) and tracing spans (OpenTelemetry) cleanly into the class invocation hierarchy without cluttering core domain algorithms.
API Interfaces and Service Contracts
The bridge between high-level service endpoints and low-level code signatures requires mapping serialized network JSON payloads directly to strongly typed DTOs and language method interfaces.
1. High-Level REST API Contract
An API endpoint in an HLD allows an external client to trigger a payment.
POST /api/v1/payments
Idempotency-Key: idemp_key_112233
Content-Type: application/json
Request Payload:
{
"order_id": "ord_88776655",
"amount_in_cents": 15000,
"currency": "USD",
"payment_method": {
"type": "CREDIT_CARD",
"token": "tok_stripe_visa_04"
}
}
2. Low-Level DTO Class Signature
The high-level JSON structure is mapped directly to a strongly typed Java Data Transfer Object (DTO) containing validation rules.
package com.codesprintpro.payment.application.dto;
import java.util.Objects;
public final class CreatePaymentRequest {
private final String orderId;
private final long amountInCents;
private final String currency;
private final String paymentMethodType;
private final String paymentToken;
public CreatePaymentRequest(String orderId, long amountInCents, String currency,
String paymentMethodType, String paymentToken) {
this.orderId = Objects.requireNonNull(orderId, "orderId cannot be null");
this.amountInCents = amountInCents;
this.currency = Objects.requireNonNull(currency, "currency cannot be null");
this.paymentMethodType = Objects.requireNonNull(paymentMethodType, "paymentMethodType cannot be null");
this.paymentToken = Objects.requireNonNull(paymentToken, "paymentToken cannot be null");
}
public String getOrderId() { return orderId; }
public long getAmountInCents() { return amountInCents; }
public String getCurrency() { return currency; }
public String getPaymentMethodType() { return paymentMethodType; }
public String getPaymentToken() { return paymentToken; }
}
High-Level Design and Visualizations
A comprehensive architecture maps macro system blocks down to low-level compile-time packages, classes, and dependency directions.
1. HLD-to-LLD Component Breakdown
This diagram demonstrates how an abstract HLD Payment Service decomposes into clean Hexagonal (Ports & Adapters) compile-time layers inside a code repository.
graph TD
subgraph HLD System Architecture
Client[Mobile App] -->|HTTPS POST| API[API Gateway]
API -->|Route Request| PaymentService[Payment Service Box]
end
subgraph LLD Package Breakdown (Hexagonal Architecture)
PaymentService -->|Decomposes Into| WebPort[com.codesprintpro.payment.adapter.in.web]
PaymentService -->|Decomposes Into| DomainCore[com.codesprintpro.payment.domain.core]
PaymentService -->|Decomposes Into| DBAdapter[com.codesprintpro.payment.adapter.out.persistence]
WebPort -->|1. Invokes Use Case| UseCase[com.codesprintpro.payment.application.port.in]
UseCase -->|2. Implemented By| ApplicationService[com.codesprintpro.payment.application.service]
ApplicationService -->|3. Coordinates| DomainCore
ApplicationService -->|4. Calls Persistence Port| OutPort[com.codesprintpro.payment.application.port.out]
DBAdapter -->|5. Implements Out Port| OutPort
end
2. Low-Level Sequence of Ports and Adapters Execution
This sequence diagram details the dependency injection pattern. The core business service depends strictly on local interfaces, completely decoupling it from external network clients and databases.
sequenceDiagram
autonumber
participant Controller as WebController (Adapter)
participant PortIn as PaymentUseCase (Port In)
participant Service as PaymentService (Business Core)
participant Domain as PaymentEntity (Domain)
participant PortOut as PaymentRepository (Port Out)
participant DB as PostgresAdapter (Adapter)
Controller->>PortIn: execute(CreatePaymentRequest)
PortIn->>Service: processPayment(OrderDto)
Note over Service: Apply domain invariants (check limits)
Service->>Domain: calculateFees()
Domain-->>Service: return FeeDetails
Service->>PortOut: savePaymentRecord(PaymentEntity)
PortOut->>DB: save(PaymentEntity)
DB-->>PortOut: DB_Success
PortOut-->>Service: return SavedRecord
Service-->>PortIn: return PaymentResultDto
PortIn-->>Controller: return HTTP Response DTO
Low-Level Design and Schema Strategies
To write robust, testable business logic, Staff Engineers organize code using the Hexagonal (Ports & Adapters) or Clean Architecture directory structure. Below is the concrete Java code design implementing the Payment Service bridge.
1. Hexagonal Directory/Package Layout
com.codesprintpro.payment
├── adapter
│ ├── in
│ │ └── web
│ │ └── PaymentWebController.java (Accepts JSON, maps to DTO)
│ └── out
│ ├── persistence
│ │ ├── PostgresPaymentRepositoryAdapter.java (Implements output interface)
│ │ └── SpringDataPostgresRepository.java (Direct JPA connector)
│ └── stripe
│ └── StripeGatewayAdapter.java (Implements gateway output interface)
├── application
│ ├── port
│ │ ├── in
│ │ │ └── ProcessPaymentUseCase.java (Input Interface)
│ │ └── out
│ │ ├── PaymentRepository.java (Output Interface)
│ │ └── StripePaymentGateway.java (Output Interface)
│ └── service
│ └── PaymentProcessingService.java (Coordinates logic; holds transactional boundaries)
└── domain
├── model
│ ├── Payment.java (Rich Entity with logic)
│ └── Money.java (Value Object)
└── exception
└── BusinessRuleViolationException.java
2. Core Domain Model and Value Objects
Value Objects (e.g., Money) must be immutable to guarantee thread safety. Entities (e.g., Payment) encapsulate rich business rules rather than just holding getters and setters.
package com.codesprintpro.payment.domain.model;
// Immutable Value Object
public final class Money {
private final long amountInCents;
private final String currency;
public Money(long amountInCents, String currency) {
if (amountInCents < 0) {
throw new IllegalArgumentException("Amount cannot be negative");
}
this.amountInCents = amountInCents;
this.currency = java.util.Objects.requireNonNull(currency, "Currency cannot be null");
}
public long getAmountInCents() { return amountInCents; }
public String getCurrency() { return currency; }
}
// Rich Domain Entity
public class Payment {
private final String paymentId;
private final String orderId;
private final Money amount;
private String status; // CREATED, SUCCESSFUL, FAILED
public Payment(String paymentId, String orderId, Money amount) {
this.paymentId = paymentId;
this.orderId = orderId;
this.amount = amount;
this.status = "CREATED";
}
// Business Logic Method: Enforces state machine transitions
public void markSuccessful() {
if (this.status.equals("SUCCESSFUL")) {
throw new IllegalStateException("Payment is already in a SUCCESSFUL state");
}
if (this.status.equals("FAILED")) {
throw new IllegalStateException("Cannot transition a FAILED payment to SUCCESSFUL");
}
this.status = "SUCCESSFUL";
}
public String getPaymentId() { return paymentId; }
public String getOrderId() { return orderId; }
public Money getAmount() { return amount; }
public String getStatus() { return status; }
}
3. Output Port Interface (Database Decoupling)
This interface resides inside the application core. The core does not know or care how payments are saved to storage (e.g., Postgres, DynamoDB, or memory).
package com.codesprintpro.payment.application.port.out;
import com.codesprintpro.payment.domain.model.Payment;
import java.util.Optional;
public interface PaymentRepository {
Payment save(Payment payment);
Optional<Payment> findById(String paymentId);
}
Scaling and Operational Challenges
Bridging HLD decisions down to LLD code exposes severe code-level resource limits that directly affect system scalability under high loads.
1. Memory Footprint and Object Allocation Rate Calculations
When an application receives high traffic (e.g., 50,000 write queries per second), inefficient class design can easily trigger severe Garbage Collection (GC) pauses that degrade tail latencies.
Let us compare a clean, lightweight DTO design with a bloated, poorly designed Java object hierarchy:
- Bloated Design: Instantiates 10 nested wrapper classes per request, incorporating heavy auto-boxed types (
Integer,Double,Boolean) and dynamicHashMapcollections for metadata. - Payload footprint in Memory: Each transaction request instantiates 120 Objects in Java, consuming a total of 8 Kilobytes (KB) of heap space due to object header overheads.
- Lightweight Design: Instantiates primitive arrays, final variables, and immutable custom Value Objects. It instantiates exactly 4 Objects per request, consuming 256 Bytes of heap space.
Let us mathematically calculate the Garbage Collection object allocation rates at a throughput of 50,000 QPS:
-
For Bloated Objects: $$\text{Allocation Rate} = 50,000 \text{ reqs/sec} \times 8 \text{ KB} = 400,000 \text{ KB/sec} \approx 390 \text{ MB/sec}$$ If the JVM heap is configured with 4 GB of Young Generation space, the garbage collector must run a Minor GC sweep every: $$\text{Minor GC Interval} = \frac{4,000 \text{ MB}}{390 \text{ MB/sec}} \approx 10.2 \text{ seconds}$$ Frequent GC sweeps introduce micro-pauses that spike P99 latency.
-
For Lightweight Objects: $$\text{Allocation Rate} = 50,000 \text{ reqs/sec} \times 0.25 \text{ KB} = 12,500 \text{ KB/sec} \approx 12.2 \text{ MB/sec}$$ The Minor GC interval scales elegantly: $$\text{Minor GC Interval} = \frac{4,000 \text{ MB}}{12.2 \text{ MB/sec}} \approx 327 \text{ seconds} \approx 5.4 \text{ minutes}$$ This $32 \times$ reduction in Garbage Collection sweeps guarantees predictable, ultra-low tail latencies.
Trade-offs and Architectural Alternatives
Decoupling high-level designs from low-level details requires selecting an appropriate software architecture pattern based on the system's operational lifecycle.
| Software Architecture Pattern | Decoupling Strength | Compile-Time Overhead | Cognitive Complexity | Ideal Use Case |
|---|---|---|---|---|
| Hexagonal Architecture (Ports & Adapters) | Excellent (No core dependencies on databases or networks; perfectly testable) | High (Requires declaring input/output ports, multiple DTO mapping classes, and adapters) | High (Developers must navigate separate packages for interfaces vs implementations) | Highly complex, business-critical microservices (e.g., Payment Ledger engine) |
| Clean Architecture (Onion) | Outstanding (Domain sits at the absolute center, surrounded by independent ring layers) | Very High (Strict dependency rules enforce data mapping across every single boundary) | Very High (Requires extensive boilerplate and mapping layers) | Core enterprise platforms with long, multi-year lifecycles and multiple teams |
| Classic Layered Architecture (3-Tier) | Poor (Controller -> Service -> Repository. The Service layer frequently imports JPA models directly, coupling it to the DB) | Very Low (Minimal boilerplate; easy to write classes quickly) | Low (Simple linear flow that is highly intuitive for junior engineers) | Low-complexity systems, microservices with minimal business logic, or MVPs |
Failure Modes and Fault Tolerance Strategies
When translating resilient high-level architectures (e.g., retry loops, failovers) to low-level code, developers must implement precise error boundaries inside their adapter classes.
1. Handling Database Connection Pool Exhaustion in LLD
In HLD, we design circuit breakers to protect services if a database is slow. In LLD, this requires configuring bounded connection pools (e.g., HikariCP) and capturing thread timeouts gracefully:
- The Failure: If the connection pool is saturated, HikariCP will throw a
SQLTransientConnectionExceptionafter a 30-second wait. - The Mitigation (LLD Code): Wrap database adapter invocations with non-blocking circuit breakers (Resilience4j) that fail fast when the pool is congested, protecting thread resources.
package com.codesprintpro.payment.adapter.out.persistence;
import com.codesprintpro.payment.application.port.out.PaymentRepository;
import com.codesprintpro.payment.domain.model.Payment;
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import java.sql.SQLTransientConnectionException;
public class PostgresPaymentRepositoryAdapter implements PaymentRepository {
private final SpringDataPostgresRepository jpaRepository;
public PostgresPaymentRepositoryAdapter(SpringDataPostgresRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
@CircuitBreaker(name = "postgresDB", fallbackMethod = "dbFallback")
public Payment save(Payment payment) {
// Raw DB operation which may fail if pool is exhausted
return jpaRepository.save(payment);
}
// LLD Fallback execution method to prevent thread blockage
private Payment dbFallback(Payment payment, SQLTransientConnectionException ex) {
System.err.println("Database connection pool exhausted! Triggering class-level fail-fast fallback.");
// Mark payment as PENDING and persist to a resilient in-memory local queue or transaction log
return new Payment(payment.getPaymentId(), payment.getOrderId(), payment.getAmount());
}
}
Staff Engineer Perspective
Verbal Script
Interviewer: "How do you systematically map a high-level system design diagram into low-level object-oriented code, ensuring the business core remains clean and testable?"
Candidate: "To bridge High-Level Design (HLD) with Low-Level Design (LLD), I enforce a Hexagonal Architecture (Ports and Adapters) model. This directly translates HLD microservice boundaries to compile-time package constraints in the code.
For example, if our HLD defines a Payments Service that integrates with a PostgreSQL database and Stripe, I map this structure into three distinct code rings: the Core Domain, the Application Use Cases, and the External Adapters.
First, the core business rules are modeled within a Rich Domain Model package: com.codesprintpro.payment.domain. I define immutable value objects like Money to prevent concurrent mutation bugs, and rich entities like Payment that encapsulate state transitions internally, rather than relying on setters. This prevents procedural 'Anemic Domain Model' anti-patterns.
Second, to keep our business logic isolated from external changes, I define Application Ports in com.codesprintpro.payment.application.port. Input ports represent use cases—such as ProcessPaymentUseCase—which are implemented by our internal application services. Output ports are interfaces that define infrastructure requirements, such as a PaymentRepository or StripePaymentGateway.
Third, the concrete technologies live inside the Adapters ring: com.codesprintpro.payment.adapter. The PostgresPaymentRepositoryAdapter implements the output PaymentRepository interface, translating our domain entities into JPA database structures. Similarly, the StripeGatewayAdapter handles the network HTTP integrations with Stripe.
By injecting these adapters into our application services via Dependency Inversion, the core business logic remains completely independent. If our high-level architecture changes—for example, replacing Postgres with DynamoDB—the only class we write is a new DynamoDBRepositoryAdapter that implements our output port. The core domain models and application services remain completely untouched, ensuring the system is highly testable, modular, and resilient to change."