Java Java

Modeling Immutable Data with Java Records and Sealed Classes

grivel Июн 24, 2026

Introduction

In event-sourcing architectures, correctness depends on two guarantees that plain JavaBeans cannot provide. First, events must be immutable — once an OrderPlaced event is created, its data cannot change. Second, the processing pipeline must handle every possible event type, or a newly added type silently falls through and is ignored. Hand-written JavaBeans with private fields, getters, and boilerplate equals/hashCode/toString methods give you neither guarantee while adding hundreds of lines of code that carry no business information.

The cost of this pattern compounds as the system grows. Every new event type requires a new class, a new set of hand-written accessors, and a new branch in every switch statement or if-else chain across the codebase — none of which the compiler enforces. A developer adding OrderExpired must remember to update every dispatch point. The compiler does not help. Tests catch some of it. Production catches the rest.

Java 17 ships two features designed specifically for this problem. Records are immutable data carriers with automatically generated constructors, accessors, equals, hashCode, and toString. Sealed interfaces restrict which classes can implement an interface, giving the compiler a closed list of subtypes it can enforce in switch expressions with pattern matching. Together they eliminate the boilerplate and make exhaustiveness a compile-time property rather than a runtime hope.

This tutorial builds the event type hierarchy and processing pipeline for a trading platform's order event stream. Each section removes one category of hand-written code and replaces it with a language feature that provides stronger guarantees.


Background

Records (introduced in Java 16, finalized in Java 17) are a restricted form of class declaration. A record declaration like record Point(int x, int y) {} generates: a canonical constructor, accessor methods (x() and y()), and implementations of equals, hashCode, and toString based on all components. Records are implicitly final and cannot extend other classes. Their fields are implicitly private final.

Compact constructors are a record-specific feature. They let you add validation logic without repeating the parameter list — the canonical constructor's assignments run automatically after the compact constructor body.

Sealed interfaces (Java 17) declare a closed set of permitted subtypes using the permits clause. A sealed interface TradingEvent permits OrderPlaced, OrderFilled, OrderCancelled tells the compiler that those three are the only possible implementations. This enables exhaustiveness checking.

Switch expressions with pattern matching (Java 17) let a switch match on the runtime type of an object. When the switched value is a sealed type, the compiler can verify that all permitted subtypes are handled — if a case is missing, the code does not compile.

Key properties to keep in mind:

  • Records cannot be subclassed, ensuring their structure is fixed
  • A compact constructor executes before field assignment, allowing validation that throws before an invalid record is created
  • switch expressions with -> return a value; the compiler enforces exhaustiveness on sealed types
  • Non-sealed subtypes of a sealed interface are permitted but remove the exhaustiveness guarantee for that branch

Practical Scenario

A trading platform processes a continuous stream of order lifecycle events. Each order moves through states: a trader places it (OrderPlaced), the exchange fills it partially or fully (OrderFilled), or it gets cancelled due to expiry or risk limits (OrderCancelled). These events are the canonical record of what happened — they are stored, replicated, and replayed to reconstruct state, so they must never change after creation.

The platform's risk engine consumes this stream in real time. It needs to compute position exposure from fills, detect anomalies in cancellation patterns, and generate audit records for regulators. Every event type requires different processing logic, and the pipeline must handle all of them — missing an event type in the risk engine is a compliance violation, not just a bug.

The team's first implementation uses plain JavaBean classes. Each event class has hand-written getters, a constructor, and equals/hashCode/toString methods that were generated by an IDE and promptly forgotten. The dispatch logic in the pipeline uses instanceof chains that a new hire recently extended — but only in two of the three dispatch points, because the third was in a different file and the code review missed it.

What the system needs is a model where the compiler rejects incomplete code — where adding a new event type automatically breaks every dispatch point that does not handle it, and where every event object is guaranteed to be immutable from the moment it is created.


The Problem

A JavaBean-based event model with manual dispatch shows the scale of the problem.

Create a new file:

touch TradingEvents.java

Compile and run:

javac TradingEvents.java && java TradingEvents
import java.time.Instant;
import java.util.List;
import java.util.Objects;

class OrderPlacedBean {
    private String orderId;
    private String ticker;
    private int quantity;
    private double limitPrice;
    private Instant timestamp;

    public OrderPlacedBean(String orderId, String ticker, int quantity,
                           double limitPrice, Instant timestamp) {
        this.orderId    = orderId;
        this.ticker     = ticker;
        this.quantity   = quantity;
        this.limitPrice = limitPrice;
        this.timestamp  = timestamp;
    }

    public String getOrderId()    { return orderId; }
    public String getTicker()     { return ticker; }
    public int    getQuantity()   { return quantity; }
    public double getLimitPrice() { return limitPrice; }
    public Instant getTimestamp() { return timestamp; }

    @Override public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof OrderPlacedBean)) return false;
        OrderPlacedBean that = (OrderPlacedBean) o;
        return quantity == that.quantity
            && Double.compare(limitPrice, that.limitPrice) == 0
            && Objects.equals(orderId, that.orderId)
            && Objects.equals(ticker, that.ticker)
            && Objects.equals(timestamp, that.timestamp);
    }

    @Override public int hashCode() {
        return Objects.hash(orderId, ticker, quantity, limitPrice, timestamp);
    }

    @Override public String toString() {
        return "OrderPlacedBean{orderId='" + orderId + "', ticker='" + ticker
            + "', quantity=" + quantity + ", limitPrice=" + limitPrice
            + ", timestamp=" + timestamp + "}";
    }
}

class OrderFilledBean {
    private String orderId;
    private String ticker;
    private int    filledQuantity;
    private double fillPrice;
    private Instant timestamp;

    public OrderFilledBean(String orderId, String ticker,
                           int filledQuantity, double fillPrice, Instant timestamp) {
        this.orderId        = orderId;
        this.ticker         = ticker;
        this.filledQuantity = filledQuantity;
        this.fillPrice      = fillPrice;
        this.timestamp      = timestamp;
    }

    public String getOrderId()       { return orderId; }
    public String getTicker()        { return ticker; }
    public int    getFilledQuantity(){ return filledQuantity; }
    public double getFillPrice()     { return fillPrice; }
    public Instant getTimestamp()    { return timestamp; }

    @Override public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof OrderFilledBean)) return false;
        OrderFilledBean that = (OrderFilledBean) o;
        return filledQuantity == that.filledQuantity
            && Double.compare(fillPrice, that.fillPrice) == 0
            && Objects.equals(orderId, that.orderId)
            && Objects.equals(ticker, that.ticker)
            && Objects.equals(timestamp, that.timestamp);
    }

    @Override public int hashCode() {
        return Objects.hash(orderId, ticker, filledQuantity, fillPrice, timestamp);
    }

    @Override public String toString() {
        return "OrderFilledBean{orderId='" + orderId + "', ticker='" + ticker
            + "', filledQuantity=" + filledQuantity + ", fillPrice=" + fillPrice
            + ", timestamp=" + timestamp + "}";
    }
}

class OrderCancelledBean {
    private String orderId;
    private String ticker;
    private String reason;
    private Instant timestamp;

    public OrderCancelledBean(String orderId, String ticker,
                              String reason, Instant timestamp) {
        this.orderId   = orderId;
        this.ticker    = ticker;
        this.reason    = reason;
        this.timestamp = timestamp;
    }

    public String getOrderId()   { return orderId; }
    public String getTicker()    { return ticker; }
    public String getReason()    { return reason; }
    public Instant getTimestamp(){ return timestamp; }

    @Override public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof OrderCancelledBean)) return false;
        OrderCancelledBean that = (OrderCancelledBean) o;
        return Objects.equals(orderId, that.orderId)
            && Objects.equals(ticker, that.ticker)
            && Objects.equals(reason, that.reason)
            && Objects.equals(timestamp, that.timestamp);
    }

    @Override public int hashCode() {
        return Objects.hash(orderId, ticker, reason, timestamp);
    }

    @Override public String toString() {
        return "OrderCancelledBean{orderId='" + orderId + "', ticker='" + ticker
            + "', reason='" + reason + "', timestamp=" + timestamp + "}";
    }
}

public class TradingEvents {

    static String dispatchEvent(Object event) {
        if (event instanceof OrderPlacedBean e) {
            return "PLACED  " + e.getOrderId() + " " + e.getTicker()
                + " qty=" + e.getQuantity() + " limit=$" + e.getLimitPrice();
        } else if (event instanceof OrderFilledBean e) {
            return "FILLED  " + e.getOrderId() + " " + e.getTicker()
                + " filled=" + e.getFilledQuantity() + " @ $" + e.getFillPrice();
        } else if (event instanceof OrderCancelledBean e) {
            return "CANCELLED " + e.getOrderId() + " reason=" + e.getReason();
        } else {
            return "UNKNOWN event type: " + event.getClass().getSimpleName();
        }
    }

    public static void main(String[] args) {
        Instant now = Instant.parse("2024-03-15T10:30:00Z");

        List<Object> events = List.of(
            new OrderPlacedBean("ORD-001", "AAPL", 100, 182.50, now),
            new OrderFilledBean("ORD-001", "AAPL", 100, 182.48, now.plusSeconds(2)),
            new OrderPlacedBean("ORD-002", "TSLA", 50, 175.00, now.plusSeconds(5)),
            new OrderCancelledBean("ORD-002", "TSLA", "RISK_LIMIT_EXCEEDED", now.plusSeconds(10))
        );

        System.out.println("=== Risk Engine Event Log ===");
        for (Object event : events) {
            System.out.println(dispatchEvent(event));
        }

        // Demonstrate toString
        System.out.println("\n=== Event Details ===");
        System.out.println(events.get(0));
    }
}


=== Risk Engine Event Log ===
PLACED  ORD-001 AAPL qty=100 limit=$182.5
FILLED  ORD-001 AAPL filled=100 @ $182.48
PLACED  ORD-002 TSLA qty=50 limit=$175.0
CANCELLED ORD-002 reason=RISK_LIMIT_EXCEEDED

=== Event Details ===
OrderPlacedBean{orderId='ORD-001', ticker='AAPL', quantity=100, limitPrice=182.5, timestamp=2024-03-15T10:30:00Z}


The three bean classes total over 150 lines, of which zero carry business logic — they are entirely structural overhead. The dispatch method takes Object, so the compiler cannot check whether all event types are handled. Adding a fourth event type like OrderExpired requires: a new bean class with its boilerplate, and manual updates to every instanceof chain in the codebase. Nothing enforces that the update happens.


Replacing JavaBeans with Records

Records replace all the structural boilerplate in a single line per class. The compiler generates the canonical constructor, accessor methods (named after the components, without get prefixes), equals, hashCode, and toString.

Replace the entire content of TradingEvents.java with:

import java.time.Instant;
import java.util.List;

record OrderPlaced(String orderId, String ticker, int quantity,
                   double limitPrice, Instant timestamp) {}

record OrderFilled(String orderId, String ticker, int filledQuantity,
                   double fillPrice, Instant timestamp) {}

record OrderCancelled(String orderId, String ticker,
                      String reason, Instant timestamp) {}

public class TradingEvents {

    static String dispatchEvent(Object event) {
        if (event instanceof OrderPlaced e) {
            return "PLACED  " + e.orderId() + " " + e.ticker()
                + " qty=" + e.quantity() + " limit=$" + e.limitPrice();
        } else if (event instanceof OrderFilled e) {
            return "FILLED  " + e.orderId() + " " + e.ticker()
                + " filled=" + e.filledQuantity() + " @ $" + e.fillPrice();
        } else if (event instanceof OrderCancelled e) {
            return "CANCELLED " + e.orderId() + " reason=" + e.reason();
        } else {
            return "UNKNOWN";
        }
    }

    public static void main(String[] args) {
        Instant now = Instant.parse("2024-03-15T10:30:00Z");

        List<Object> events = List.of(
            new OrderPlaced("ORD-001", "AAPL", 100, 182.50, now),
            new OrderFilled("ORD-001", "AAPL", 100, 182.48, now.plusSeconds(2)),
            new OrderPlaced("ORD-002", "TSLA", 50, 175.00, now.plusSeconds(5)),
            new OrderCancelled("ORD-002", "TSLA", "RISK_LIMIT_EXCEEDED", now.plusSeconds(10))
        );

        System.out.println("=== Risk Engine Event Log ===");
        for (Object event : events) {
            System.out.println(dispatchEvent(event));
        }

        System.out.println("\n=== Event Details ===");
        System.out.println(events.get(0));
    }
}


=== Risk Engine Event Log ===
PLACED  ORD-001 AAPL qty=100 limit=$182.5
FILLED  ORD-001 AAPL filled=100 @ $182.48
PLACED  ORD-002 TSLA qty=50 limit=$175.0
CANCELLED ORD-002 reason=RISK_LIMIT_EXCEEDED

=== Event Details ===
OrderPlaced[orderId=ORD-001, ticker=AAPL, quantity=100, limitPrice=182.5, timestamp=2024-03-15T10:30:00Z]


The three event types now occupy three lines each instead of fifty lines each. The generated toString uses the record's component names directly, making it immediately readable without custom formatting. Accessors are named orderId() instead of getOrderId(), which reads more naturally in stream pipelines and reduces noise. Because records are immutable by design, there is no possibility of a caller mutating an event after it is created — a guarantee the bean version could not provide without making every field final and removing setters explicitly.

Note: Record accessor methods do not follow the JavaBean naming convention (getX). If a framework requires JavaBean-style accessors (older Spring versions, some serialization libraries), add explicit getX() methods to the record body, or configure the framework's property discovery accordingly.


Compact Constructors for Validation

Records guarantee immutability, but not validity. A record constructed with a negative quantity or a null ticker is immutable garbage. Compact constructors add validation that runs before the record is stored, throwing before an invalid event enters the system.

Replace the three record declarations with the following validated versions:

import java.time.Instant;
import java.util.List;
import java.util.Objects;

record OrderPlaced(String orderId, String ticker, int quantity,
                   double limitPrice, Instant timestamp) {
    OrderPlaced {
        Objects.requireNonNull(orderId,  "orderId must not be null");
        Objects.requireNonNull(ticker,   "ticker must not be null");
        Objects.requireNonNull(timestamp,"timestamp must not be null");
        if (quantity <= 0)    throw new IllegalArgumentException("quantity must be positive, got: " + quantity);
        if (limitPrice <= 0)  throw new IllegalArgumentException("limitPrice must be positive, got: " + limitPrice);
    }
}

record OrderFilled(String orderId, String ticker, int filledQuantity,
                   double fillPrice, Instant timestamp) {
    OrderFilled {
        Objects.requireNonNull(orderId,  "orderId must not be null");
        Objects.requireNonNull(ticker,   "ticker must not be null");
        Objects.requireNonNull(timestamp,"timestamp must not be null");
        if (filledQuantity <= 0) throw new IllegalArgumentException("filledQuantity must be positive, got: " + filledQuantity);
        if (fillPrice <= 0)      throw new IllegalArgumentException("fillPrice must be positive, got: " + fillPrice);
    }
}

record OrderCancelled(String orderId, String ticker,
                      String reason, Instant timestamp) {
    OrderCancelled {
        Objects.requireNonNull(orderId,  "orderId must not be null");
        Objects.requireNonNull(ticker,   "ticker must not be null");
        Objects.requireNonNull(reason,   "reason must not be null");
        Objects.requireNonNull(timestamp,"timestamp must not be null");
    }
}

public class TradingEvents {

    static String dispatchEvent(Object event) {
        if (event instanceof OrderPlaced e) {
            return "PLACED  " + e.orderId() + " " + e.ticker()
                + " qty=" + e.quantity() + " limit=$" + e.limitPrice();
        } else if (event instanceof OrderFilled e) {
            return "FILLED  " + e.orderId() + " " + e.ticker()
                + " filled=" + e.filledQuantity() + " @ $" + e.fillPrice();
        } else if (event instanceof OrderCancelled e) {
            return "CANCELLED " + e.orderId() + " reason=" + e.reason();
        } else {
            return "UNKNOWN";
        }
    }

    public static void main(String[] args) {
        Instant now = Instant.parse("2024-03-15T10:30:00Z");

        // Valid events work as before
        List<Object> events = List.of(
            new OrderPlaced("ORD-001", "AAPL", 100, 182.50, now),
            new OrderFilled("ORD-001", "AAPL", 100, 182.48, now.plusSeconds(2)),
            new OrderCancelled("ORD-002", "TSLA", "RISK_LIMIT_EXCEEDED", now.plusSeconds(10))
        );
        events.forEach(e -> System.out.println(dispatchEvent(e)));

        // Invalid event is rejected at construction time
        System.out.println("\nAttempting invalid order:");
        try {
            new OrderPlaced("ORD-003", "MSFT", -5, 420.00, now);
        } catch (IllegalArgumentException ex) {
            System.out.println("Rejected: " + ex.getMessage());
        }
    }
}


PLACED  ORD-001 AAPL qty=100 limit=$182.5
FILLED  ORD-001 AAPL filled=100 @ $182.48
CANCELLED ORD-002 reason=RISK_LIMIT_EXCEEDED

Attempting invalid order:
Rejected: quantity must be positive, got: -5


In the compact constructor, the component names (orderId, ticker, etc.) are in scope and will be assigned automatically after the body executes — there is no need to write this.orderId = orderId. This means validation cannot be bypassed by a subclass or a secondary constructor, because records have exactly one constructor path. An invalid OrderPlaced cannot exist; any attempt to create one throws before the object is allocated.

Note: The compact constructor body runs before the automatic field assignments, so do not assign this.componentName inside it — that causes a compile error. Validation logic only; transformation of values (normalizing a string to uppercase, rounding a price) is permitted by reassigning the parameter variable before the body exits.


Sealed Interfaces for Exhaustive Dispatch

The dispatch method still takes Object, which means the compiler cannot check whether all event types are handled. Introducing a sealed interface TradingEvent declares the closed set of permitted subtypes and enables exhaustiveness checking in switch expressions.

Replace the entire content of TradingEvents.java with:

import java.time.Instant;
import java.util.List;
import java.util.Objects;

sealed interface TradingEvent permits OrderPlaced, OrderFilled, OrderCancelled {}

record OrderPlaced(String orderId, String ticker, int quantity,
                   double limitPrice, Instant timestamp) implements TradingEvent {
    OrderPlaced {
        Objects.requireNonNull(orderId,   "orderId must not be null");
        Objects.requireNonNull(ticker,    "ticker must not be null");
        Objects.requireNonNull(timestamp, "timestamp must not be null");
        if (quantity <= 0)   throw new IllegalArgumentException("quantity must be positive, got: " + quantity);
        if (limitPrice <= 0) throw new IllegalArgumentException("limitPrice must be positive, got: " + limitPrice);
    }
}

record OrderFilled(String orderId, String ticker, int filledQuantity,
                   double fillPrice, Instant timestamp) implements TradingEvent {
    OrderFilled {
        Objects.requireNonNull(orderId,   "orderId must not be null");
        Objects.requireNonNull(ticker,    "ticker must not be null");
        Objects.requireNonNull(timestamp, "timestamp must not be null");
        if (filledQuantity <= 0) throw new IllegalArgumentException("filledQuantity must be positive, got: " + filledQuantity);
        if (fillPrice <= 0)      throw new IllegalArgumentException("fillPrice must be positive, got: " + fillPrice);
    }
}

record OrderCancelled(String orderId, String ticker,
                      String reason, Instant timestamp) implements TradingEvent {
    OrderCancelled {
        Objects.requireNonNull(orderId,   "orderId must not be null");
        Objects.requireNonNull(ticker,    "ticker must not be null");
        Objects.requireNonNull(reason,    "reason must not be null");
        Objects.requireNonNull(timestamp, "timestamp must not be null");
    }
}

public class TradingEvents {

    static String dispatchEvent(TradingEvent event) {
        return switch (event) {
            case OrderPlaced    e -> "PLACED    " + e.orderId() + " " + e.ticker()
                + " qty=" + e.quantity() + " limit=$" + e.limitPrice();
            case OrderFilled    e -> "FILLED    " + e.orderId() + " " + e.ticker()
                + " filled=" + e.filledQuantity() + " @ $" + e.fillPrice();
            case OrderCancelled e -> "CANCELLED " + e.orderId() + " reason=" + e.reason();
        };
    }

    public static void main(String[] args) {
        Instant now = Instant.parse("2024-03-15T10:30:00Z");

        List<TradingEvent> events = List.of(
            new OrderPlaced("ORD-001", "AAPL", 100, 182.50, now),
            new OrderFilled("ORD-001", "AAPL", 100, 182.48, now.plusSeconds(2)),
            new OrderPlaced("ORD-002", "TSLA", 50, 175.00, now.plusSeconds(5)),
            new OrderCancelled("ORD-002", "TSLA", "RISK_LIMIT_EXCEEDED", now.plusSeconds(10))
        );

        System.out.println("=== Risk Engine Event Log ===");
        events.forEach(e -> System.out.println(dispatchEvent(e)));
    }
}


=== Risk Engine Event Log ===
PLACED    ORD-001 AAPL qty=100 limit=$182.5
FILLED    ORD-001 AAPL filled=100 @ $182.48
PLACED    ORD-002 TSLA qty=50 limit=$175.0
CANCELLED ORD-002 reason=RISK_LIMIT_EXCEEDED


The switch expression has no default branch, and it compiles. The compiler verified that OrderPlaced, OrderFilled, and OrderCancelled are the only possible implementations of TradingEvent (because the interface is sealed), and that all three are handled. Remove any one case and the file will not compile. This means adding a new event type to the sealed interface automatically breaks every dispatch point that does not handle it — a compile error instead of a runtime miss.

Note: The switch expression form (with -> and no break) requires all branches to return a value of the same type when used as an expression. If some branches need multiple statements, wrap them in braces and end with yield value; instead of return value;.


Pattern Matching with Guard Conditions

Pattern matching in switch supports guard conditions using when (Java 21) — but in Java 17, guards are expressed by nesting a secondary switch or if inside the case body. More practically, specific subtypes of records can be pattern-matched to extract components inline, and the same switch can compute derived values without intermediate variables.

Replace the dispatchEvent method and add a processRiskSignal method:

import java.time.Instant;
import java.util.List;
import java.util.Objects;

sealed interface TradingEvent permits OrderPlaced, OrderFilled, OrderCancelled {}

record OrderPlaced(String orderId, String ticker, int quantity,
                   double limitPrice, Instant timestamp) implements TradingEvent {
    OrderPlaced {
        Objects.requireNonNull(orderId,   "orderId must not be null");
        Objects.requireNonNull(ticker,    "ticker must not be null");
        Objects.requireNonNull(timestamp, "timestamp must not be null");
        if (quantity <= 0)   throw new IllegalArgumentException("quantity must be positive, got: " + quantity);
        if (limitPrice <= 0) throw new IllegalArgumentException("limitPrice must be positive, got: " + limitPrice);
    }
}

record OrderFilled(String orderId, String ticker, int filledQuantity,
                   double fillPrice, Instant timestamp) implements TradingEvent {
    OrderFilled {
        Objects.requireNonNull(orderId,   "orderId must not be null");
        Objects.requireNonNull(ticker,    "ticker must not be null");
        Objects.requireNonNull(timestamp, "timestamp must not be null");
        if (filledQuantity <= 0) throw new IllegalArgumentException("filledQuantity must be positive, got: " + filledQuantity);
        if (fillPrice <= 0)      throw new IllegalArgumentException("fillPrice must be positive, got: " + fillPrice);
    }
}

record OrderCancelled(String orderId, String ticker,
                      String reason, Instant timestamp) implements TradingEvent {
    OrderCancelled {
        Objects.requireNonNull(orderId,   "orderId must not be null");
        Objects.requireNonNull(ticker,    "ticker must not be null");
        Objects.requireNonNull(reason,    "reason must not be null");
        Objects.requireNonNull(timestamp, "timestamp must not be null");
    }
}

public class TradingEvents {

    static String dispatchEvent(TradingEvent event) {
        return switch (event) {
            case OrderPlaced    e -> "PLACED    " + e.orderId() + " " + e.ticker()
                + " qty=" + e.quantity() + " limit=$" + e.limitPrice();
            case OrderFilled    e -> "FILLED    " + e.orderId() + " " + e.ticker()
                + " filled=" + e.filledQuantity() + " @ $" + e.fillPrice();
            case OrderCancelled e -> "CANCELLED " + e.orderId() + " reason=" + e.reason();
        };
    }

    // Compute risk exposure from event — each type contributes differently
    static String processRiskSignal(TradingEvent event) {
        return switch (event) {
            case OrderPlaced e -> {
                double notional = e.quantity() * e.limitPrice();
                yield String.format("RESERVE  %-8s notional=$%.2f", e.orderId(), notional);
            }
            case OrderFilled e -> {
                double exposure = e.filledQuantity() * e.fillPrice();
                yield String.format("EXPOSURE %-8s +$%.2f on %s", e.orderId(), exposure, e.ticker());
            }
            case OrderCancelled e -> {
                boolean riskDriven = e.reason().startsWith("RISK_");
                yield String.format("RELEASE  %-8s risk_driven=%b", e.orderId(), riskDriven);
            }
        };
    }

    public static void main(String[] args) {
        Instant now = Instant.parse("2024-03-15T10:30:00Z");

        List<TradingEvent> events = List.of(
            new OrderPlaced("ORD-001", "AAPL", 100, 182.50, now),
            new OrderFilled("ORD-001", "AAPL", 100, 182.48, now.plusSeconds(2)),
            new OrderPlaced("ORD-002", "TSLA", 50, 175.00, now.plusSeconds(5)),
            new OrderCancelled("ORD-002", "TSLA", "RISK_LIMIT_EXCEEDED", now.plusSeconds(10))
        );

        System.out.println("=== Event Log ===");
        events.forEach(e -> System.out.println(dispatchEvent(e)));

        System.out.println("\n=== Risk Signals ===");
        events.forEach(e -> System.out.println(processRiskSignal(e)));
    }
}


=== Event Log ===
PLACED    ORD-001 AAPL qty=100 limit=$182.5
FILLED    ORD-001 AAPL filled=100 @ $182.48
PLACED    ORD-002 TSLA qty=50 limit=$175.0
CANCELLED ORD-002 reason=RISK_LIMIT_EXCEEDED

=== Risk Signals ===
RESERVE  ORD-001  notional=$18250.00
EXPOSURE ORD-001  +$18248.00 on AAPL
RESERVE  ORD-002  notional=$8750.00
RELEASE  ORD-002  risk_driven=true


The yield keyword inside a block case lets each branch compute a multi-step result while still participating in an expression switch. Both dispatchEvent and processRiskSignal are exhaustive over TradingEvent — adding a fourth event type to the sealed interface causes both methods to fail compilation immediately, catching the omission before the code reaches a test environment.


Summary

Java Records and Sealed Classes together solve the two fundamental problems of event-sourcing models: verbosity and incomplete dispatch. This tutorial built a trading platform event pipeline that is both concise and compiler-verified:

  • A record declaration generates a canonical constructor, component accessors (named fieldName(), not getFieldName()), and value-based equals, hashCode, and toString — replacing fifty lines of JavaBean boilerplate with one
  • Records are implicitly final and their fields are implicitly private final, guaranteeing that an event object cannot be mutated after construction
  • A compact constructor validates inputs without repeating the parameter list — the component fields are assigned automatically after the body executes, so validation cannot be bypassed
  • Objects.requireNonNull and IllegalArgumentException in compact constructors make invalid states unrepresentable — construction either succeeds with a valid object or throws before the object exists
  • A sealed interface with a permits clause closes the type hierarchy, giving the compiler a known and finite set of subtypes to check against
  • A switch expression over a sealed type is exhaustively checked by the compiler — removing any permitted subtype's case is a compile error, not a runtime gap
  • Block cases in switch expressions use yield to return a value from a multi-statement branch, enabling complex per-type logic while preserving exhaustiveness guarantees
  • Adding a new event type to a sealed interface propagates as a compile error to every non-exhaustive switch in the codebase, turning a developer's oversight into a build failure

Чтобы получить доступ к облачной лаборатории, необходимо войти в систему.

Войти