Back to API Design Best Practices
Q1Question 1 of 22

Advanced Java: sealed classes, records, pattern matching, modules, reflection

Key Concepts
  • Sealed classes restrict which types may implement an interface, enabling exhaustive switch expressions without a default branch.
  • Records are immutable data carriers with auto-generated constructor, getters, equals, hashCode, and toString, ideal for API response DTOs.
  • Pattern matching combines type checking and variable binding in one step, eliminating manual casting in instanceof and switch blocks.
  • The Java Platform Module System (JPMS) declares explicit exports and dependencies, enforcing encapsulation at the jar level.
  • Reflection inspects classes at runtime, which is how Spring and Jackson read annotations, but it bypasses compile-time safety.
  • Combining sealed interfaces with records and pattern matching produces type-safe, exhaustive domain models for banking APIs.

Definition

Sealed classes restrict a type hierarchy, records provide immutable DTOs, pattern matching eliminates casts, modules enforce encapsulation, and reflection powers frameworks – together they build type-safe, modern banking APIs.
  • Sealed classes – restrict exactly which types may extend a class or implement an interface using the sealed and permits keywords, so the compiler knows every subtype and can enforce exhaustive switch expressions.
  • Records – declared with record, they are immutable data carriers that auto-generate the canonical constructor, accessor methods, equals, hashCode, and toString, making them ideal for DTOs.
  • Pattern matching – combines a type test and a variable binding in one step, available in instanceof (Java 16+) and switch expressions (Java 21+), removing manual casts.
  • JPMS modules – declared in module-info.java, they enforce explicit exports and requires so internal packages are truly hidden at compile time and runtime.
  • Reflection – the java.lang.reflect API inspects classes, methods, and fields at runtime, powering frameworks like Spring and Jackson but bypassing compile-time checks.

At National Bank of India, sealed classes are like the rule that only three approved departments – Savings, Current, and Loan – may handle account creation; no rogue department can sneak in. Records are the pre-printed receipt slips that are filled once and cannot be altered. Pattern matching is the smart teller window that reads the slip type and routes it in one step without asking twice. Modules are the locked office doors that only open to authorised departments. Reflection is the security camera that can see inside any office at runtime.

Understanding the Concepts

What are sealed classes?

A sealed class or interface restricts exactly which types may extend or implement it. You declare it with the sealed keyword and list the permitted subtypes with permits. The compiler then knows every possible subtype, which enables exhaustive switch expressions without a default branch.

In a banking API, you might model the types of fee strategies as a sealed interface so no rogue implementation can slip in.

JAVA
package com.bank.model;

public sealed interface FeeStrategy
        permits RegularFeeStrategy, PremiumFeeStrategy, ZeroFeeStrategy {
    java.math.BigDecimal calculateFee(java.math.BigDecimal amount);
}

public record RegularFeeStrategy() implements FeeStrategy {
    public java.math.BigDecimal calculateFee(java.math.BigDecimal amount) {
        return amount.multiply(new java.math.BigDecimal("0.005"));
    }
}

public record PremiumFeeStrategy() implements FeeStrategy {
    public java.math.BigDecimal calculateFee(java.math.BigDecimal amount) {
        return amount.multiply(new java.math.BigDecimal("0.002"));
    }
}

public record ZeroFeeStrategy() implements FeeStrategy {
    public java.math.BigDecimal calculateFee(java.math.BigDecimal amount) {
        return java.math.BigDecimal.ZERO;
    }
}

Because the interface is sealed, a switch over it is exhaustive and the compiler warns if you forget a case.

What are records and why use them for DTOs?

A record is a special class introduced in Java 16 that is an immutable data carrier. The compiler generates the canonical constructor, accessor methods (not getters with get prefix, just the field name), equals, hashCode, and toString from the component list in the header.

Records are ideal for REST API response and request DTOs because they are concise, immutable, and self-documenting.

JAVA
package com.bank.dto;

import java.math.BigDecimal;

public record AccountResponse(
        String accountId,
        String holderName,
        BigDecimal balance
) {}

// Usage:
AccountResponse resp = new AccountResponse("SAV2001", "Rajesh", new BigDecimal("150000.00"));
System.out.println(resp.accountId());  // SAV2001
System.out.println(resp);             // AccountResponse[accountId=SAV2001, holderName=Rajesh, balance=150000.00]
TEXT
SAV2001
AccountResponse[accountId=SAV2001, holderName=Rajesh, balance=150000.00]

What is pattern matching and how does it simplify API code?

Pattern matching combines type checking and variable binding in one expression. Before Java 16, you had to use instanceof and then cast. With pattern matching the cast is eliminated.

JAVA
// Before Java 16 – manual cast:
if (strategy instanceof RegularFeeStrategy) {
    RegularFeeStrategy reg = (RegularFeeStrategy) strategy;
    System.out.println("Regular fee: " + reg.calculateFee(amount));
}

// Java 16+ – pattern matching instanceof:
if (strategy instanceof RegularFeeStrategy reg) {
    System.out.println("Regular fee: " + reg.calculateFee(amount));
}

In Java 21+, sealed interfaces combine with pattern matching in switch expressions for exhaustive, cast-free branching.

JAVA
package com.bank.service;

import com.bank.model.*;
import java.math.BigDecimal;

public class FeeService {
    public BigDecimal computeFee(FeeStrategy strategy, BigDecimal amount) {
        return switch (strategy) {
            case RegularFeeStrategy r  -> r.calculateFee(amount);
            case PremiumFeeStrategy p  -> p.calculateFee(amount);
            case ZeroFeeStrategy z     -> z.calculateFee(amount);
            // No default needed – sealed permits list is exhaustive
        };
    }
}

How do modules (JPMS) enforce encapsulation?

The Java Platform Module System, introduced in Java 9, lets you declare explicit boundaries for a JAR. A module-info.java file states which packages are exported and which modules are required. Internal packages that are not exported are truly hidden at both compile time and runtime.

JAVA
// module-info.java for the banking service module
module com.bank.service {
    requires java.sql;
    requires com.fasterxml.jackson.databind;
    exports com.bank.dto;       // public API – DTOs
    exports com.bank.service;   // public API – services
    // com.bank.model is NOT exported – internal only
}

This means another module cannot access com.bank.model.Account even if the class is public, because the package is not exported. This is stronger than package-private access in the pre-module world.

What is reflection and how do frameworks use it?

Reflection is the ability to inspect and manipulate classes, methods, and fields at runtime using the java.lang.reflect API. Frameworks like Spring and Jackson rely on reflection to read annotations, invoke methods, and populate fields without knowing the types at compile time.

JAVA
package com.bank.util;

import java.lang.reflect.Field;
import java.math.BigDecimal;

public class ReflectionDemo {
    public static void main(String[] args) throws Exception {
        var resp = new com.bank.dto.AccountResponse("SAV2001", "Rajesh", new BigDecimal("150000"));

        // Reflection reads fields at runtime:
        for (Field f : resp.getClass().getDeclaredFields()) {
            f.setAccessible(true);
            System.out.println(f.getName() + " = " + f.get(resp));
        }
    }
}
TEXT
accountId = SAV2001
holderName = Rajesh
balance = 150000

The risk is that reflection bypasses compile-time safety and encapsulation. With JPMS modules, strong encapsulation blocks reflective access to unexported packages unless you explicitly opens them.

How do sealed classes differ from enums?

An enum has a fixed set of singleton instances with no per-instance data variation. A sealed class has a fixed set of subtypes, but each subtype can carry different fields, have its own methods, and be instantiated multiple times.

| Feature | Enum | Sealed class |
|---|---|---|
| Fixed set of types | Yes (instances) | Yes (subtypes) |
| Per-instance fields | Limited | Full freedom |
| Instantiation | Singleton per value | Many instances per subtype |
| Switch exhaustiveness | Yes | Yes (Java 21+) |
| Inheritance | Cannot extend | Full subtype hierarchy |

JAVA
// Enum – fixed values, no per-instance data:
public enum AccountType { SAVINGS, CURRENT, LOAN }

// Sealed – fixed subtypes, each can carry data:
public sealed interface TransactionResult
        permits Success, InsufficientFunds, AccountFrozen {}
public record Success(String txnId, BigDecimal newBalance) implements TransactionResult {}
public record InsufficientFunds(BigDecimal available, BigDecimal requested) implements TransactionResult {}
public record AccountFrozen(String reason) implements TransactionResult {}

What are the limitations of records?

Records cannot extend another class because they implicitly extend java.lang.Record. Their fields are final, so you cannot mutate them after construction. They cannot declare instance fields outside the component list. If you need mutable state, inheritance, or non-trivial field logic, use a regular class.

JAVA
// This does NOT compile – records cannot extend another class:
// public record LoanResponse() extends BaseResponse {}

// Workaround – records CAN implement interfaces:
public sealed interface ApiResponse permits AccountResponse, ErrorResponse {}
public record AccountResponse(String id, BigDecimal balance) implements ApiResponse {}
public record ErrorResponse(String code, String message) implements ApiResponse {}

What are common mistakes with these features?

A common mistake is forgetting to list a permitted subtype in the permits clause. The compiler will reject the unlisted type.

JAVA
// Wrong – MissingStrategy not in permits list:
public sealed interface FeeStrategy
        permits RegularFeeStrategy, PremiumFeeStrategy {}
public record MissingStrategy() implements FeeStrategy { // COMPILE ERROR
    public BigDecimal calculateFee(BigDecimal a) { return BigDecimal.ZERO; }
}

Another mistake is using reflection to mutate record fields. Even though setAccessible(true) can bypass the final modifier, mutating a record breaks its contract and can cause equals and hashCode to return inconsistent results.

JAVA
// Wrong – never mutate a record via reflection:
Field f = resp.getClass().getDeclaredField("balance");
f.setAccessible(true);
f.set(resp, new BigDecimal("999999")); // breaks immutability contract

When should you use each feature in API design?

Use sealed interfaces to model a closed set of domain outcomes or strategies where the compiler must enforce exhaustiveness, such as transaction results or fee tiers. Use records for DTOs that cross the API boundary, because they are immutable, concise, and work well with Jackson serialization. Use pattern matching to dispatch on sealed subtypes cleanly in service logic. Use modules in large banking monorepos to enforce that internal packages stay internal. Use reflection only through frameworks; avoid hand-rolled reflection in application code because it bypasses type safety.

What should a senior engineer know?

A senior engineer understands that sealed classes plus records plus pattern matching form a complete algebraic data type system in Java. This lets you model a transfer result as sealed interface TransferResult permits Success, InsufficientFunds, Frozen, return it from the service, and handle every case exhaustively in the controller without a default branch, which means the compiler catches missing cases at build time rather than at runtime. In regulated banking this compile-time guarantee is a significant safety improvement over unchecked downcasts or string-based status codes.

Senior engineers also know that Jackson supports records natively since version 2.12, so record AccountResponse(String id, BigDecimal balance) {} serializes to JSON without any additional configuration. And they know that JPMS strong encapsulation can break frameworks that rely on deep reflection, which is why Spring Boot adds --add-opens flags during startup.

Visual Overview

DIAGRAM
  Sealed Interface: FeeStrategy
  ┌─────────────────────────────────────────┐
  │  sealed interface FeeStrategy           │
  │  permits:                               │
  │    ├── RegularFeeStrategy  (record)     │
  │    ├── PremiumFeeStrategy  (record)     │
  │    └── ZeroFeeStrategy     (record)     │
  └─────────────────────────────────────────┘
             │
             ▼
  switch (strategy) {
    case RegularFeeStrategy r  → 0.5%
    case PremiumFeeStrategy p  → 0.2%
    case ZeroFeeStrategy z     → 0
    // no default needed – exhaustive
  }
DIAGRAM
  Module Encapsulation:
  ┌──────────────────────────────────┐
  │  module com.bank.service         │
  │                                  │
  │  exports com.bank.dto     ──────┼──→ visible to other modules
  │  exports com.bank.service ──────┼──→ visible to other modules
  │                                  │
  │  com.bank.model (not exported) ──┼──✗ hidden from other modules
  │  com.bank.internal              ──┼──✗ hidden from other modules
  └──────────────────────────────────┘

Key Takeaway

Modern Java gives API designers five powerful features that work together. Sealed classes restrict a type hierarchy to a known set of subtypes, so the compiler enforces exhaustive handling. Records provide immutable, boilerplate-free data carriers that serialize cleanly to JSON, making them ideal for DTOs. Pattern matching eliminates manual casts by combining type checks and variable binding in one step. Modules enforce true encapsulation at the JAR level, keeping internal classes hidden. And reflection, used by frameworks like Spring and Jackson, inspects classes at runtime to read annotations and populate fields.

The real power emerges when these features combine. At National Bank of India, Pankaj modelled transaction results as a sealed interface with three record subtypes: Success, InsufficientFunds, and AccountFrozen. The service returns this sealed result, the controller handles every case with a pattern-matching switch, and the compiler guarantees no case is missed. When the team later added a new result type for rate-limited transactions, the compiler immediately flagged every switch that did not handle the new case, catching the gap at build time rather than in production.

For DTOs, records replaced dozens of verbose response classes. An AccountResponse record with three components gives Rajesh exactly the fields he should see, serializes to JSON with Jackson out of the box since version 2.12, and cannot be mutated after creation. The module system ensures that internal entity classes in com.bank.model are never accidentally imported by another team's microservice, even if the classes are public.

Remember one sentence: sealed classes define what types exist, records carry the data immutably, pattern matching dispatches exhaustively, modules hide the internals, and reflection lets frameworks wire it all together.

---

Share
Use ← → arrow keys to navigate