Опирается на правила: JS-8.1JS-8.7 из Java Style Guide → раздел 8. Современные фичи Java.

Важно знать

  • Раздел применяется если Java 21+. На Java 17 — только record + sealed без patterns. Java 11 — не применим.
  • switch expression на sealed — exhaustiveness check от компилятора.
  • Record patterns в case — деконструкция: case Created(UUID id) -> id.
  • Record patterns в instanceof — деконструкция без binding+getter.
  • Exhaustive switch без defaultdefault превращает compile-time error в runtime.
  • Sealed interfaces — closed-набор альтернатив (None/Reused/Created).
  • String.formatted() вместо String.format().
  • record для in-class data carriers — 3-5 полей, только в этом классе.

Java 21 — текущая LTS-версия для UCP-проектов. Sealed interfaces + record patterns + exhaustive switch дают компилятор-enforced полноту обработки вариантов. Это в разы лучше runtime-pattern matching через instanceof + if/else.

Switch expression на sealed

JS-8.1: вместо if-else цепочек.

public sealed interface ParkingResolution {
    record None() implements ParkingResolution {}
    record Reused(UUID id) implements ParkingResolution {}
    record Created(UUID id) implements ParkingResolution {}
}

// ✓ — switch expression, exhaustiveness check
public UUID extractId(ParkingResolution parking) {
    return switch (parking) {
        case ParkingResolution.None ignored -> null;
        case ParkingResolution.Reused(UUID id) -> id;
        case ParkingResolution.Created(UUID id) -> id;
    };
}

// ✗ — instanceof chain, exhaustiveness не гарантируется
public UUID extractId(ParkingResolution parking) {
    if (parking instanceof ParkingResolution.None) return null;
    if (parking instanceof ParkingResolution.Reused r) return r.id();
    if (parking instanceof ParkingResolution.Created c) return c.id();
    throw new IllegalStateException("Unhandled: " + parking);
}

Что даёт switch:

  • Compile-time проверка покрытия всех вариантов sealed-иерархии.
  • При добавлении record Pending(UUID id) implements ParkingResolution — компилятор сломает сборку: «не обработан Pending».
  • Без unreachable throw new IllegalStateException(...) — компилятор знает, что после exhaustive switch return unreachable.

Record patterns в case

JS-8.2: деконструкция.

// ✓ — деконструкция в case
case ParkingResolution.Created(UUID id) -> id;

// ✗ — binding + геттер
case ParkingResolution.Created c -> c.id();

Record pattern достаёт поля прямо в case. Меньше кода, нет промежуточной переменной.

Когда поле есть, но не нужно:

case ParkingResolution.None ignored -> null;

ignored (Java convention) — binding нужен для type matching, но значение не используется.

Record patterns в instanceof

JS-8.3: то же для if.

// ✓ — record pattern
if (parking instanceof ParkingResolution.Created(UUID parkingSessionId)) {
    parkingSessionService.cancelParking(parkingSessionId);
}

// ✗ — binding + геттер
if (parking instanceof ParkingResolution.Created created) {
    parkingSessionService.cancelParking(created.id());
}

Тот же принцип: достаём поля прямо в instanceof.

Exhaustive switch без default

JS-8.4: critical для sealed.

// ✓ — компилятор сломает сборку, если добавить новый ParkingResolution
return switch (parking) {
    case ParkingResolution.None ignored -> null;
    case ParkingResolution.Reused(UUID id) -> id;
    case ParkingResolution.Created(UUID id) -> id;
};

// ✗ — default превращает compile-time error в runtime
return switch (parking) {
    case ParkingResolution.None ignored -> null;
    case ParkingResolution.Reused(UUID id) -> id;
    case ParkingResolution.Created(UUID id) -> id;
    default -> throw new IllegalStateException("Unhandled: " + parking);
};

Сценарий с default:

  • Добавили record Pending(UUID id) implements ParkingResolution.
  • Компилятор не ругается — default обрабатывает.
  • При первом Pending в runtime — IllegalStateException.
  • Инцидент в проде.

Без default:

  • Добавили Pending.
  • Компилятор сразу ругается во всех местах switch.
  • Не получится merge PR без обработки.
  • Compile-time → runtime прокидывание удалено.

default оправдан только для open-иерархий (switch по String, int, enum, который может расширяться).

Sealed interfaces

JS-8.5: closed-набор альтернатив.

// ✓ — sealed: компилятор знает полный список implements
public sealed interface ParkingResolution {
    record None() implements ParkingResolution {}
    record Reused(UUID id) implements ParkingResolution {}
    record Created(UUID id) implements ParkingResolution {}

    static ParkingResolution none() { return new None(); }
}

public sealed interface OrderResult {
    record Success(Order order) implements OrderResult {}
    record Failure(String reason) implements OrderResult {}
}

Преимущества:

  • Exhaustiveness check в switch (JS-8.1).
  • Документирует возможные результаты — читатель видит «3 варианта resolution».
  • Refactoring safety — добавил вариант, компилятор показывает все switch-места требующие обновления.

sealed не для:

  • Open-расширяемых иерархий (доменные базовые классы для extension в other modules/services). Используем обычный interface.

String.formatted()

JS-8.6: вместо String.format.

// ✓ — линейное чтение
"User(id=%s) default card not found".formatted(userId);
log.info("Order created: orderId={} amount={}".formatted(orderId, amount));

// ✗ — формат в начале, аргументы в конце
String.format("User(id=%s) default card not found", userId);
log.info(String.format("Order created: orderId=%s amount=%s", orderId, amount));

Чтение слева-направо: «строка → подставить аргументы». String.format(...) заставляет глаз бегать туда-обратно. Особенно заметно в длинных exception-сообщениях.

Альтернатива в Spring (через slf4j placeholders) — обычно лучше для logging:

log.info("Order created: orderId={} amount={}", orderId, amount);

{}-placeholders ленивы (не выполняют toString при выключенном уровне). Используем для логов. .formatted() — для exception messages и других eager-форматов.

Records для in-class data carriers

JS-8.7: 3-5 полей, только в одном классе.

@UseCase
@RequiredArgsConstructor
public class StartChargingHandler implements UseCaseHandler<StartChargingCommand, Session> {

    private final ConnectorsRepository connectorsRepository;
    private final ChargeStationsRepository chargeStationsRepository;
    private final LocationsRepository locationsRepository;

    // ✓ — private record для группировки данных
    private record ChargingContext(
        ConnectorsPojo connector,
        ChargeStationsPojo chargeStation,
        LocationsPojo location
    ) {}

    public Session handle(StartChargingCommand command) {
        var context = loadContext(command.connectorId());
        return start(context, command);
    }

    private ChargingContext loadContext(Long connectorId) {
        var connector = connectorsRepository.findById(connectorId).orElseThrow();
        var station = chargeStationsRepository.findById(connector.getStationId()).orElseThrow();
        var location = locationsRepository.findById(station.getLocationId()).orElseThrow();
        return new ChargingContext(connector, station, location);
    }
}

// ✗ — Lombok-класс ради тех же геттеров
@Getter
@RequiredArgsConstructor
private static class ChargingContext {
    private final ConnectorsPojo connector;
    private final ChargeStationsPojo chargeStation;
    private final LocationsPojo location;
}

Когда private record подходит:

  • 3-5 полей, связанных по смыслу.
  • Используется только в этом классе (private).
  • Нет custom behaviour (только data).

Когда VO (отдельный класс в domain/):

  • Используется в нескольких классах.
  • Имеет доменные методы (Money.add(), OrderId.next()).
  • Часть бизнес-словаря.

Lombok поверх records запрещён (JS-6.4).

Что запрещено

АнтипаттернПравилоЧто взамен
if/else if/else chain для sealedJS-8.1switch expression
case X x -> x.field() (binding + getter)JS-8.2record pattern case X(field)
instanceof X x + x.field()JS-8.3instanceof X(field)
default -> в exhaustive switch на sealedJS-8.4без default
String.format("...", args)JS-8.6"...".formatted(args)
@Getter @RequiredArgsConstructor private data classJS-8.7private record
Lombok поверх recordJS-6.4record даёт всё сам
Sealed для open-расширяемой иерархииJS-8.5обычный interface

Куда дальше

  • Java → раздел 8. Современные фичи Java — нормативные формулировки.
  • Именование — record accessor name() (без get).
  • Выражения — guard expression vs switch.
  • Lombok — Lombok НЕ на record.
  • Комментарии — sealed switch устраняет необходимость в комментариях о вариантах.
  • DDD → value-objects — record для domain VO.
  • Distributed → compensation — sealed Result.Success/Result.Failure.