Errors, validation and the Null Object
Part of the free Low-Level Design course on LogicWiz, in the chapter: Code that survives review. The class-diagram exercises run in the app.
Errors, validation and the Null Object
"Your method returns null for an unknown coupon. The crash happens in the payment step, three classes away."
Priya, reading a stack trace
The null that travelled
SwiftKart lets a customer type a coupon code. Arjun wrote the lookup like this.
public Coupon findCoupon(String code) {
return coupons.get(code); // null when the code is unknown
}
// in the payment step, three calls later
Money off = cart.coupon().discountFor(cart); // NullPointerException
A customer typed FLAT5O, with the letter O in place of the zero. The lookup returned null. The cart stored it, and nothing failed. Minutes later the payment step called a method on null, and Java threw a NullPointerException. The stack trace pointed at the payment step, not at the lookup. The mistake and the symptom were far apart, so debugging meant walking backwards through the code.
Arjun said: "I return null when the coupon is missing, and the caller will check." Priya gave three reasons this fails. The compiler does not force anyone to check. One caller out of nine forgets, today or when a new teammate joins. Second, null does not say why: was the code unknown, expired or mistyped? Third, null spreads. It gets stored in a field and passed on, and it fails later, far from its cause.
This lesson is about designing failure: where to check, what a failure looks like, and how to say "nothing here" without null.
Fail fast at the boundary, with your own exceptions
A boundary is a place where outside data enters your code, such as a public method or a constructor. To validate is to check that the data obeys the rules. To fail fast is to stop at the first sign of a problem, with a clear error, instead of carrying bad data forward. It is like airport security at the entrance. Finding a problem there is cheap, and finding it at the aircraft door is not. The comparison stops working in one place: do not repeat every check in every layer. Check once at the boundary, then trust the types. A Money is already non-negative, so code that holds one does not check again.
public static Order place(Cart cart, Address address) {
Objects.requireNonNull(cart, "cart");
Objects.requireNonNull(address, "address");
if (cart.isEmpty()) {
throw new IllegalArgumentException("Cannot place an order with an empty cart");
}
return new Order(cart, address);
}
public void markPaid() {
if (status != OrderStatus.PLACED) {
throw new IllegalStateException("Order " + id + " is " + status + ", so it cannot be paid");
}
status = OrderStatus.PAID;
}
Java has three standard choices. NullPointerException, thrown by Objects.requireNonNull with a name, says a required value is missing. IllegalArgumentException says the caller passed a bad argument. IllegalStateException says the object is in the wrong state for this call. Put the bad value in the message, so the log tells you what happened.
Sometimes the caller can act on the failure, for example by offering another store. Then write your own exception. Name it after the business problem, such as OutOfStockException. A name like OrderServiceException names a place and tells nobody anything. Let it carry the data, and give your exceptions one shared parent.
public class SwiftKartException extends RuntimeException {
public SwiftKartException(String message) { super(message); }
public SwiftKartException(String message, Throwable cause) { super(message, cause); }
}
public class OutOfStockException extends SwiftKartException {
private final String sku;
private final int available;
public OutOfStockException(String sku, int requested, int available) {
super("Only " + available + " of " + sku + " left, but " + requested + " were requested");
this.sku = sku;
this.available = available;
}
public String sku() { return sku; }
public int available() { return available; }
}
When you catch a low-level failure and throw your own, pass the original as the cause, as the second constructor allows. The log then shows both stories.
Now the choice between two kinds. A checked exception extends Exception directly, and the compiler forces every caller to catch it or declare it with throws. An unchecked exception extends RuntimeException and carries no such rule. Java's own library uses checked exceptions where the world can fail, such as IOException for a file. For a business domain, many teams choose unchecked exceptions. Suppose PaymentMethod.pay threw a checked exception. Every implementation and every caller up the chain would have to declare it, so one new failure changes many signatures. Use a checked exception only when callers can truly recover and you want the compiler to remind them. Both choices can be defended. In a machine-coding round, pick unchecked and say why in one sentence.
Optional, an exception or a result object
There are three ways to say "this did not work". Choose by what the caller needs.
| The situation | Use | SwiftKart example |
|---|---|---|
| Finding nothing is normal | Optional |
Look up a coupon by code |
| The caller broke a rule and cannot go on | Exception | Paying an order twice |
| Failure is common and the user needs the reason | Result object | Coupon rejected: expired, minimum not met |
An Optional is a box that holds one value or is empty. A method that returns Optional<Coupon> tells the caller, in its type, that the coupon may be missing.
Coupon coupon = repo.find(code)
.orElseThrow(() -> new UnknownCouponException(code));
public record CouponCheck(boolean valid, String reason) {
public static CouponCheck ok() { return new CouponCheck(true, ""); }
public static CouponCheck rejected(String reason) { return new CouponCheck(false, reason); }
}
Use Optional for return types only, not for fields or parameters. Never return null from a method whose type is Optional. Prefer orElse, orElseThrow, map and ifPresent over get(), which throws if the box is empty.
A customer who types an expired coupon is not an exception. It happens daily, and the screen must show the reason. A result object carries both the outcome and the reason, as CouponCheck does, so the caller reads it like any other value. Exceptions that jump out of normal flow suit rare, broken-rule cases.
One more rule: never return null for a collection. A method such as ordersOf(customerId) should return List.of() when there are none. An empty list means "none", and loops and size() work with no check.
The Null Object: a NoDiscount instead of null
A cart has no coupon most of the time, so its coupon field is null. See what that does to the code.
Money total = subtotal;
if (cart.coupon() != null) total = cart.coupon().applyTo(total);
// ... the invoice
if (cart.coupon() != null) line = cart.coupon().label();
// ... the receipt, and the analytics event, with the same check
The same check sits in four places. A fifth place that forgets it crashes. The idea: replace "nothing" with a real object whose behaviour is to do nothing. A gift card with a blank message works like this. The printer prints every card the same way, with no special case for "no message". The comparison stops working when the blank is a mistake. If the customer forgot to write a message, a blank card hides that.
This is the Null Object pattern. It is a strategy whose behaviour is to do nothing. Start with an interface, as in the strategy lesson.
classDiagram
class Cart {
-Discount discount
+total() Money
}
class Discount {
<<interface>>
+applyTo(total) Money
+label() String
}
class PercentDiscount
class NoDiscount
Cart --> Discount
PercentDiscount ..|> Discount
NoDiscount ..|> Discount
public final class NoDiscount implements Discount {
public static final NoDiscount INSTANCE = new NoDiscount();
private NoDiscount() {}
public Money applyTo(Money total) { return total; }
public String label() { return ""; }
}
public class Cart {
private Discount discount = NoDiscount.INSTANCE;
public void apply(Discount d) { this.discount = Objects.requireNonNull(d, "discount"); }
public Money total() { return discount.applyTo(subtotal()); }
}
NoDiscount has no fields, so one shared constant serves every cart. The cart starts with it and refuses null, so no if remains anywhere. The invoice, the receipt and the analytics code call discount.label() and get an empty string.
The pattern has limits:
- It hides absence. If a customer types a code that does not exist, the right response is a message, so use a result object or an exception.
NoDiscountis for "this cart has no coupon", which is a normal state. - It works only when doing nothing is harmless. A
NoPaymentthat "pays" nothing would let an order ship unpaid. Silence there is dangerous. - It adds a class and a constant. For a value read in one place,
Optionalis lighter.
In interviews it fits "no discount", a notifier that sends nothing, or a logger that prints nothing. Try it: the starter has a cart that holds a coupon, or null. Put a Discount interface in between, with a real discount and a NoDiscount behind it.
Cleaning up, and what the interviewer reads
A resource is something your code must release, such as a file, a database connection or a network socket. Release must happen even when an error occurs. The block called finally runs after try whether or not an exception was thrown. Java has a shorter form, try-with-resources: declare the resource in parentheses, and Java calls its close() method for you. The class must implement AutoCloseable.
class ReceiptWriter implements AutoCloseable {
private final String orderId;
ReceiptWriter(String orderId) { this.orderId = orderId; System.out.println("open " + orderId); }
void write(String line) {
if (line.isBlank()) throw new IllegalArgumentException("Empty receipt line");
System.out.println("write " + line);
}
@Override public void close() { System.out.println("close " + orderId); }
}
try (ReceiptWriter w = new ReceiptWriter("A1")) {
w.write("Milk x2");
w.write(" ");
} catch (IllegalArgumentException e) {
System.out.println("failed: " + e.getMessage());
}
It prints open A1, write Milk x2, close A1 and then failed: Empty receipt line. The resource closes before the catch runs. With several resources, they close in reverse order. If close() also fails, that second error is attached to the first one, so the first is not lost.
⚠️ Warning: Catch an exception only when you can act on it: show a message, retry, or add context and rethrow with the cause. An empty
catchblock hides the failure, ande.printStackTrace()is not handling.
In a machine-coding round the interviewer reads your unhappy paths. They notice:
- inputs checked once, at the boundary, with the bad value in the message,
- no method that returns
nullfor a missing object or a collection, - two or three exceptions named for the business, not twelve,
- no empty
catch, - a
mainthat triggers one failure on purpose, such as booking a full slot, and prints the message.
That last point costs a minute and shows you thought about failure, which gives the interviewer a good question to ask.
📌 Summary: Check input once at the boundary and fail fast with a named exception. Return Optional, a result object or an empty list, never null. Use a Null Object when doing nothing is the right behaviour.
What Arjun learns next
Arjun's code now fails clearly. Priya's next review question is about a list he used to look up slots: what does that choice cost when there are thousands of orders? Next: choosing data structures, and what they cost.
Check your understanding
- findCoupon returns null for an unknown code, and one caller forgets to check. Which change gives the best protection?
- A customer types an expired coupon. This happens every day, and the screen must show why it was refused. What fits best?
- Which of these is a safe Null Object for SwiftKart?
- In try (ReceiptWriter w = new ReceiptWriter("A1")) { w.write(" "); } catch (IllegalArgumentException e) { ... }, when does close() run?
- Order.markPaid() is called on an order that is already PAID. Which exception describes this best?