Capstone: A Real Java Application

Error Handling & Validation

15 min Lesson 7 of 13

Error Handling & Validation

A production-grade Java application does not merely work when inputs are perfect — it degrades gracefully when they are not, surfaces actionable messages to callers, and never leaks internal state to the outside world. This lesson builds a systematic approach to error handling and validation that spans every layer of the capstone application.

Why a Layered Error Strategy Matters

Each architectural layer has different responsibilities and different failure modes:

  • Domain layer — rejects semantically invalid state (e.g., a negative account balance).
  • Data layer — wraps JDBC/persistence failures in domain-meaningful exceptions.
  • Service/business layer — orchestrates domain rules; signals business violations (e.g., entity not found, duplicate key).
  • Interface layer — converts every exception into a human-readable or machine-readable response; is the only layer that should catch broadly.

Letting SQLException bubble up to the UI, or swallowing exceptions silently, violates this contract and makes the system hard to debug and maintain.

Designing a Custom Exception Hierarchy

Start with a thin hierarchy rooted in a single unchecked base class. Prefer unchecked exceptions for application errors because callers rarely can recover at the call site, and checked exceptions add friction without adding safety in most modern designs.

// Base — every capstone exception extends this public abstract class AppException extends RuntimeException { private final String errorCode; protected AppException(String errorCode, String message) { super(message); this.errorCode = errorCode; } protected AppException(String errorCode, String message, Throwable cause) { super(message, cause); this.errorCode = errorCode; } public String getErrorCode() { return errorCode; } } // ---- Concrete exceptions ---- public class EntityNotFoundException extends AppException { public EntityNotFoundException(String entity, Object id) { super("NOT_FOUND", entity + " with id " + id + " does not exist."); } } public class ValidationException extends AppException { private final List<String> violations; public ValidationException(List<String> violations) { super("VALIDATION_ERROR", "Input validation failed."); this.violations = List.copyOf(violations); } public List<String> getViolations() { return violations; } } public class DuplicateEntityException extends AppException { public DuplicateEntityException(String entity, String field, Object value) { super("DUPLICATE", entity + " with " + field + " '" + value + "' already exists."); } } public class DataAccessException extends AppException { public DataAccessException(String message, Throwable cause) { super("DATA_ACCESS_ERROR", message, cause); } }
Keep errorCode stable and machine-readable. String codes like "NOT_FOUND" let API clients branch on the code without parsing the human message, which can change between releases.

Validation: Fail Fast at the Boundary

Never let invalid data travel deeper than the first layer that can detect it. Write a small validation utility that collects all violations in one pass instead of stopping at the first, so callers receive a complete picture.

public final class Validator { private final List<String> violations = new ArrayList<>(); public Validator requireNonBlank(String value, String fieldName) { if (value == null || value.isBlank()) { violations.add(fieldName + " must not be blank."); } return this; } public Validator requirePositive(Number value, String fieldName) { if (value == null || value.doubleValue() <= 0) { violations.add(fieldName + " must be a positive number."); } return this; } public Validator requireMaxLength(String value, int max, String fieldName) { if (value != null && value.length() > max) { violations.add(fieldName + " must not exceed " + max + " characters."); } return this; } /** Throws ValidationException if any violations were collected. */ public void validate() { if (!violations.isEmpty()) { throw new ValidationException(violations); } } }

Usage inside a service method:

public Product createProduct(String name, double price, int stock) { new Validator() .requireNonBlank(name, "name") .requirePositive(price, "price") .requirePositive(stock, "stock") .requireMaxLength(name, 120, "name") .validate(); // throws if any field is invalid // domain logic continues only with clean data return productRepository.save(new Product(name, price, stock)); }
Collect all violations, not just the first. A form that surfaces one error at a time forces the user to submit repeatedly. Collecting everything in a single pass is a much better user experience.

Wrapping Data-Layer Exceptions

JDBC throws SQLException, which is a checked exception and carries low-level detail (driver messages, SQL state codes). The service layer must not know which database engine you use. Translate at the repository boundary:

public class ProductRepository { public void save(Product product) { String sql = "INSERT INTO products (name, price, stock) VALUES (?, ?, ?)"; try (var conn = DataSource.getConnection(); var ps = conn.prepareStatement(sql)) { ps.setString(1, product.getName()); ps.setDouble(2, product.getPrice()); ps.setInt(3, product.getStock()); ps.executeUpdate(); } catch (SQLException e) { // MySQL/MariaDB error code 1062 = duplicate entry if ("23000".equals(e.getSQLState())) { throw new DuplicateEntityException("Product", "name", product.getName()); } throw new DataAccessException("Failed to save product.", e); } } public Optional<Product> findById(long id) { String sql = "SELECT id, name, price, stock FROM products WHERE id = ?"; try (var conn = DataSource.getConnection(); var ps = conn.prepareStatement(sql)) { ps.setLong(1, id); try (var rs = ps.executeQuery()) { if (rs.next()) { return Optional.of(mapRow(rs)); } return Optional.empty(); } } catch (SQLException e) { throw new DataAccessException("Failed to fetch product " + id, e); } } private Product mapRow(ResultSet rs) throws SQLException { return new Product( rs.getLong("id"), rs.getString("name"), rs.getDouble("price"), rs.getInt("stock") ); } }

Centralising Error Handling at the Interface Layer

Rather than surrounding every service call in a try/catch, route all exceptions through a single handler. For a console or CLI interface, this is a top-level dispatcher; for an HTTP API, it is a global exception mapper.

public class ErrorHandler { private static final System.Logger LOG = System.getLogger(ErrorHandler.class.getName()); public static void handle(Runnable operation) { try { operation.run(); } catch (ValidationException ex) { System.err.println("[VALIDATION ERROR]"); ex.getViolations().forEach(v -> System.err.println(" - " + v)); } catch (EntityNotFoundException ex) { System.err.println("[NOT FOUND] " + ex.getMessage()); } catch (DuplicateEntityException ex) { System.err.println("[DUPLICATE] " + ex.getMessage()); } catch (DataAccessException ex) { LOG.log(System.Logger.Level.ERROR, "Data access failure", ex); System.err.println("[SYSTEM ERROR] A database error occurred. Please try again."); } catch (AppException ex) { System.err.println("[ERROR " + ex.getErrorCode() + "] " + ex.getMessage()); } catch (Exception ex) { LOG.log(System.Logger.Level.ERROR, "Unexpected error", ex); System.err.println("[INTERNAL ERROR] An unexpected error occurred."); } } }

Call site — the interface layer stays clean:

ErrorHandler.handle(() -> { var product = productService.findById(42L) .orElseThrow(() -> new EntityNotFoundException("Product", 42L)); System.out.println(product); });
Never expose stack traces or internal messages to end users. Log full detail (with the original cause chain) to a secure log, but present only a sanitised, generic message externally. A stack trace is a roadmap for an attacker.

Using Optional to Eliminate Null Checks

Repository methods should return Optional<T> rather than null when an entity may not exist. This forces callers to make the absent case explicit:

// Service converts Optional into a domain exception — callers get a clear signal public Product requireProduct(long id) { return productRepository.findById(id) .orElseThrow(() -> new EntityNotFoundException("Product", id)); }

Structured Error Responses for APIs

If the interface layer is an HTTP API (e.g., using com.sun.net.httpserver or a framework), standardise the error response shape so clients can parse it reliably:

// A simple record used as the JSON error envelope public record ErrorResponse( String errorCode, String message, List<String> details, // null unless it is a validation error Instant timestamp ) {}

Summary

A robust error strategy has three pillars: a typed exception hierarchy that carries stable codes and meaningful messages; early validation that collects all violations before any state is mutated; and a centralised handler at the interface boundary that logs full detail internally and exposes only safe, actionable messages externally. Together these pillars make the application easy to debug, maintain, and operate safely in production.