Java Fundamentals Tutorial 0/33 lessons ~6 min read Lesson 19

    Records & Sealed Classes

    Payment domain models in legacy Java meant POJOs with 80 lines of boilerplate — constructors, getters, equals, hashCode, toString — and stringly-typed status fields where "SETTL…

    Course progress0%
    Focus
    21 guided sections
    Practice signal
    Examples included
    Career prep
    Interview Q&A included

    Introduction

    Payment domain models in legacy Java meant POJOs with 80 lines of boilerplate — constructors, getters, equals, hashCode, toString — and stringly-typed status fields where "SETTLED" typos compile fine but fail in production. Records (Java 16+) are immutable data carriers with generated accessors and value semantics. Sealed classes (Java 17) restrict inheritance — your PaymentEvent hierarchy is exactly Authorized, Declined, or Pending, no rogue subclasses. Pattern matching for instanceof (Java 16) and switch expressions (Java 14+) with sealed types (Java 21) make exhaustive handling compiler-checked.

    This lesson teaches modern Java domain modeling for banking — wire instructions as records, payment outcomes as sealed hierarchies, routing logic with switch expressions that fail compilation when you add a new event type. Staff engineers at Goldman and Stripe-style shops use these features to eliminate null-heavy POJOs and non-exhaustive switch bugs that cause reconciliation gaps.

    Records + sealed + pattern matching are the Java answer to algebraic data types — without leaving the JVM ecosystem.

    Business problem

    Legacy POJO domain models fail regulated payment systems:

    • Mutable state bugs: Setter on TransferDto modified after validation — double-submit to SWIFT gateway.
    • Non-exhaustive switch: New ChargebackReceived event added — switch default silently drops events in audit pipeline.
    • equals/hashCode drift: Developer adds field to PaymentId — forgets hashCode — HashMap lookup fails intermittently.
    • Uncontrolled inheritance: Third party extends PaymentProcessor — overrides settle() with insecure impl.

    Why this topic exists

    Java needed concise immutable data and closed hierarchies — Kotlin data classes and sealed classes proved the model works at scale.

    • Records: JEP 395 — nominal tuple for DTOs, events, value objects; compiler generates equals/hashCode/toString.
    • Sealed classes: JEP 409 — author controls permitted subclasses; enables exhaustive pattern matching.
    • Pattern matching instanceof: JEP 394 — cast and bind in one step — if (o instanceof Payment p).
    • Switch expressions: JEP 361 + sealed — compiler verifies all cases covered — no fall-through bugs.

    Core concepts

    Four pillars of modern Java domain modeling:

    • Records: record WireInstruction(String id, long amountCents, String beneficiarySwift) {} — immutable, final fields.
    • Sealed classes: sealed interface PaymentResult permits Success, Failure, Pending { } — closed hierarchy.
    • Pattern matching: Deconstruct in switch — case Success(var id, var amt) -> ... (Java 21 record patterns).
    • Switch expressions: return switch (result) { case Success s -> ...; case Failure f -> ...; }; — expression yields value, exhaustiveness checked.

    Internal architecture

    Payment event model — sealed + records + switch:

    text
    sealed interface PaymentEvent permits Authorized, Declined, PendingReview {
    }
    record Authorized(String paymentId, long amountCents, Instant at) implements PaymentEvent {}
    record Declined(String paymentId, String reasonCode) implements PaymentEvent {}
    record PendingReview(String paymentId, String caseId) implements PaymentEvent {}
    // Exhaustive routing — compiler error if case missing
    String route(PaymentEvent event) {
    return switch (event) {
    case Authorized(var id, var amt, var at) ->
    "SETTLE queue: " + id + " amount=" + amt;
    case Declined(var id, var code) ->
    "NOTIFY merchant: " + id + " code=" + code;
    case PendingReview(var id, var caseId) ->
    "AML queue case=" + caseId;
    };
    }
    Record rules:
    • No setters — copy-with via wither pattern manual or rebuild
    • Can implement interfaces, add compact constructor validation
    • Not JPA entities (mutable persistence) — use for DTOs/events
    Sealed rules:
    • permits clause lists all subclasses in same module/package
    • subclasses must be final, sealed, or non-sealed

    Four concepts — model a payment lifecycle with compiler-enforced correctness.

    1. Records

    Immutable wire instruction DTO
    record header
    declares fields
    canonical ctor
    all fields
    accessors
    id(), amount()
    equals/hashCode
    by value
    Compiler generates boilerplate — focus on domain fields.
    • Syntax: record Beneficiary(String iban, String name) {} — implicit final fields.
    • Compact constructor: Validate IBAN checksum — if (iban.isBlank()) throw... before field assignment.
    • Value semantics: Two records equal if all components equal — safe as HashMap keys for idempotency cache.
    • Not for JPA entities: Use records for API DTOs, Kafka events, immutable snapshots — entities stay mutable classes.

    2. Sealed Classes

    Closed PaymentResult hierarchy
    PaymentResult
    sealed interface
    Success
    permitted
    Failure
    permitted
    Pending
    permitted
    Only listed types may implement — no external subclasses.
    • permits clause: sealed interface PaymentResult permits Success, Failure, Pending — exhaustive domain.
    • Subclass modifiers: Each permitted type must be final, sealed, or non-sealed — controls further extension.
    • Records as permits: record Success(String id) implements PaymentResult — common pattern.
    • Module boundary: Sealed + module-info exports control who can extend — security for SPI plugins.

    3. Pattern Matching

    instanceof + switch deconstruction
    PaymentEvent
    sealed
    instanceof
    type test
    record pattern
    destructure
    bound vars
    id, amt
    Pattern matching eliminates manual cast + getter chains.
    • instanceof: if (event instanceof Authorized auth) — auth in scope, no cast.
    • Record patterns: case Authorized(var id, var amt, var at) — destructure in switch case label.
    • Guarded patterns: case Authorized(var id, var amt, _) when amt > 1_000_000 — high-value routing (preview/stable).
    • Exhaustiveness: Sealed hierarchy + switch — compiler warns on missing case when new type added.

    4. Switch Expressions

    Exhaustive event routing
    switch(event)
    sealed type
    case Success
    branch
    case Failure
    branch
    yield result
    expression
    Switch as expression — no fall-through, compile-time exhaustiveness.
    • Arrow syntax: case Success s -> handle(s); — no break needed, no fall-through bugs.
    • Expression form: var msg = switch (r) { ... }; — assigns value from all branches.
    • yield: Block case body uses yield value; to return from switch expression.
    • Domain routing: Map PaymentEvent to queue name — compiler fails build when AML adds new event type without handler.

    Code walkthrough

    Sealed payment events with record patterns and switch expression:

    • Compact constructor: Authorized validates amountCents > 0 at construction — fail fast.
    • Guarded case: when amt > 1_000_000 routes high-value payments separately.
    • Record patterns: Destructure Authorized components directly in case label.
    • instanceof pattern: extractAmount binds amt without explicit cast.
    • Exhaustive switch: All three permitted types handled — new type breaks compile until case added.
    java
    import java.time.Instant;
    sealed interface PaymentEvent permits Authorized, Declined, PendingReview {}
    record Authorized(String paymentId, long amountCents, Instant authorizedAt)
    implements PaymentEvent {
    Authorized {
    if (amountCents <= 0) throw new IllegalArgumentException("amount must be positive");
    }
    }
    record Declined(String paymentId, String reasonCode) implements PaymentEvent {}
    record PendingReview(String paymentId, String amlCaseId) implements PaymentEvent {}
    class PaymentRouter {
    static String route(PaymentEvent event) {
    return switch (event) {
    case Authorized(var id, var amt, var at) when amt > 1_000_000L ->
    "HIGH_VALUE_SETTLEMENT:" + id;
    case Authorized(var id, var amt, _) ->
    "STANDARD_SETTLEMENT:" + id;
    case Declined(var id, var code) ->
    "DECLINE_NOTIFY:" + id + ":" + code;
    case PendingReview(var id, var caseId) ->
    "AML_HOLD:" + caseId;
    };
    }
    static long extractAmount(PaymentEvent event) {
    if (event instanceof Authorized(var id, var amt, var at)) {
    return amt;
    }
    return 0L;
    }
    public static void main(String[] args) {
    PaymentEvent ok = new Authorized("P-100", 50_000L, Instant.now());
    PaymentEvent big = new Authorized("P-200", 2_000_000L, Instant.now());
    PaymentEvent bad = new Declined("P-300", "INSUFFICIENT_FUNDS");
    System.out.println(route(ok));
    System.out.println(route(big));
    System.out.println(route(bad));
    System.out.println("Amount: " + extractAmount(ok));
    }
    }

    Production example

    Spring Kafka — sealed event hierarchy for payment bus:

    • Kafka deserialization: Polymorphic JSON needs type discriminator — @JsonSubTypes or custom — sealed hierarchy documents allowed types.
    • Exhaustive handler: New LedgerEvent type — compiler forces new switch case before merge.
    • Records for DTOs: BalanceSnapshot API response — immutable, JSON-friendly with Jackson 2.12+.
    • Block switch cases: Multi-statement case with audit + metrics — arrow syntax with braces.
    java
    // Domain events — immutable records on sealed interface
    public sealed interface LedgerEvent permits Posted, Reversed, Held {
    }
    public record Posted(String entryId, String accountId, long amountCents, Instant at)
    implements LedgerEvent {}
    public record Reversed(String originalEntryId, String reason) implements LedgerEvent {}
    public record Held(String entryId, String holdReason) implements LedgerEvent {}
    @Service
    public class LedgerEventHandler {
    public void handle(LedgerEvent event) {
    switch (event) {
    case Posted(var id, var acct, var amt, var at) -> {
    audit.log("POSTED", id, acct, amt);
    metrics.increment("ledger.posted", amt);
    }
    case Reversed(var origId, var reason) -> {
    audit.log("REVERSED", origId, reason);
    reconciliation.queue(origId);
    }
    case Held(var id, var reason) -> {
    compliance.notifyHold(id, reason);
    }
    }
    }
    // DTO layer — record for REST response
    public record BalanceSnapshot(String accountId, long balanceCents, Instant asOf) {}
    }

    Enterprise case study

    Missing switch case chargeback incident: A card processor modeled payment notifications as string type field with switch statement and default: log-and-ignore. Product added CHARGEBACK_INITIATED event; backend team updated API docs but one reconciliation microservice's switch had no case — default branch logged at DEBUG. For 11 days, chargebacks never hit hold queue — $4.2M exposure before audit. Fix: migrated to sealed interface CardNotification permits ... ChargebackInitiated with record types; switch expression without default — compile error until handler added. Records replaced 12-field POJO with 4 lines.

    • Symptom: Chargebacks in Kafka topic but not in hold DB — silent default branch.
    • Root cause: Stringly-typed events + non-exhaustive switch with default.
    • Fix: Sealed hierarchy + exhaustive switch; remove default that swallows unknown.
    • Prevention: CI rule — no default in payment event switches; sealed permits reviewed in PR.

    Performance considerations

    Records and sealed types performance:

    • Records vs classes: Same field layout — no performance penalty; equals/hashCode may be faster than hand-rolled broken impl.
    • Switch on sealed: JVM may use tableswitch/lookupswitch — O(1) dispatch vs if-else chain; profile if hot path.
    • Pattern matching: No reflection — compiler lowers to casts and field access — same as manual code.
    • Allocation: Records allocate like classes — use for events/DTOs; not a pooling candidate unless profiling shows issue.
    • Serialization: Jackson records support — slightly faster construction vs setter hydration on large batches.

    Security considerations

    Sealed types and records in security-sensitive code:

    • Sealed SPI: PaymentProcessor sealed — only bank-approved implementations in permits — blocks classpath plugin attacks.
    • Record validation: Compact constructor must validate — record can still hold invalid IBAN if no validation.
    • Immutability: Records prevent post-validation mutation — defense against TOCTOU if reference leaked.
    • Deserialization: Polymorphic deserialization of sealed types — whitelist permitted class names in JSON config.

    Scalability considerations

    Modeling at scale:

    • Event sourcing: Records ideal immutable events — append-only ledger of Posted/Reversed records.
    • Kafka schema evolution: Adding sealed permit requires new consumer deployment — plan coordinated rollout.
    • DTO mapping: MapStruct generates mappers to/from records — batch API responses at high QPS.
    • Non-sealed escape hatch: rare third-party extension via non-sealed — document security review requirement.

    Production challenges

    Real records/sealed adoption challenges:

    • JPA incompatibility: Records can't be entities — teams need clear DTO vs entity split.
    • Jackson polymorphism: Forgot @JsonSubTypes — all events deserialize as null or fail silently.
    • Binary compatibility: Adding record component breaks serialization — version events carefully.
    • Framework reflection: Older libs expect setters — records break; upgrade Spring Boot 3+ / Jackson 2.12+.
    • Guarded pattern preview: when guards in switch — ensure CI uses same --enable-preview flags as prod if used.

    Common mistakes

    • Using records as JPA @Entity — persistence providers expect mutable beans and no-arg constructor.
    • Adding sealed subclass without updating all switch expressions — compile error (good) but missed in multi-module builds if not rebuilt.
    • Mutable field inside record (collection) — record is shallow immutable; expose unmodifiable copy in compact constructor.
    • default case in payment event switch — swallows new types silently; prefer exhaustive sealed switch.
    • Record implements Serializable without serialVersionUID plan — component add breaks compat.

    Debugging guide

    Debug sealed hierarchies and records:

    • toString: Records auto toString — log full event in Kafka consumer without manual formatting.
    • Pattern match failure: ClassCastException means non-sealed rogue type in collection — validate at deserialization.
    • Switch compile errors: "pattern not exhaustive" — add case for new permitted type — intentional safety net.
    • Equals surprises: Record with array field — arrays compared by reference; use List in record instead.
    • Jackson: Enable FAIL_ON_UNKNOWN_PROPERTIES — catch schema drift early.
    bash
    # Jackson polymorphic sealed deserialization
    @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
    @JsonSubTypes({
    @JsonSubTypes.Type(value = Authorized.class, name = "authorized"),
    @JsonSubTypes.Type(value = Declined.class, name = "declined")
    })
    public sealed interface PaymentEvent permits Authorized, Declined {}
    # Log record in consumer
    log.info("Event: {}", event); // Authorized[paymentId=P-1, amountCents=50000, ...]

    Best practices

    • Model payment events as sealed interface + record implementations — exhaustive switch handlers.
    • Validate in record compact constructor — IBAN format, positive amounts, non-blank IDs.
    • Use records for DTOs, API responses, Kafka events — classes for JPA entities only.
    • Remove default from switches on sealed types — let compiler enforce new cases.
    • Prefer switch expressions over statement switches — no fall-through, assignable result.
    • Document permitted types in architecture diagram — sealed permits is living documentation.
    • Use record patterns in switch (Java 21) instead of manual getter extraction.

    Anti-patterns

    • Mutable record components — ArrayList modified after construction without defensive copy.
    • String type field + switch default instead of sealed hierarchy.
    • Record with 25 fields — still a god object; decompose into nested records.
    • non-sealed everywhere — defeats purpose of closed hierarchy for payment SPI.
    • Lombok @Data instead of record for pure DTO — record is simpler and correct by default.

    Staff engineer notes

    • Sealed + switch exhaustiveness catches the chargeback-default-branch bug at compile time — highest ROI Java 17 feature for fintech.
    • Records replaced 60% of inner static POJO classes in mature codebases — review focuses on validation not boilerplate.
    • If your switch still has default for domain type — you're not done migrating to sealed.
    • Record patterns in Java 21 switch are the endgame for payment routing — readable and provably exhaustive.
    • JPA entity vs record DTO boundary should be in team ADR — biggest adoption friction point.

    Interview questions

    Interview preparation

    15 questions grouped by difficulty — expand any card for a model answer and follow-up probe.

    Beginner

    5
    1. 1What is a Java record?
      Beginner

      Model answer

      Immutable data carrier class.

      Compiler generates constructor, accessors (field name()), equals, hashCode, toString.

      Implicitly final.

      Can implement interfaces and have compact constructor for validation.

      Cannot extend classes or be abstract.

      Follow-up probe

      Can record have instance field not in header?

    2. 2What are sealed classes?
      Beginner

      Model answer

      Class or interface restricts which types can extend/implement via permits clause.

      Subclasses must be final, sealed, or non-sealed.

      Enables exhaustive pattern matching and documents closed domain hierarchies.

      Follow-up probe

      sealed vs final?

    3. 3Explain pattern matching for instanceof.
      Beginner

      Model answer

      • if (obj instanceof Payment p)
      • tests type and binds variable p in true branch. No explicit cast. Works with records
      • destructure in later switch patterns.

      Follow-up probe

      Null handling?

    4. 4Switch expression vs switch statement?
      Beginner

      Model answer

      Expression yields value: var x = switch (e) { case A -> 1; case B -> 2; }; Arrow cases no fall-through.

      Statement uses break/yield.

      Expression requires exhaustiveness for sealed/enums.

      Follow-up probe

      yield keyword?

    5. 5Can records be used as JPA entities?
      Beginner

      Model answer

      No.

      JPA requires no-arg constructor and typically mutable state for persistence context.

      Use records for DTOs/projections; entities remain classes.

      Follow-up probe

      Projection as record?

    Intermediate

    5
    1. 6What is a compact constructor in records?
      Intermediate

      Model answer

      • Validation constructor without parameter list
      • runs before field assignment. Assign to this.field after validation. Cannot call super. Use to enforce invariants on immutable data.

      Follow-up probe

      Canonical vs compact?

    2. 7How achieve exhaustiveness in switch on sealed type?
      Intermediate

      Model answer

      • Switch covers all permitted subtypes
      • compiler error if case missing (no default needed). Adding new permitted record requires new case in all switches
      • forces handler update.

      Follow-up probe

      What if default added?

    3. 8Record patterns in switch (Java 21)?
      Intermediate

      Model answer

      case Authorized(var id, var amt, var at) deconstructs record components in case label.

      Works with guarded when clauses.

      Replaces verbose instanceof + getters.

      Follow-up probe

      Nested patterns?

    4. 9Difference between final and sealed class?
      Intermediate

      Model answer

      • final: no subclasses at all. sealed: specific permitted subclasses only
      • controlled extension. sealed interface with record permits common for ADTs.

      Follow-up probe

      non-sealed meaning?

    5. 10Records and equals/hashCode contract?
      Intermediate

      Model answer

      • Generated from all components in order. Two records equal if same type and each component equal. Stable for HashMap keys if components are stable. Watch arrays
      • reference equality.

      Follow-up probe

      Float/Double in record?

    Advanced

    5
    1. 11Design payment notification hierarchy with sealed types.
      Advanced

      Model answer

      sealed interface PaymentNotification permits Authorized, Declined, ChargebackInitiated as records.

      Kafka JSON with type discriminator.

      Handler switch expression routes each.

      No default.

      Tests per case.

      CI compiles all modules on new permit.

      Follow-up probe

      Cross-module permits?

    2. 12Migrate stringly-typed events to sealed records safely.
      Advanced

      Model answer

      Phase 1: dual-write old string + new typed event.

      Phase 2: consumers handle both.

      Phase 3: remove string switch.

      Phase 4: delete default branch.

      Feature flag per service.

      Integration test matrix for all permits.

      Follow-up probe

      Backward compat JSON?

    3. 13When use non-sealed subclass?
      Advanced

      Model answer

      • Allow extension outside module for plugin SPI
      • third parties extend non-sealed base of sealed hierarchy. Trade security for extensibility. Rare in payment core; more in gateway plugins.

      Follow-up probe

      Module opens?

    4. 14Compare record vs Lombok @Value.
      Advanced

      Model answer

      • Record is language feature
      • no annotation processor, correct equals/hashCode guaranteed, pattern matching integration. Lombok @Value similar but external dep and can misconfigure. Prefer record for new Java 16+ code.

      Follow-up probe

      Lombok with records?

    5. 15Implement high-value payment routing with guarded patterns.
      Advanced

      Model answer

      switch(event) { case Authorized(var id, var amt, _) when amt > threshold -> highValueQueue; case Authorized(var id, _, _) -> standardQueue; ...

      }.

      Threshold config externalized.

      Audit log includes matched guard.

      Unit test boundary at threshold.

      Follow-up probe

      Guard vs if inside case?

    Hands-on exercise

    Lab: Sealed payment events

    • Run playground — observe routing for standard vs high-value Authorized.
    • Add new permitted type Refunded — fix compile errors in switch.
    • Add compact constructor validation on Declined (non-blank reasonCode).
    • Convert extractAmount to switch expression instead of instanceof if.
    • Write Jackson @JsonSubTypes sketch for polymorphic deserialization.
    • Bonus: nested record for Money(long cents, Currency currency) inside Authorized.

    JavaRecords & Sealed Classes

    Starter Templates
    OutputRemote JVM (Piston · Java 15)
    Press Run to execute. Real javac + JVM via remote API. Falls back to simulator on network failure.

    Architecture trade-offs

    • Record vs class DTO: Record immutable and concise; class needed for JPA/builders.
    • Sealed vs open hierarchy: Sealed exhaustive; open extensible but switch fragile.
    • Switch expression vs if-else chain: Switch exhaustive on sealed; if-else flexible but verbose.
    • Guarded patterns vs nested if: Guards declarative; nested if works on older Java.

    Summary

    Records, sealed classes, pattern matching, and switch expressions modernize payment domain modeling — immutable events, closed hierarchies, and exhaustive handlers that fail compilation instead of silently dropping chargebacks. Use records for DTOs and events, sealed for domain ADTs, switch expressions for routing. These Java 17–21 features are the baseline for enterprise code review in 2026.

    Key takeaways

    • Records are immutable DTOs/events — compiler generates equals, hashCode, accessors.
    • Sealed classes restrict hierarchy — PaymentEvent permits only known outcomes.
    • Pattern matching + switch expressions give exhaustive, compiler-checked routing.
    • Validate in compact constructors — records don't prevent invalid component values without checks.
    • Never use default switch branch for sealed payment events — silent drops cause financial incidents.
    Ready to mark this lesson complete?Track your journey across the entire course.