The app that read error messages
Riya's team builds a wallet app. When a payment fails, the backend throws IllegalStateException("Insufficient funds: short by Rs 2500"). To show a “Add Rs 2,500” button, the app developer cuts the number out of the message text. One day someone changes the message to “Low balance, need 2500 more” and the button breaks for every user.
Sir, the button broke because somebody improved an error message. How can a message change break code?
Because the code was reading data out of a sentence. Messages are for humans. If the app needs a number, the exception should carry it as a field.
But IllegalStateException doesn't have a shortfall field.
So write your own: class InsufficientFundsException extends Exception with a shortfall field and a getter. Its name tells the whole story, and a catch can pick it out by type.
What is a custom exception?
A custom (user-defined) exception is a class that extends Exception (making it checked) or RuntimeException (making it unchecked), created to represent an application-specific error condition. It usually provides constructors that pass a message and/or a cause to the superclass with super(...), and may declare additional fields and getters that describe the failure. It is thrown with throw and caught by its type like any other exception.
A custom exception is your own named alarm. Its name says what happened (InsufficientFunds, OutOfStock), its message explains it to people, and its fields give code the details it needs to react.
Words you'll meet in this lesson
javac -Xlint:serial warns if missing.Designing one at a glance
| Parent class | Exception → checked (callers must handle). RuntimeException → unchecked (callers may handle). |
|---|---|
| Name | A noun ending in Exception that says what happened: InsufficientFundsException, not BankError or MyException |
| Constructors | At least (String message) and (String message, Throwable cause); build the message from fields when they exist |
| Fields | private final with getters only: exceptions should be immutable |
| Inherited for free | getMessage(), getCause(), printStackTrace(), toString(), the stack trace |
| Don't create one | when a built-in already says it: bad argument → IllegalArgumentException, wrong state → IllegalStateException |
Exam tip: “How do you create a user-defined exception?” Answer: extend Exception (checked) or RuntimeException (unchecked), call super(message) in the constructor, and throw it with throw new MyException("…"). Write all three parts in the answer.
Checked or unchecked, simple or data-carrying
Checked: extends Exception
For business outcomes every caller must think about: insufficient funds, seat already booked, KYC pending.
compiler enforcesUnchecked: extends RuntimeException
For rule violations usually handled at one boundary: invalid order, expired coupon, limit exceeded. The modern default in many teams.
no throws neededData-carrying
Adds fields such as shortfall, item, errorCode or retryable, so callers decide without parsing text.
// Checked: callers must handle it
class KycPendingException extends Exception {
public KycPendingException(String message) {
super(message);
}
}
// Unchecked: carries data, callers may handle it
class DailyLimitException extends RuntimeException {
private final long remaining;
public DailyLimitException(long remaining) {
super("Daily limit reached, Rs " + remaining + " left today");
this.remaining = remaining;
}
public long getRemaining() {
return remaining;
}
}
public class Main {
static void send(boolean kycDone, long amount) throws KycPendingException {
if (!kycDone) {
throw new KycPendingException("Complete KYC to send money");
}
long left = 10000;
if (amount > left) {
throw new DailyLimitException(left);
}
System.out.println("Sent Rs " + amount);
}
public static void main(String[] args) {
try {
send(true, 2000);
send(false, 500);
} catch (KycPendingException e) {
System.out.println("Checked: " + e);
}
try {
send(true, 15000);
} catch (KycPendingException e) {
System.out.println("not reached");
} catch (DailyLimitException e) {
System.out.println("Unchecked: " + e);
System.out.println("Remaining: " + e.getRemaining());
}
}
}Both classes sit in the same file as Main (only one class per file can be public). send must declare the checked one; the unchecked one needs no declaration. Printing e shows your class's name, which is why a good name matters.
| Situation | Better choice | Why |
|---|---|---|
| Negative quantity passed to a method | Built-in IllegalArgumentException | Everyone already knows what it means |
| Withdrawal larger than the balance | Custom InsufficientFundsException with shortfall | A business case callers handle specially, with data |
| Item out of stock during checkout | Custom OutOfStockException with item and available units | The UI suggests alternatives from the fields |
| Database driver throws SQLException | Wrap in your RepositoryException(msg, cause) | Hides the technology, keeps the cause |
| “Something went wrong” for everything | Not a custom AppException alone | A single generic type tells callers nothing |
Always offer a (message, cause) constructor. If your exception only has (String message), anyone wrapping a lower-level exception in it is forced to throw the original away, and the “Caused by:” part of the stack trace disappears.
Building InsufficientFundsException
Pick the parent and a clear name
Extendingclass InsufficientFundsException extends Exception {Exceptionmakes it checked. BecauseThrowableimplementsSerializable, so does your class;javac -Xlint:serialwarns that it has noserialVersionUID. Addprivate static final long serialVersionUID = 1L;if the exception may travel between JVMs.Add the data as final fields
The field lives inside the exception object on the heap, next to the fields inherited fromprivate final long shortfall;Throwable(message, cause, stack trace).finalmeans it can't change after the object is built.Write the constructors
public InsufficientFundsException(String message, long shortfall) { super(message); this.shortfall = shortfall; }super(message)runsException's constructor first, which stores the message and callsfillInStackTrace(). The recorded trace starts at the line withnew; the constructor frames themselves are left out.Add getters (no setters)
Callers read the value with a normal method call on the caught object.public long getShortfall() { return shortfall; }getMessage(),getCause()andtoString()are inherited.Throw it and catch it by type
To the JVM it's an ordinary exception: same handler search, same frame popping. The exception table simply names your class, so only a catch for it (or a parent) matches.throw new InsufficientFundsException("Need Rs " + amount + ", have Rs " + balance, amount - balance); … catch (InsufficientFundsException e) { … e.getShortfall() … }
A custom exception is born, thrown and read: both views
Left: the code and the console. Right: the stack, and the exception object with its inherited and its own fields.
- Rule broken: 5000 is more than the balance of 2500, so
withdrawdecides to throw. - new: creating the exception runs your constructor with the shortfall 2500.
- super: the first line hands the message to
Exception, which stores it and records the stack trace. - Field: the constructor saves
shortfall = 2500in the object. - Throw and catch: the object is thrown; the catch for
InsufficientFundsExceptionreceives it. The message prints. - Use the data:
getShortfall()returns 2500, so the app can show an exact top-up button. No text parsing.
- main has called
withdraw(5000). Its frame is on top of main's. newallocates object #1 with every field at its default value, then pushes a frame for your constructor.super(message)runs Throwable's constructor: it sets the message and fills in the stack trace (withdraw, main; the constructor frames are skipped).- Back in your constructor,
this.shortfall = shortfallstores 2500. The constructor then returns. throwstarts the handler search. withdraw has no matching catch, so it is popped; main's catch matches andenow refers to #1.getShortfall()is a normal instance method call on #1 that returns the field. The data travelled with the exception.
Custom exceptions in real systems
Most large Java applications define a small set of business exceptions. Their names and fields become part of the application's vocabulary.
Watch it with an example: a delivery app that offers pickup instead
A food-delivery backend can't deliver to some PIN codes. Its custom exception carries the PIN and the nearest hub, so the app can offer pickup without reading any message text.
- The order: a customer in PIN 400099 orders biryani from a restaurant that delivers only to nearby areas.
- Rule broken: the order service finds the PIN isn't served and throws
NotServiceableException, a class written by the team. - Inside the object: its constructor passes the message to
superand stores two extra fields,pincodeandnearestHub. - Caught by type: the app's catch for
NotServiceableExceptionmatches. Other errors would go to other handlers. - Data, not text:
getNearestHub()returns "Andheri". If someone rewords the message tomorrow, this still works. - Better result: instead of a dead end, the customer gets a pickup offer. That is the point of a custom exception: a name for the situation and the data to handle it.
1. Mapping to API responses
A REST API maps each business exception to a status and a JSON body: InsufficientFundsException → a clear error code and the shortfall, OutOfStockException → the available quantity. One central handler does the mapping.
2. Exception families per module
A payments module defines PaymentException with subclasses (declined, timeout, limit). Callers can catch the whole family or one member, and new members don't break old catch blocks.
3. Error codes for dashboards and support
An errorCode field (like QTY_LIMIT or KYC_PENDING) lets monitoring count failures by kind and lets support staff look up a fix, whatever the message wording.
4. Hiding technology behind your own type
A repository catches the database driver's exceptions and throws RepositoryException(msg, cause). Service code no longer depends on which database is used, and the cause keeps the detail.
Examples are simplified. Frameworks such as Spring provide the central handler; the exception design (names, fields, causes) is up to your team.
The wallet, with a proper InsufficientFundsException
class InsufficientFundsException extends Exception {
private final long shortfall;
public InsufficientFundsException(String message, long shortfall) {
super(message);
this.shortfall = shortfall;
}
public long getShortfall() {
return shortfall;
}
}
public class Main {
static long balance = 3000;
static void withdraw(long amount) throws InsufficientFundsException {
if (amount > balance) {
throw new InsufficientFundsException(
"Need Rs " + amount + ", have Rs " + balance, amount - balance);
}
balance -= amount;
}
public static void main(String[] args) {
long[] requests = {1000, 5000, 2000};
for (long r : requests) {
try {
withdraw(r);
System.out.println("Withdrew Rs " + r + ", balance Rs " + balance);
} catch (InsufficientFundsException e) {
System.out.println(e.getMessage() + " -> add Rs " + e.getShortfall());
}
}
}
}extends ExceptionChecked:withdrawmust declare it and main must catch it.super(message)Gives the message to Exception, so getMessage() and the stack trace work.private final long shortfallData for code; set once in the constructor, read with a getter.e.getShortfall()The catch block uses the number directly, whatever the message says.
Your constructor runs, then the exception travels
Watch a frame for CouponExpiredException's constructor appear on top of the stack while the object is built, before the throw pops apply.
Worked examples: real problems, solved and traced in memory
Three business exceptions designed from scratch. Run each solution, then step through it: you will see the custom exception's own constructor frame being pushed when it is created, then the frames popped as it travels to the catch block.
InsufficientFundsException with a shortfall
Problem statement
A transfer withdraws from the sender's account. If the balance is too low, withdraw throws a checked InsufficientFundsException that carries the shortfall, so the app can say exactly how much to add.
balance = Rs 4,000 transfer Rs 6,500
Transfer failed: Short by Rs 2500 Add Rs 2500 to continue
Approach
- Define
InsufficientFundsException extends Exceptionwith ashortfallfield, set in the constructor. withdrawdeclaresthrows InsufficientFundsException; so doestransfer, which doesn't catch it.maincatches it and readsgetShortfall()to build the advice.
Solution: run it live
What happens in memory, step by step
Recorded from the real JVM: press Next to execute one line at a time. Watch method calls push and pop frames on the stack, and objects (numbered #1, #2 …) appear on the heap.
An unchecked InvalidOrderException with an error code
Problem statement
The checkout validates each order line. Problems are reported with an unchecked InvalidOrderException that holds a short error code (for the app and analytics) as well as a message (for people).
lines: qty 0, qty 15 (limit is 10 per item)
qty 0: [QTY_ZERO] Quantity cannot be 0 qty 15: [QTY_LIMIT] Max 10 per item, got 15
Approach
InvalidOrderException extends RuntimeException: no throws clauses needed anywhere.checkLinethrows it with codesQTY_ZEROorQTY_LIMIT.mainloops over lines and usesgetCode()in its message.
Solution: run it live
What happens in memory, step by step
Recorded from the real JVM: press Next to execute one line at a time. Watch method calls push and pop frames on the stack, and objects (numbered #1, #2 …) appear on the heap.
PaymentFailedException that keeps the cause
Problem statement
A payment service talks to a bank API. A low-level failure (here an IllegalStateException from the API client) is wrapped in your own PaymentFailedException with the transaction id in the message and the original as the cause.
transaction id = "UPI7731", bank API is down
Payment UPI7731 failed Technical cause: Bank API unavailable
Approach
PaymentFailedException(String message, Throwable cause)passes both tosuper.paycatches the low-level exception and throws the business one, with context only it knows.mainsees one business exception type, and can still reach the technical cause.
Solution: run it live
What happens in memory, step by step
Recorded from the real JVM: press Next to execute one line at a time. Watch method calls push and pop frames on the stack, and objects (numbered #1, #2 …) appear on the heap.
Check yourself
Quick revision
Lesson 9.5 in one screen
- Custom exception = a class extending
Exception(checked) orRuntimeException(unchecked). - Constructors call
super(message)/super(message, cause); constructors aren't inherited. - Name it after what happened, ending in
Exception. - Carry data in
private finalfields with getters; build the message from them. - Group related exceptions under a base class; catch subclasses first.
- Use built-ins (IllegalArgumentException, IllegalStateException) when they already say it.
- The stack trace starts at the line with
new; your class name appears in it.
Interview and viva questions
How do you create a custom exception in Java?
Write a class that extends Exception (checked) or RuntimeException (unchecked), add constructors that pass the message and optionally a cause to super, add any fields the caller needs, and throw it with throw new YourException(...).
Should a custom exception be checked or unchecked?
Checked if every caller must consciously handle the situation and can recover (for example insufficient funds in a banking API). Unchecked if it represents a rule violation usually handled at one boundary, or if it must pass through lambdas. Many modern codebases default to unchecked and document the exceptions well.
Why make exception fields final?
An exception describes something that already happened. It is passed around, logged and maybe inspected by several handlers; if it could be modified, one handler could change what another sees. Final fields with getters keep it immutable.
When should you NOT create a custom exception?
When a standard one already expresses the problem (bad argument, wrong state, unsupported operation), when the caller would never handle it differently from other errors, or when you'd create one class per message. Too many exception types make code harder to read, not easier.
What is serialVersionUID and do exceptions need it?
Throwable implements Serializable, so every exception class is serializable. serialVersionUID is a version number used when objects are serialized, for example sent between JVMs. javac warns about it only with -Xlint:serial; add private static final long serialVersionUID = 1L; if your exceptions cross process boundaries.
Practice problems
Practise designing your own exception classes: choosing the parent, writing the constructors, adding useful fields, and catching them by type. The last four are real business tasks.
Your first custom exception
Create an unchecked InvalidAgeException and throw it from checkVoterAge when the age is below 18.
ages = 21, 16
Age 21 is eligible InvalidAgeException: Voter must be 18+, got 16
Show solution and explanation
class InvalidAgeException extends RuntimeException {
public InvalidAgeException(String message) {
super(message);
}
}
public class Main {
static void checkVoterAge(int age) {
if (age < 18) {
throw new InvalidAgeException("Voter must be 18+, got " + age);
}
System.out.println("Age " + age + " is eligible");
}
public static void main(String[] args) {
try {
checkVoterAge(21);
checkVoterAge(16);
} catch (InvalidAgeException e) {
System.out.println(e);
}
}
}How it works: a custom exception is just a class that extends an exception class. The constructor passes the message to the parent with super(message), which is where getMessage() and toString() get it from. Because it extends RuntimeException, no throws clause is needed.
A checked custom exception
Create a checked SeatUnavailableException (extends Exception). bookSeat must declare it, and main must catch it.
booked seats: 12; requests: 7, 12
Seat 7 booked Sorry: Seat 12 is already booked
Show solution and explanation
class SeatUnavailableException extends Exception {
public SeatUnavailableException(String message) {
super(message);
}
}
public class Main {
static int bookedSeat = 12;
static void bookSeat(int seat) throws SeatUnavailableException {
if (seat == bookedSeat) {
throw new SeatUnavailableException("Seat " + seat + " is already booked");
}
System.out.println("Seat " + seat + " booked");
}
public static void main(String[] args) {
int[] requests = {7, 12};
for (int s : requests) {
try {
bookSeat(s);
} catch (SeatUnavailableException e) {
System.out.println("Sorry: " + e.getMessage());
}
}
}
}How it works: extending Exception (not RuntimeException) makes it checked, so the compiler forces every caller of bookSeat to catch it or declare it. Choose this when you want callers to be unable to forget the situation.
The four standard constructors
Give ReportException the four constructors that the JDK's own exceptions have: none, message, message + cause, cause. Create one of each and print its message and cause.
cause = IOException("disk full")message=null | cause=null message=Report failed | cause=null message=Report failed | cause=java.io.IOException: disk full message=java.io.IOException: disk full | cause=java.io.IOException: disk full
Show solution and explanation
import java.io.IOException;
class ReportException extends Exception {
public ReportException() { super(); }
public ReportException(String message) { super(message); }
public ReportException(String message, Throwable cause) { super(message, cause); }
public ReportException(Throwable cause) { super(cause); }
}
public class Main {
public static void main(String[] args) {
IOException io = new IOException("disk full");
ReportException[] all = {
new ReportException(),
new ReportException("Report failed"),
new ReportException("Report failed", io),
new ReportException(io)
};
for (ReportException r : all) {
System.out.println("message=" + r.getMessage() + " | cause=" + r.getCause());
}
}
}How it works: each constructor just passes its arguments to the matching constructor of Exception. Note the last one: with only a cause, the message becomes cause.toString(). The message + cause constructor is the most important one; without it, people wrapping exceptions in yours would lose the original.
A family of exceptions
Create a base ShopException and two subclasses, OutOfStockException and CouponExpiredException. Show that a single catch (ShopException e) handles both, and that a more specific catch placed first handles its own type.
problems thrown: out of stock, coupon expired
Suggest similar items: Kurta size M is out of stock CouponExpiredException: DIWALI20 expired on 05-11
Show solution and explanation
class ShopException extends RuntimeException {
public ShopException(String message) { super(message); }
}
class OutOfStockException extends ShopException {
public OutOfStockException(String message) { super(message); }
}
class CouponExpiredException extends ShopException {
public CouponExpiredException(String message) { super(message); }
}
public class Main {
static void process(int problem) {
if (problem == 1) throw new OutOfStockException("Kurta size M is out of stock");
if (problem == 2) throw new CouponExpiredException("DIWALI20 expired on 05-11");
}
public static void main(String[] args) {
for (int p = 1; p <= 2; p++) {
try {
process(p);
} catch (OutOfStockException e) {
System.out.println("Suggest similar items: " + e.getMessage());
} catch (ShopException e) {
System.out.println(e.getClass().getSimpleName() + ": " + e.getMessage());
}
}
}
}How it works: a common base class lets callers choose: catch the whole family in one place, or pick out one member for special handling. As always, the more specific catch must come first. Large applications often have one base exception per module.
An exception that carries data
An OTP is valid for 120 seconds. Create OtpExpiredException with a field secondsLate and a getter, so the caller can decide what to show without parsing the message.
OTP entered after 95 s, then after 160 s
OTP accepted after 95 s OTP expired 40 s ago Tip: tap 'Resend OTP'
Show solution and explanation
class OtpExpiredException extends RuntimeException {
private final int secondsLate;
public OtpExpiredException(int secondsLate) {
super("OTP expired " + secondsLate + " s ago");
this.secondsLate = secondsLate;
}
public int getSecondsLate() {
return secondsLate;
}
}
public class Main {
static final int VALID_FOR = 120;
static void verify(int secondsTaken) {
if (secondsTaken > VALID_FOR) {
throw new OtpExpiredException(secondsTaken - VALID_FOR);
}
System.out.println("OTP accepted after " + secondsTaken + " s");
}
public static void main(String[] args) {
int[] attempts = {95, 160};
for (int t : attempts) {
try {
verify(t);
} catch (OtpExpiredException e) {
System.out.println(e.getMessage());
if (e.getSecondsLate() < 60) {
System.out.println("Tip: tap 'Resend OTP'");
}
}
}
}
}How it works: the message is for humans and logs; the field is for code. Making it final with only a getter keeps the exception immutable, which matters because exception objects are passed around and logged. The constructor builds the message from the data, so the two never disagree.
Build the message from fields
Instead of building the message at every throw, let LimitExceededException build it in its constructor from the limit and the attempted value.
UPI daily limit Rs 1,00,000; attempts Rs 40,000 and Rs 1,25,000
Paid Rs 40000 Limit Rs 100000 exceeded by Rs 25000 Split into 2 payments of Rs 62500
Show solution and explanation
class LimitExceededException extends RuntimeException {
private final long limit;
private final long attempted;
public LimitExceededException(long limit, long attempted) {
super("Limit Rs " + limit + " exceeded by Rs " + (attempted - limit));
this.limit = limit;
this.attempted = attempted;
}
public long getLimit() { return limit; }
public long getAttempted() { return attempted; }
}
public class Main {
static void pay(long amount) {
long limit = 100000;
if (amount > limit) {
throw new LimitExceededException(limit, amount);
}
System.out.println("Paid Rs " + amount);
}
public static void main(String[] args) {
long[] payments = {40000, 125000};
for (long p : payments) {
try {
pay(p);
} catch (LimitExceededException e) {
System.out.println(e.getMessage());
System.out.println("Split into 2 payments of Rs " + e.getAttempted() / 2);
}
}
}
}How it works: every throw site now writes only new LimitExceededException(limit, amount), and the wording stays consistent everywhere. The caller still gets the raw numbers through getters to compute a suggestion.
Don't lose the cause
Wrap a low-level NumberFormatException in your own InvalidPriceException using a (message, cause) constructor. Print both the message and the cause.
price text = "12,50"
Price "12,50" is not valid Caused by: java.lang.NumberFormatException: For input string: "12,50"
Show solution and explanation
class InvalidPriceException extends RuntimeException {
public InvalidPriceException(String message, Throwable cause) {
super(message, cause);
}
}
public class Main {
static double parsePrice(String text) {
try {
return Double.parseDouble(text);
} catch (NumberFormatException e) {
throw new InvalidPriceException("Price \"" + text + "\" is not valid", e);
}
}
public static void main(String[] args) {
try {
parsePrice("12,50");
} catch (InvalidPriceException e) {
System.out.println(e.getMessage());
System.out.println("Caused by: " + e.getCause());
}
}
}How it works: your exception speaks the language of your application (“price is not valid”), while the cause keeps the technical detail. A custom exception without a cause-accepting constructor forces people to throw the original away, a common review comment.
Withdrawals with InsufficientFundsException
Process a day's withdrawal requests on a savings account. A request larger than the balance throws a checked InsufficientFundsException carrying the shortfall. Print each result and the total amount refused.
opening balance Rs 10,000; requests 2500, 9000, 4000, 3500
Rs 2500 paid, balance Rs 7500 Rs 9000 refused (Insufficient funds: short by Rs 1500) Rs 4000 paid, balance Rs 3500 Rs 3500 paid, balance Rs 0 Total refused today: Rs 9000
Show solution and explanation
class InsufficientFundsException extends Exception {
private final long shortfall;
public InsufficientFundsException(long shortfall) {
super("Insufficient funds: short by Rs " + shortfall);
this.shortfall = shortfall;
}
public long getShortfall() {
return shortfall;
}
}
public class Main {
static long balance = 10000;
static void withdraw(long amount) throws InsufficientFundsException {
if (amount > balance) {
throw new InsufficientFundsException(amount - balance);
}
balance -= amount;
}
public static void main(String[] args) {
long[] requests = {2500, 9000, 4000, 3500};
long refused = 0;
for (long r : requests) {
try {
withdraw(r);
System.out.println("Rs " + r + " paid, balance Rs " + balance);
} catch (InsufficientFundsException e) {
refused += r;
System.out.println("Rs " + r + " refused (" + e.getMessage() + ")");
}
}
System.out.println("Total refused today: Rs " + refused);
}
}How it works:
- Insufficient funds is a normal business outcome every caller must handle, so a checked exception makes it impossible to forget.
- The check happens before the balance changes, so a refused request leaves the account untouched.
- The shortfall travels as data in the exception, ready for a message like “Add Rs 1,500 to continue”.
Out-of-stock items in an order
An order has several lines. reserve throws OutOfStockException with the item name and the units available. Fulfil what you can, and for each failed line print a suggestion to order only the available units.
stock: Rice 10, Dal 3, Oil 0 order: Rice x4, Dal x5, Oil x2
Rice x4 reserved Problem: Dal: requested 5, available 3 Suggest: Dal x3 Problem: Oil: requested 2, available 0 Suggest: remove Oil
Show solution and explanation
class OutOfStockException extends RuntimeException {
private final String item;
private final int available;
public OutOfStockException(String item, int requested, int available) {
super(item + ": requested " + requested + ", available " + available);
this.item = item;
this.available = available;
}
public String getItem() { return item; }
public int getAvailable() { return available; }
}
public class Main {
static String[] items = {"Rice", "Dal", "Oil"};
static int[] stock = {10, 3, 0};
static void reserve(int index, int qty) {
if (qty > stock[index]) {
throw new OutOfStockException(items[index], qty, stock[index]);
}
stock[index] -= qty;
}
public static void main(String[] args) {
int[] orderQty = {4, 5, 2};
for (int i = 0; i < items.length; i++) {
try {
reserve(i, orderQty[i]);
System.out.println(items[i] + " x" + orderQty[i] + " reserved");
} catch (OutOfStockException e) {
System.out.println("Problem: " + e.getMessage());
if (e.getAvailable() > 0) {
System.out.println(" Suggest: " + e.getItem() + " x" + e.getAvailable());
} else {
System.out.println(" Suggest: remove " + e.getItem());
}
}
}
}
}How it works:
- The exception carries exactly what the caller needs (item and available units), so the suggestion logic doesn't have to look anything up again.
- It is unchecked: an order service usually handles it at one boundary, and the type name alone tells you what happened.
- Each line is reserved independently, so one missing item doesn't cancel the whole order.
Retry only what is retryable
A payment gateway can fail in two ways: a timeout (worth retrying) or a declined card (never retry). Model this with PaymentFailedException that has a retryable flag, and retry up to 3 times only when it is true.
payment 1: times out twice, then succeeds payment 2: card declined
Payment 1: Attempt 1: Gateway timeout Attempt 2: Gateway timeout Paid on attempt 3 Payment 2: Attempt 1: Card declined by bank Not retrying
Show solution and explanation
class PaymentFailedException extends RuntimeException {
private final boolean retryable;
public PaymentFailedException(String message, boolean retryable) {
super(message);
this.retryable = retryable;
}
public boolean isRetryable() {
return retryable;
}
}
public class Main {
static int timeoutsLeft = 2;
static void charge(String card) {
if (card.equals("BLOCKED")) {
throw new PaymentFailedException("Card declined by bank", false);
}
if (timeoutsLeft > 0) {
timeoutsLeft--;
throw new PaymentFailedException("Gateway timeout", true);
}
}
static void pay(String card) {
for (int attempt = 1; attempt <= 3; attempt++) {
try {
charge(card);
System.out.println(" Paid on attempt " + attempt);
return;
} catch (PaymentFailedException e) {
System.out.println(" Attempt " + attempt + ": " + e.getMessage());
if (!e.isRetryable()) {
System.out.println(" Not retrying");
return;
}
}
}
System.out.println(" Gave up after 3 attempts");
}
public static void main(String[] args) {
System.out.println("Payment 1:");
pay("4111-XXXX");
System.out.println("Payment 2:");
pay("BLOCKED");
}
}How it works:
- A field on the exception lets the thrower, who knows the reason, tell the caller how to react.
- Retrying a declined card would only annoy the bank and the customer; retrying a timeout often works.
- The alternative, two separate classes (
RetryablePaymentException,PaymentDeclinedException), is also common; both designs keep the decision out of message text.
Config file errors that name the key
Load settings from key=value lines. Any problem must surface as one ConfigException that knows which key failed and keeps the original cause, so the operator knows exactly what to fix.
lines = {"maxUsers=500", "timeoutSec=thirty", "region=ap-south"}Config key 'timeoutSec': expected a whole number, got thirty Fix key: timeoutSec Cause: NumberFormatException
Show solution and explanation
class ConfigException extends RuntimeException {
private final String key;
public ConfigException(String key, String message, Throwable cause) {
super("Config key '" + key + "': " + message, cause);
this.key = key;
}
public String getKey() {
return key;
}
}
public class Main {
static int readInt(String line) {
String[] kv = line.split("=");
try {
return Integer.parseInt(kv[1]);
} catch (NumberFormatException e) {
throw new ConfigException(kv[0], "expected a whole number, got " + kv[1], e);
}
}
public static void main(String[] args) {
String[] lines = {"maxUsers=500", "timeoutSec=thirty", "region=ap-south"};
try {
int maxUsers = readInt(lines[0]);
int timeout = readInt(lines[1]);
System.out.println("Loaded: " + maxUsers + ", " + timeout);
} catch (ConfigException e) {
System.out.println(e.getMessage());
System.out.println("Fix key: " + e.getKey());
System.out.println("Cause: " + e.getCause().getClass().getSimpleName());
}
}
}How it works:
- A low-level
NumberFormatExceptiondoesn't say which setting was wrong; the custom exception adds that context and keeps the cause. - The start-up code catches one type, whatever went wrong inside.
- The key is a field, so a tool could highlight the bad line in the file automatically.
Real-world scenario questions (asked in company interviews)
Exception design questions test whether you think about the people and code that will catch your exceptions. Decide your answer first, then open the model answer.
The button that parsed a message
The mobile app reads the shortfall by cutting the last word out of an exception message. After a wording change, the “Add money” button shows “2500.” with a dot and fails. How would you redesign the backend's exception?
The interviewer is checking: do you separate data for code from text for people?
Show a strong answer
- Create
InsufficientFundsExceptionwith aprivate final long shortfallfield (in paise or as BigDecimal for money) and a getter. - Build the message from the field in the constructor so text and data always agree.
- Have the API's error handler put the field into the JSON response (
"shortfall": 2500), so the app never parses text.
Avoid: treating message strings as an API. They are for logs and humans.
Forty exception classes
A pull request adds 40 new exception classes like NameTooLongException, NameEmptyException, PinTooShortException, each used in one place and all handled identically by showing the message. What feedback do you give?
The interviewer is checking: do you know when a custom exception adds value?
Show a strong answer
- A new type is worth it only if some caller handles it differently or needs its data. Here every handler does the same thing.
- Replace them with one
ValidationExceptioncarrying a field name and an error code (or simply IllegalArgumentException with good messages). - Keep separate types for genuinely different business outcomes, such as OutOfStock or PaymentDeclined.
Avoid: one class per message. It bloats the codebase without helping callers.
Checked or unchecked for a core-banking client?
You are writing a client library other teams will use to call the core banking system. It can fail with insufficient funds, account frozen, or the core system being down. Design the exception types.
The interviewer is checking: can you design a small, useful exception hierarchy and justify checked vs unchecked?
Show a strong answer
- A base
CoreBankingExceptionso callers can catch everything from this library in one place. - Subclasses:
InsufficientFundsException(with shortfall),AccountFrozenException(with reason code),CoreUnavailableException(with a retryable flag, wrapping the network cause). - Business outcomes could be checked so callers can't ignore them; the outage could be unchecked or checked depending on team convention. State the choice and document it.
- Every class gets a (message, cause) constructor.
Avoid: exposing raw low-level exceptions (sockets, JSON parsing) to callers.
Custom exception that hid the real error
Logs show ReportGenerationException: Report failed thousands of times, with traces ending inside your service. The team can't tell whether the database, the template or the PDF library is failing. The exception class has only a (String message) constructor. What do you change?
The interviewer is checking: do you know how custom exceptions should preserve causes and context?
Show a strong answer
- Add a
(String message, Throwable cause)constructor and use it at every wrap site, so the trace shows “Caused by:” with the real failure. - Add context to the message or fields: report id, tenant, stage (query, template, render).
- Optionally add a
stageorerrorCodefield so dashboards can count failures by stage.
Avoid: new ReportGenerationException("Report failed: " + e.getMessage()). It loses the type and the stack trace of the real problem.