Why this matters
Most interview code still looks like Java 8: a class, five fields, a constructor, getters, equals, hashCode and toString. That is a lot of typing for a simple idea. Modern Java removes most of it. Using these features in an LLD round shows you write current Java, and domain models get shorter and safer.
This repo uses Java 17 as its baseline (see pom.xml). Every example works on Java 17 unless a heading or note explicitly says Java 21. In an interview or production project, ask which version you may assume before choosing syntax.
| Feature | Final in |
|---|---|
var for local variables | Java 10 |
| Switch expressions | Java 14 |
| Records | Java 16 |
Pattern matching for instanceof | Java 16 |
| Sealed classes | Java 17 |
Pattern matching for switch, record patterns | Java 21 |
Records
A record is a special kind of class whose whole job is to carry some values. You list the values in the header, and Java writes the boring parts for you.
public record Money(BigDecimal amount, String currency) { }That one line gives you:
private finalfieldsamountandcurrency- A constructor taking both, in that order. This is called the canonical constructor, meaning the one that matches the header exactly
- Accessor methods (read only getters)
amount()andcurrency(). Note: notgetAmount() equalsandhashCodebased on all fieldstoStringlikeMoney[amount=349.00, currency=INR]
The class is implicitly final (nobody can extend it) and it cannot extend another class. It can implement interfaces, and it can have static fields, static methods and extra instance methods. It cannot declare extra instance fields. All of its state has to be in the header.
public record Money(BigDecimal amount, String currency) {
public Money add(Money other) {
return new Money(amount.add(other.amount), currency);
}
}Validation with a compact constructor
You often need to reject bad values. A compact constructor is written without a parameter list. It runs before the fields are assigned, so you can check the parameters or change them. Java then assigns the fields for you at the end.
public record Money(BigDecimal amount, String currency) {
public Money {
Objects.requireNonNull(amount, "amount");
Objects.requireNonNull(currency, "currency");
if (amount.signum() < 0) {
throw new IllegalArgumentException("amount cannot be negative: " + amount);
}
currency = currency.toUpperCase(); // reassign the parameter, not this.currency
}
}
new Money(new BigDecimal("349.00"), "inr"); // Money[amount=349.00, currency=INR]
new Money(new BigDecimal("-1"), "INR"); // IllegalArgumentExceptionRecords are only shallowly immutable
Shallow means only the top level is locked. The fields are final, so they always point to the same object. But if a field is a mutable list, the list itself can still change.
public record Cart(String userId, List<String> items) { }
List<String> items = new ArrayList<>(List.of("Masala Dosa"));
Cart cart = new Cart("u42", items);
cart.items().add("Filter Coffee");
System.out.println(cart); // Cart[userId=u42, items=[Masala Dosa, Filter Coffee]]Fix it with a copy in the compact constructor:
public record Cart(String userId, List<String> items) {
public Cart {
items = List.copyOf(items); // now cart.items().add(...) throws
}
}Same rule as any immutable class. See 19-StringsAndImmutability.md.
A small trap: equals uses each field's own equals. For BigDecimal, 349.00 and 349.0 are not equal because the scale (the number of digits after the point) differs. So Money(349.00, INR) and Money(349.0, INR) are not equal either. If amounts come from different sources, normalise them in the compact constructor with amount.setScale(2, RoundingMode.HALF_UP).
When not to use a record
- JPA or Hibernate entities. They need a no argument constructor, fields they can change, and non final classes so they can build proxies (stand-in objects). Records have none of those.
- Things with a lifecycle. An
Orderthat moves fromPLACEDtoDELIVEREDis changing state. A record would force you to build a new one each time, so it would no longer be the same order. - When you need inheritance. Records cannot extend a class.
- Lots of optional fields. A record with twelve header values and half of them null is harder to read than a builder.
Good fits: Money, Coordinates, DateRange, request and response DTOs (simple objects that carry data between layers), map keys, event payloads, and the cases of a sealed type. Jackson (2.12 and later, a common JSON library) reads and writes records without extra setup, so DTOs are usually the first place a team starts using them. Records also make good query results coming out of JPA, even though the entities themselves stay normal classes.
Sealed classes and interfaces
Normally anyone can implement your interface or extend your class. A sealed type (Java 17) is closed to outsiders. It lists exactly which classes are allowed, using the permits keyword.
public sealed interface PaymentResult permits Success, Failed, Pending { }
public record Success(String txnId, BigDecimal amount) implements PaymentResult { }
public record Failed(String reason, boolean retryable) implements PaymentResult { }
public record Pending(String txnId, int checkAfterSeconds) implements PaymentResult { }Now a UPI payment can end in exactly three ways, and the compiler knows it. Nobody can add a fourth somewhere else in the codebase.
Every permitted subclass must say whether the family stops there or goes on:
public sealed abstract class Vehicle permits Car, Bike, Truck { }
public final class Car extends Vehicle { } // stops here
public final class Bike extends Vehicle { }
public non-sealed class Truck extends Vehicle { } // open again, anyone can extend Truckfinal: no subclasses.sealed: has its ownpermitslist.non-sealed: anyone can extend it from here on.
Records are implicitly final, so they need nothing extra. The permitted classes must live in the same module (a named group of packages), or in the same package if you do not use modules. If they are all in the same file, you can leave out permits and Java works out the list.
Sealed type vs enum
Both are "a fixed set". The difference:
- An enum is a fixed set of instances. Every
PaymentStatus.SUCCESSis the same object with the same fields. - A sealed type is a fixed set of types. Each
Successcarries its owntxnIdandamount. EachFailedcarries its own reason.
If the cases carry different data, use a sealed type. If they are just labels, use an enum.
Pattern matching for instanceof (Java 16)
The old way: check, then cast.
if (result instanceof Success) {
Success s = (Success) result;
System.out.println(s.txnId());
}The new way: check and bind in one step.
if (result instanceof Success s) {
System.out.println(s.txnId());
}
Object input = "KA01AB1234";
if (input instanceof String plate && plate.length() == 10) {
System.out.println(plate.substring(0, 4)); // KA01
}plate exists only when the check passes, so the second test can use it safely.
Switch expressions (Java 14)
The old switch was a statement. Without break, execution fell through into the next case, so break was everywhere. The new one is an expression that returns a value.
String dayType = switch (day) {
case SATURDAY, SUNDAY -> "weekend";
case MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY -> "weekday";
};- Arrow
->means no fall through and nobreak. - Several labels can share one case.
- In a switch expression over an enum (with no
default), the compiler checks that every constant is covered. Add a new constant and this stops compiling, which is what you want. - For a block that needs more than one line, use
yield:
int deliveryFee = switch (zone) {
case NEAR -> 0;
case MEDIUM -> 30;
case FAR -> {
int base = 50;
yield isRaining ? base + 20 : base;
}
};Pattern matching for switch (Java 21)
This is where sealed types pay off. You can switch on the type of the object. The compiler knows every permitted subtype, so it checks that you handled all of them. No default is needed.
static String describe(PaymentResult result) {
return switch (result) {
case Success s -> "Paid Rs " + s.amount() + ", txn " + s.txnId();
case Failed f when f.retryable() -> "Failed, retrying: " + f.reason();
case Failed f -> "Failed: " + f.reason();
case Pending p -> "Pending, check again in " + p.checkAfterSeconds() + "s";
};
}
describe(new Success("UPI-88231", new BigDecimal("499"))); // Paid Rs 499, txn UPI-88231
describe(new Failed("Bank server down", true)); // Failed, retrying: Bank server downcase Failed f when f.retryable() matches a Failed only if the extra test is true. It must come before the plain Failed case, or it would never be reached.
If someone adds Refunded to the permits list, every switch like this fails to compile until it handles Refunded. With an if/else chain or a default branch, the new case would slip through silently. (One edge case: a library that owns PaymentResult adds a subtype and your code is not recompiled. The switch then throws MatchException at runtime, instead of quietly doing the wrong thing.)
Record patterns (also Java 21) let you take the fields out of a record right in the case:
case Success(String txnId, BigDecimal amount) -> "Paid Rs " + amount;On Java 17, pattern matching for switch is only a preview feature (not final yet), so you must turn it on with --enable-preview. Without it, write the same logic with instanceof:
if (result instanceof Success s) return "Paid Rs " + s.amount();
if (result instanceof Failed f) return "Failed: " + f.reason();
if (result instanceof Pending p) return "Pending";
throw new IllegalStateException("Unknown result: " + result);Switch on type vs polymorphism
Do not replace every interface method with a big switch. A rough rule:
- If each type should do something different and new types get added often, put a method on the interface. That is the Strategy and Composite style.
- If the set of types is fixed and many callers each react in their own way (show a screen, log, retry, notify), a sealed type with switch keeps each caller's logic in one place.
PaymentResult is a good fit for the second case. The payment service, the UI and the notification service all react to it in their own way.
var for local variables (Java 10)
var lets the compiler work out the type of a local variable from the right hand side. It is still statically typed: the type is fixed at compile time.
var ordersByRestaurant = new HashMap<String, List<Order>>(); // clearly a HashMap
for (var entry : ordersByRestaurant.entrySet()) { ... } // saves a long typeRules: only local variables, for loops and lambda parameters (Java 11). Not fields, method parameters or return types. It needs a value on the right (an initializer). var x = null; does not compile, because null has no type to work out.
When it hurts:
var result = paymentService.process(order); // what type is result?
var items = new ArrayList<>(); // ArrayList<Object>, not what you meant
var total = 0; // int, so total += 99.5 quietly gives 99In the last line, total is an int, so += quietly cuts off the decimal part.
Use var when the type is obvious from the right hand side. Write the type when it is not.
Common mistakes
- Calling
getAmount()on a record. The accessor isamount(). - Assuming a record is deeply immutable. Copy lists in the compact constructor.
- Writing
this.currency = ...in a compact constructor. Reassign the parameter instead. The field is assigned for you at the end. - Using records for JPA entities.
- Adding
defaultto a switch over a sealed type. It turns off the exhaustiveness check that was the whole point. - Forgetting
final,sealedornon-sealedon a permitted class. It will not compile. - Using pattern matching for switch on Java 17 without knowing it is a preview there.
vareverywhere, including where the reader cannot see the type.
Interview questions
"What does a record give you for free?"
Private final fields, a canonical constructor, accessor methods named after the fields, and
equals,hashCodeandtoStringbased on all fields. It is final and cannot extend another class.
"Is a record immutable?"
Only shallowly. The fields are final, but if one holds a mutable list, that list can still change. Copy it with
List.copyOfin a compact constructor to make it truly immutable.
"Why would you not use a record for a JPA entity?"
JPA needs a no argument constructor, mutable fields and non final classes so it can create proxies and load data in. Records are final with final fields and no no-arg constructor.
"What is a sealed class and why use one?"
A class or interface that lists exactly which classes may extend it with
permits. It models a closed set of cases, and lets the compiler check that a switch handles every one.
"What is the difference between a sealed interface and an enum?"
An enum is a fixed set of instances that all share the same fields. A sealed interface is a fixed set of types, and each type can carry its own different data, like
Successwith a transaction id andFailedwith a reason.
"Why avoid default when switching over a sealed type?"
Without
default, the compiler checks every permitted subtype is handled, so adding a new subtype breaks the build where it needs attention. Adefaulthides the new case and it fails quietly at runtime.
"When does var hurt readability?"
When the type is not obvious from the right side, like
var result = service.process(order). It also has traps, such asvar list = new ArrayList<>()givingArrayList<Object>.
Where you see it in this repo
Records are already used in the newer LLD projects, for example User, Expense and Transfer in LLD/Splitwise, TimeSlot and Booking in LLD/MeetingRoomBooking, and Decision and Rule in LLD/RateLimiter. There is no sealed, var or pattern switch in OOOD, LLD or DesignPatterns, and the switch blocks there are the old statement form. Places where they would fit:
LLD/ParkingLot/src/domain/Payment.javauses aPaymentStatusenum ofPENDING,SUCCESS,FAILEDwithmarkAsSuccess()andmarkAsFailed().Paymentitself should stay a normal class, since it has a lifecycle. But the answer a payment gateway sends back fits aPaymentResultsealed interface, since a success has a transaction id and a failure has a reason.LLD/Zomato/src/strategies/PaymentStrategy.javahasvoid pay(double amount). Returning aPaymentResultinstead would let checkout react to failures.- Value objects like a
Moneyrecord would replace thedoublemoney fields inParkingLotandZomato.OOOD/01-parking-lotalready usesBigDecimal, which is the right base for one. instanceofchains:CompositeDesignPattern/Before/Main.javachecksinstanceof Productandinstanceof ProductBundleby hand. The After version fixes it with polymorphism, which is the right call there, since each item should calculate its own price.
Related: 14-Enums.md for when an enum is enough, 15-EqualsAndHashCode.md for the methods records write for you, and 19-StringsAndImmutability.md for immutable classes written by hand.