EthicsComputerCourses
Join training +91 90532 10052

Clean Code & Exception Best Practices

Java · Lesson 41 of 44 Advanced 8 min read Updated

Code is read far more often than it is written. The method you write today will be read by your teammates during code review, by you in six months while fixing a bug, and by a new joiner in Bengaluru who has never seen the project. Clean code is code that such a reader can understand quickly and change safely. It is not about clever tricks; it is about removing everything that makes a reader stop and think "what does this mean?".

In this lesson you will refactor a messy method step by step, learn the habits behind clean Java (names, small methods, guard clauses, immutability, avoiding null), and then the exception practices that real projects use: validate early, translate low-level exceptions at layer boundaries, and handle errors in one place. You already know the mechanics from Exceptions; this lesson is about using them well.

Refactoring a messy method

Here is working code of the kind you will find in many projects. It filters exam results and builds labels:

Java
import java.util.ArrayList;
import java.util.List;

public class Before {
    // Works, but hard to read, test or change
    static List<String> proc(List<String[]> d, boolean f) {
        List<String> r = new ArrayList<>();
        for (String[] x : d) {
            if (x != null) {
                if (x.length == 3) {
                    int m = Integer.parseInt(x[2]);
                    if (m >= 33) {
                        if (f) {
                            if (m >= 75) {
                                r.add(x[0] + " (" + x[1] + ") " + m + " D");
                            }
                        } else {
                            r.add(x[0] + " (" + x[1] + ") " + m + (m >= 75 ? " D" : " P"));
                        }
                    }
                }
            }
        }
        return r;
    }

    public static void main(String[] args) {
        List<String[]> data = List.of(
                new String[]{"Aarav", "Jaipur", "81"},
                new String[]{"Meera", "Kochi", "29"},
                new String[]{"Kabir", "Pune", "64"},
                new String[]{"Isha", "Lucknow", "92"});
        System.out.println(proc(data, false));
        System.out.println(proc(data, true));
    }
}
Output
[Aarav (Jaipur) 81 D, Kabir (Pune) 64 P, Isha (Lucknow) 92 D]
[Aarav (Jaipur) 81 D, Isha (Lucknow) 92 D]

Count the problems: the names proc, d, f, r, x and m say nothing; 33 and 75 are magic numbers with no explanation; x[0] means "name" only by convention; the boolean flag f makes one method do two jobs, and every caller must remember what true means; six levels of nesting hide the logic; and the label is built in two places, so a format change must be made twice. Here is the same behaviour, cleaned up:

Java
import java.util.List;

public class After {
    static final int PASS_MARK = 33;
    static final int DISTINCTION_MARK = 75;

    record Result(String name, String city, int marks) {
        Result {
            if (marks < 0 || marks > 100) {
                throw new IllegalArgumentException("marks must be 0-100 for " + name + ", got " + marks);
            }
        }

        boolean passed() { return marks >= PASS_MARK; }
        boolean distinction() { return marks >= DISTINCTION_MARK; }

        String label() {
            return name + " (" + city + ") " + marks + (distinction() ? " D" : " P");
        }
    }

    static List<String> passList(List<Result> results) {
        return results.stream()
                .filter(Result::passed)
                .map(Result::label)
                .toList();
    }

    static List<String> distinctionList(List<Result> results) {
        return results.stream()
                .filter(Result::distinction)
                .map(Result::label)
                .toList();
    }

    public static void main(String[] args) {
        List<Result> results = List.of(
                new Result("Aarav", "Jaipur", 81),
                new Result("Meera", "Kochi", 29),
                new Result("Kabir", "Pune", 64),
                new Result("Isha", "Lucknow", 92));
        System.out.println(passList(results));
        System.out.println(distinctionList(results));
    }
}
Output
[Aarav (Jaipur) 81 D, Kabir (Pune) 64 P, Isha (Lucknow) 92 D]
[Aarav (Jaipur) 81 D, Isha (Lucknow) 92 D]

The output is identical, which is the definition of refactoring: improving structure without changing behaviour. Before refactoring real code, make sure tests cover it (Unit Testing with JUnit) so you can prove nothing changed. What improved:

  • A record replaced the string array, so name() and marks() are checked by the compiler, and invalid marks are rejected the moment a Result is created.
  • Named constants explain the numbers and live in one place.
  • Two well-named methods replaced the boolean flag. distinctionList(results) reads like English; proc(data, true) did not.
  • The rules (passed, distinction, label) moved next to the data they use, so they are written once.
  • Streams removed the nesting; each line does one thing.

Habits of clean Java

  • Names reveal intent. daysUntilExam, not d; isEligibleForScholarship(), not check(). Classes are nouns, methods are verbs, booleans read as questions. Follow Java conventions: camelCase methods and variables, PascalCase types, UPPER_SNAKE_CASE constants.
  • Small methods that do one thing. If you need "and" to describe a method, split it. A method that fits on one screen is easier to test and name.
  • Guard clauses instead of deep nesting. Handle the bad cases first and return or throw early; the main logic then sits at the left margin.
  • Prefer immutability. Records, final fields and unmodifiable collections cannot be changed by surprise from another class or thread. If a constructor receives a list, store List.copyOf(list) so the caller cannot change your object afterwards.
  • Avoid returning null. Return an empty list for "nothing found" in a collection, Optional for a single value that may be missing, and throw an exception when absence is a real error.
  • Comments explain why, not what. // GST is charged on the discounted price, as per the invoice rules helps. // add 1 to i does not. If a comment explains what confusing code does, rename and restructure until the comment is unnecessary.
  • Document public APIs with Javadoc: what a method returns, what each parameter means, and which exceptions it throws (@param, @return, @throws). The exercise solution below shows the style.
  • Let tools help. A formatter keeps style consistent, and static analysis tools such as SpotBugs, Checkstyle, PMD or SonarQube catch common bugs and smells automatically on every build.

Exception best practices

Good exception handling follows a simple shape: fail fast, translate at boundaries, handle once at the top. In this example, a repository reads students from a file, a service applies business rules, and a top-level handle method plays the role of a web controller:

Java
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Objects;

public class Layers {
    // Unchecked exceptions that speak the language of the application
    static class AppException extends RuntimeException {
        AppException(String message, Throwable cause) { super(message, cause); }
        AppException(String message) { super(message); }
    }
    static class StorageException extends AppException {
        StorageException(String message, Throwable cause) { super(message, cause); }
    }
    static class StudentNotFoundException extends AppException {
        StudentNotFoundException(String roll) { super("No student with roll number " + roll); }
    }

    record Student(String roll, String name) {}

    // Lowest layer: translates IOException into the application's own exception
    static class FileStudentRepository {
        private final Path file;

        FileStudentRepository(Path file) {
            this.file = Objects.requireNonNull(file, "file");
        }

        List<Student> findAll() {
            try {
                return Files.readAllLines(file).stream()
                        .map(line -> line.split(","))
                        .map(p -> new Student(p[0], p[1]))
                        .toList();
            } catch (IOException e) {
                throw new StorageException("Could not read students from " + file, e);
            }
        }
    }

    // Service: business rules, no try/catch needed
    static class StudentService {
        private final FileStudentRepository repository;

        StudentService(FileStudentRepository repository) {
            this.repository = repository;
        }

        Student get(String roll) {
            return repository.findAll().stream()
                    .filter(s -> s.roll().equals(roll))
                    .findFirst()
                    .orElseThrow(() -> new StudentNotFoundException(roll));
        }
    }

    // Top level: the one place that turns exceptions into messages for people
    static String handle(StudentService service, String roll) {
        try {
            return "Found: " + service.get(roll).name();
        } catch (StudentNotFoundException e) {
            return "404: " + e.getMessage();
        } catch (StorageException e) {
            String cause = e.getCause().getClass().getSimpleName();
            return "500: " + e.getMessage() + " (cause: " + cause + ")";
        }
    }

    public static void main(String[] args) throws IOException {
        Path file = Files.createTempFile("students", ".csv");
        Files.write(file, List.of("R1,Ananya", "R2,Farhan"));

        StudentService ok = new StudentService(new FileStudentRepository(file));
        System.out.println(handle(ok, "R2"));
        System.out.println(handle(ok, "R9"));

        StudentService broken = new StudentService(new FileStudentRepository(Path.of("missing.csv")));
        System.out.println(handle(broken, "R1"));

        try {
            new FileStudentRepository(null);
        } catch (NullPointerException e) {
            System.out.println("Fail fast: " + e.getMessage());
        }
        Files.delete(file);
    }
}
Output
Found: Farhan
404: No student with roll number R9
500: Could not read students from missing.csv (cause: NoSuchFileException)
Fail fast: file

The rules behind this design:

  1. Fail fast. Check arguments where they enter: Objects.requireNonNull, validation in constructors and compact constructors. A clear error at the entrance beats a NullPointerException ten calls later.
  2. Translate at layer boundaries. The repository catches the low-level IOException and throws StorageException, keeping the original as the cause. Callers never depend on whether storage is a file, MySQL or a web service. Spring does the same, turning SQLException into its DataAccessException family.
  3. Prefer unchecked exceptions for application errors. Most modern frameworks do. Checked exceptions force every layer to declare or wrap them even when it cannot do anything useful. Use checked exceptions only when the caller can realistically recover.
  4. Create a small hierarchy of meaningful exceptions. StudentNotFoundException maps naturally to "404 Not Found", StorageException to "500". Do not create one exception class per method.
  5. Handle and log once, at the top. The service has no try at all. Logging and rethrowing in every layer fills the logs with the same error five times.
  6. Write messages that help the person on call. Include the values involved (roll number R9, the file name) but never passwords, OTPs or full card numbers.
  7. Never swallow. An empty catch block hides bugs. If ignoring an exception really is correct, write a comment saying why.

Code review checklist

Before you open a pull request, read your own diff once with this list:

CheckAsk yourself
NamesWould a new teammate understand every name without asking?
SizeIs any method longer than a screen or doing two jobs?
DuplicationIs the same logic written in two places?
ErrorsAre inputs validated, exceptions specific, causes kept?
NullsCan anything return or accept null unexpectedly?
TestsDo tests cover the new behaviour and the edge cases?
LeftoversAny debug prints, commented-out code or unused imports?

Common mistakes

  • Boolean flag parameters. send(order, true, false) hides meaning; write two methods or pass an enum.
  • Catching Exception or Throwable everywhere. Catch what you can handle; let the rest reach the top-level handler.
  • Losing the cause. throw new StorageException("failed") without e throws away the real reason.
  • Returning null for "empty". Return List.of() or Optional.empty().
  • Commented-out code. Delete it; version control remembers it.
  • Refactoring without tests. You cannot prove that behaviour stayed the same.

Key points

  • Clean code is easy to read and safe to change; refactoring improves structure without changing behaviour.
  • Use intention-revealing names, named constants, small single-purpose methods and guard clauses.
  • Prefer records and immutable data; avoid returning null.
  • Fail fast with validation, translate low-level exceptions at layer boundaries (keeping the cause), and handle them once at the top.
  • Prefer unchecked, meaningful application exceptions with helpful, safe messages.
  • Review your own diff with a checklist before asking others to.

Exercise

Refactor this ATM method. It uses double for money, a cryptic name, a magic number, deep nesting, and returns null for every kind of failure, so callers cannot tell what went wrong:

Java
static String w(double b, String a, int t) {
    try {
        double x = Double.parseDouble(a);
        if (x > 0) {
            if (x <= b) {
                if (t < 5) { return "OK " + (b - x); }
                else return null;
            } else return null;
        } else return null;
    } catch (Exception e) { return null; }
}

Write BigDecimal withdraw(BigDecimal balance, String amountText, int withdrawalsToday) with a named constant for the daily limit, guard clauses, IllegalArgumentException (keeping the cause) for text that is not a positive amount, and a custom unchecked WithdrawalRejectedException for the limit and for insufficient balance. Add Javadoc, then try "1500", "two thousand", "-100", "9000" and a sixth withdrawal on a balance of Rs 5000.00.

Show solution
Java
import java.math.BigDecimal;

public class Atm {
    static final int MAX_WITHDRAWALS_PER_DAY = 5;

    static class WithdrawalRejectedException extends RuntimeException {
        WithdrawalRejectedException(String message) { super(message); }
    }

    /**
     * Returns the new balance after withdrawing the amount typed by the customer.
     *
     * @throws IllegalArgumentException if the text is not a positive amount
     * @throws WithdrawalRejectedException if the daily limit or the balance does not allow it
     */
    static BigDecimal withdraw(BigDecimal balance, String amountText, int withdrawalsToday) {
        BigDecimal amount = parseAmount(amountText);
        if (withdrawalsToday >= MAX_WITHDRAWALS_PER_DAY) {
            throw new WithdrawalRejectedException("daily limit of " + MAX_WITHDRAWALS_PER_DAY + " withdrawals reached");
        }
        if (amount.compareTo(balance) > 0) {
            throw new WithdrawalRejectedException("balance Rs " + balance + " is less than Rs " + amount);
        }
        return balance.subtract(amount);
    }

    private static BigDecimal parseAmount(String text) {
        BigDecimal amount;
        try {
            amount = new BigDecimal(text.strip());
        } catch (NumberFormatException e) {
            throw new IllegalArgumentException("'" + text + "' is not an amount", e);
        }
        if (amount.signum() <= 0) {
            throw new IllegalArgumentException("amount must be positive, got " + amount);
        }
        return amount;
    }

    public static void main(String[] args) {
        BigDecimal balance = new BigDecimal("5000.00");
        Object[][] tries = {{"1500", 0}, {"two thousand", 1}, {"-100", 1}, {"9000", 1}, {"500", 5}};
        for (Object[] t : tries) {
            try {
                BigDecimal after = withdraw(balance, (String) t[0], (Integer) t[1]);
                System.out.println("OK, new balance Rs " + after);
            } catch (IllegalArgumentException | WithdrawalRejectedException e) {
                System.out.println("Rejected: " + e.getMessage());
            }
        }
    }
}
Output
OK, new balance Rs 3500.00
Rejected: 'two thousand' is not an amount
Rejected: amount must be positive, got -100
Rejected: balance Rs 5000.00 is less than Rs 9000
Rejected: daily limit of 5 withdrawals reached

Every failure now has its own clear message, the happy path reads straight down without nesting, and BigDecimal keeps the paise exact. The Object[][] table in main is only a quick demonstration; in a real project these cases would be a parameterized JUnit test.

Quick Quiz

1. What does "refactoring" mean?

2. Why is a method like report(data, true) considered unclean?

3. A repository catches IOException. What is the best practice?

4. What should a search method return when it finds no matching items?

Learn Java with a mentor

Free lessons get you started. Our Java, C & C++ Programming training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call