Java Records: глубокое погружение и паттерны использования за пределами DTO

Введение

Java Records, дебютировавшие как preview в Java 14 и ставшие полноценной фичей в Java 16, произвели революцию в создании классов-носителей данных. Однако большинство разработчиков воспринимают Records лишь как лаконичную замену DTO с автоматическими геттерами, equals/hashCode и toString. Это упущение — настоящий потенциал Records значительно шире. За пределами тривиального использования скрываются возможности для построения типобезопасных конфигураций, реализации бизнес-логики через value objects, создания DSL и даже моделирования state-машин. В этом материале мы погрузимся в глубины Records, разберем их внутреннее устройство и исследуем паттерны, которые превращают обычный Record в мощный инструмент архитектурного проектирования.


Внутреннее устройство Records: от синтаксиса до байт-кода

1.1. Что скрывается за лаконичным синтаксисом?

// То, что пишет разработчик
public record Point(int x, int y) {}

// То, что генерирует компилятор
public final class Point extends java.lang.Record {
    private final int x;
    private final int y;
    
    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }
    
    public int x() { return x; }
    public int y() { return y; }
    
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Point p)) return false;
        return x == p.x && y == p.y;
    }
    
    public int hashCode() {
        int result = Integer.hashCode(x);
        result = 31 * result + Integer.hashCode(y);
        return result;
    }
    
    public String toString() {
        return "Point[x=" + x + ", y=" + y + "]";
    }
}

Ключевые характеристики из анализа байт-кода:

  • Record неявно объявлен как final — наследование невозможно

  • Все поля — private и final, гарантируя истинную иммутабельность

  • Методы доступа имеют форму x(), а не getX()

  • Равенство основано на всех компонентах — семантическое, а не ссылочное

  • Record наследуется от java.lang.Record — базового класса для всех записей

1.2. Канонический и компактный конструкторы

// Явный канонический конструктор — полный контроль над валидацией
public record Person(String fullName, int age) {
    public Person(String fullName, int age) {  // Канонический конструктор
        if (age < 0 || age > 150) {
            throw new IllegalArgumentException("Invalid age: " + age);
        }
        if (fullName == null || fullName.isBlank()) {
            throw new IllegalArgumentException("Name cannot be empty");
        }
        this.fullName = fullName.trim();
        this.age = age;
    }
}

// Компактная форма — наиболее идиоматичный подход
public record EmailAddress(String localPart, String domain) {
    public EmailAddress {  // Параметры localPart и domain автоматически доступны
        if (localPart == null || localPart.isBlank()) {
            throw new IllegalArgumentException("Local part cannot be empty");
        }
        if (domain == null || !domain.contains(".")) {
            throw new IllegalArgumentException("Invalid domain: " + domain);
        }
        // Нормализация — параметры можно изменять
        localPart = localPart.toLowerCase().trim();
        domain = domain.toLowerCase().trim();
        // Присваивание полям происходит автоматически после выхода из конструктора
    }
    
    // Дополнительный конструктор
    public EmailAddress(String fullAddress) {
        this(fullAddress.substring(0, fullAddress.indexOf('@')),
             fullAddress.substring(fullAddress.indexOf('@') + 1));
    }
    
    // Фабричный метод
    public static EmailAddress of(String fullAddress) {
        return new EmailAddress(fullAddress);
    }
}

Важные нюансы конструкторов:

  • Компактный конструктор выполняется до присваивания полей

  • В компактном конструкторе можно изменять параметры — это повлияет на финальные поля

  • Нельзя обращаться к this в компактном конструкторе — поля еще не инициализированы

  • Канонический конструктор обязан инициализировать все поля

1.3. Статические компоненты и вспомогательные методы

public record Temperature(double celsius) {
    
    // Статические константы — единственные публичные поля
    public static final Temperature ABSOLUTE_ZERO = new Temperature(-273.15);
    public static final Temperature ROOM_TEMPERATURE = new Temperature(22.0);
    
    // Статические фабричные методы
    public static Temperature fromFahrenheit(double fahrenheit) {
        return new Temperature((fahrenheit - 32) * 5.0 / 9.0);
    }
    
    public static Temperature fromKelvin(double kelvin) {
        return new Temperature(kelvin - 273.15);
    }
    
    // Дополнительные методы экземпляра — расширяют функциональность
    public double fahrenheit() {
        return celsius * 9.0 / 5.0 + 32;
    }
    
    public double kelvin() {
        return celsius + 273.15;
    }
    
    // Бизнес-методы
    public boolean isFreezing() {
        return celsius <= 0;
    }
    
    public boolean isBoiling() {
        return celsius >= 100;
    }
    
    // Методы для операций
    public Temperature add(Temperature other) {
        return new Temperature(this.celsius + other.celsius);
    }
    
    public Temperature subtract(Temperature other) {
        return new Temperature(this.celsius - other.celsius);
    }
}

Паттерны использования в бизнес-логике

2.1. Value Objects с инвариантами и бизнес-методами

// Value Object для денежных сумм — классический пример
public record Money(BigDecimal amount, Currency currency) {
    
    public Money {
        if (amount == null) {
            throw new IllegalArgumentException("Amount cannot be null");
        }
        if (currency == null) {
            throw new IllegalArgumentException("Currency cannot be null");
        }
        if (amount.compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("Amount cannot be negative");
        }
        // Нормализация — убираем лишние знаки после запятой
        amount = amount.stripTrailingZeros();
    }
    
    // Арифметические операции
    public Money add(Money other) {
        requireSameCurrency(other);
        return new Money(this.amount.add(other.amount), this.currency);
    }
    
    public Money subtract(Money other) {
        requireSameCurrency(other);
        if (this.amount.compareTo(other.amount) < 0) {
            throw new IllegalArgumentException("Insufficient funds");
        }
        return new Money(this.amount.subtract(other.amount), this.currency);
    }
    
    public Money multiply(int factor) {
        if (factor < 0) {
            throw new IllegalArgumentException("Factor cannot be negative");
        }
        return new Money(this.amount.multiply(BigDecimal.valueOf(factor)), this.currency);
    }
    
    public Money multiply(BigDecimal factor) {
        if (factor.compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("Factor cannot be negative");
        }
        return new Money(this.amount.multiply(factor), this.currency);
    }
    
    // Операции сравнения
    public boolean isGreaterThan(Money other) {
        requireSameCurrency(other);
        return this.amount.compareTo(other.amount) > 0;
    }
    
    public boolean isLessThan(Money other) {
        requireSameCurrency(other);
        return this.amount.compareTo(other.amount) < 0;
    }
    
    // Процентные операции
    public Money percentage(int percent) {
        if (percent < 0 || percent > 100) {
            throw new IllegalArgumentException("Percent must be between 0 and 100");
        }
        BigDecimal percentValue = BigDecimal.valueOf(percent)
            .divide(BigDecimal.valueOf(100));
        return new Money(this.amount.multiply(percentValue), this.currency);
    }
    
    // Вспомогательные методы
    private void requireSameCurrency(Money other) {
        if (!this.currency.equals(other.currency)) {
            throw new IllegalArgumentException(
                "Cannot operate with different currencies: " + 
                this.currency + " vs " + other.currency);
        }
    }
    
    public String formatted() {
        NumberFormat format = NumberFormat.getCurrencyInstance();
        format.setCurrency(currency);
        return format.format(amount);
    }
    
    public Money negate() {
        return new Money(this.amount.negate(), this.currency);
    }
    
    public boolean isZero() {
        return amount.compareTo(BigDecimal.ZERO) == 0;
    }
    
    // Компоненты для разбиения
    public Money[] split(int parts) {
        if (parts <= 0) {
            throw new IllegalArgumentException("Parts must be positive");
        }
        Money[] result = new Money[parts];
        BigDecimal[] division = amount.divideAndRemainder(BigDecimal.valueOf(parts));
        BigDecimal base = division[0];
        BigDecimal remainder = division[1];
        
        for (int i = 0; i < parts; i++) {
            BigDecimal partAmount = i < remainder.intValue() 
                ? base.add(BigDecimal.ONE) 
                : base;
            result[i] = new Money(partAmount, currency);
        }
        return result;
    }
}

// Использование в бизнес-логике
public class ShoppingCart {
    private Map<Product, Integer> items = new LinkedHashMap<>();
    
    public Money calculateTotal() {
        return items.entrySet().stream()
            .map(e -> e.getKey().price().multiply(e.getValue()))
            .reduce(new Money(BigDecimal.ZERO, Currency.getInstance("USD")), 
                    Money::add);
    }
    
    public void applyDiscount(Money discount) {
        Money newTotal = calculateTotal().subtract(discount);
        // ... логика применения скидки
    }
}

2.2. Композитные ключи для коллекций и кэширования

// Составной ключ для кэша — иммутабельный и с предсказуемым hashCode
public record CacheKey(String tenant, String entityType, String entityId, LocalDateTime version) {
    
    public CacheKey(String tenant, String entityType, String entityId) {
        this(tenant, entityType, entityId, null);
    }
    
    public CacheKey withVersion(LocalDateTime version) {
        return new CacheKey(tenant, entityType, entityId, version);
    }
    
    // Компактное представление для логирования
    public String compact() {
        return version == null 
            ? String.format("%s:%s:%s", tenant, entityType, entityId)
            : String.format("%s:%s:%s@%s", tenant, entityType, entityId, version);
    }
}

// Многоключевой кэш с использованием Records в качестве ключей
@Component
public class AdvancedCache<K, V> {
    private final ConcurrentHashMap<K, CacheEntry<V>> cache = new ConcurrentHashMap<>();
    
    private record CacheEntry<V>(V value, LocalDateTime timestamp) {}
    
    public void put(K key, V value) {
        cache.put(key, new CacheEntry<>(value, LocalDateTime.now()));
    }
    
    public Optional<V> get(K key) {
        return Optional.ofNullable(cache.get(key))
            .map(CacheEntry::value);
    }
    
    public void evictOldEntries(Duration age) {
        LocalDateTime cutoff = LocalDateTime.now().minus(age);
        cache.entrySet().removeIf(entry -> 
            entry.getValue().timestamp().isBefore(cutoff));
    }
}

// Использование в репозитории
@Repository
public class OrderRepository {
    private final AdvancedCache<CacheKey, Order> cache = new AdvancedCache<>();
    
    public Order findById(String tenant, String orderId) {
        CacheKey key = new CacheKey(tenant, "Order", orderId);
        return cache.get(key)
            .orElseGet(() -> {
                Order order = database.findOrder(tenant, orderId);
                cache.put(key, order);
                return order;
            });
    }
}

2.3. Представление состояний в state-машине

// State machine заказа — каждое состояние как отдельный Record
public sealed interface OrderState {
    
    // Состояния как Records с данными, специфичными для состояния
    record Draft(LocalDateTime createdAt, List<OrderItem> items) implements OrderState {
        public Draft {
            if (items == null || items.isEmpty()) {
                throw new IllegalArgumentException("Draft order must have items");
            }
            createdAt = createdAt == null ? LocalDateTime.now() : createdAt;
        }
    }
    
    record Submitted(LocalDateTime submittedAt, String customerId, 
                     Address shippingAddress, Money total) implements OrderState {
        public Submitted {
            if (customerId == null || customerId.isBlank()) {
                throw new IllegalArgumentException("Customer required");
            }
        }
    }
    
    record Paid(LocalDateTime paidAt, String transactionId, 
                String paymentMethod, BigDecimal paidAmount) implements OrderState {
        public Paid {
            if (transactionId == null || transactionId.isBlank()) {
                throw new IllegalArgumentException("Transaction ID required");
            }
            if (paidAmount.compareTo(BigDecimal.ZERO) <= 0) {
                throw new IllegalArgumentException("Paid amount must be positive");
            }
        }
    }
    
    record Shipped(LocalDateTime shippedAt, String trackingNumber, 
                   String carrier, LocalDateTime estimatedDelivery) implements OrderState {
        public Shipped(LocalDateTime shippedAt, String trackingNumber, 
                       String carrier, LocalDateTime estimatedDelivery) {
            this.shippedAt = shippedAt == null ? LocalDateTime.now() : shippedAt;
            this.trackingNumber = trackingNumber;
            this.carrier = carrier;
            this.estimatedDelivery = estimatedDelivery;
        }
    }
    
    record Delivered(LocalDateTime deliveredAt, String signature, 
                     String deliveryPhotoUrl) implements OrderState {
        public Delivered(LocalDateTime deliveredAt, String signature, 
                        String deliveryPhotoUrl) {
            this.deliveredAt = deliveredAt == null ? LocalDateTime.now() : deliveredAt;
            this.signature = signature == null ? "No signature required" : signature;
            this.deliveryPhotoUrl = deliveryPhotoUrl;
        }
    }
    
    record Cancelled(LocalDateTime cancelledAt, String reason, 
                     String cancelledBy) implements OrderState {
        public Cancelled {
            if (reason == null || reason.isBlank()) {
                throw new IllegalArgumentException("Cancellation reason required");
            }
        }
    }
}

// Агрегат Order — иммутабельный с state-based transitions
public record Order(String orderId, OrderState state) {
    
    public Order submit(String customerId, Address shippingAddress) {
        if (!(state instanceof OrderState.Draft draft)) {
            throw new IllegalStateException("Only draft orders can be submitted");
        }
        Money total = calculateTotal(draft.items());
        return new Order(orderId, new OrderState.Submitted(
            LocalDateTime.now(), customerId, shippingAddress, total));
    }
    
    public Order pay(String transactionId, String paymentMethod) {
        if (!(state instanceof OrderState.Submitted submitted)) {
            throw new IllegalStateException("Only submitted orders can be paid");
        }
        if (submitted.total().isZero()) {
            throw new IllegalStateException("Cannot pay for order with zero total");
        }
        return new Order(orderId, new OrderState.Paid(
            LocalDateTime.now(), transactionId, paymentMethod, 
            submitted.total().amount()));
    }
    
    public Order ship(String trackingNumber, String carrier) {
        if (!(state instanceof OrderState.Paid paid)) {
            throw new IllegalStateException("Only paid orders can be shipped");
        }
        return new Order(orderId, new OrderState.Shipped(
            LocalDateTime.now(), trackingNumber, carrier, 
            LocalDateTime.now().plusDays(5)));
    }
    
    public Order deliver(String signature, String photoUrl) {
        if (!(state instanceof OrderState.Shipped shipped)) {
            throw new IllegalStateException("Only shipped orders can be delivered");
        }
        return new Order(orderId, new OrderState.Delivered(
            LocalDateTime.now(), signature, photoUrl));
    }
    
    public Order cancel(String reason, String cancelledBy) {
        if (state instanceof OrderState.Delivered) {
            throw new IllegalStateException("Delivered orders cannot be cancelled");
        }
        if (state instanceof OrderState.Cancelled) {
            throw new IllegalStateException("Order already cancelled");
        }
        return new Order(orderId, new OrderState.Cancelled(
            LocalDateTime.now(), reason, cancelledBy));
    }
    
    private Money calculateTotal(List<OrderItem> items) {
        return items.stream()
            .map(item -> item.product().price().multiply(item.quantity()))
            .reduce(new Money(BigDecimal.ZERO, Currency.getInstance("USD")), 
                    Money::add);
    }
    
    public boolean isActive() {
        return !(state instanceof OrderState.Cancelled || 
                 state instanceof OrderState.Delivered);
    }
    
    public String getStatusDescription() {
        return switch (state) {
            case OrderState.Draft d -> "Draft created at " + d.createdAt();
            case OrderState.Submitted s -> "Submitted at " + s.submittedAt();
            case OrderState.Paid p -> "Paid via " + p.paymentMethod() + " at " + p.paidAt();
            case OrderState.Shipped s -> "Shipped via " + s.carrier() + ", tracking: " + s.trackingNumber();
            case OrderState.Delivered d -> "Delivered at " + d.deliveredAt();
            case OrderState.Cancelled c -> "Cancelled: " + c.reason();
        };
    }
}

2.4. Типобезопасные конфигурации с вложенностью

// Конфигурация приложения — полностью типобезопасная иерархия Records
public record AppConfig(
    DatabaseConfig database,
    RedisConfig redis,
    SecurityConfig security,
    MonitoringConfig monitoring,
    FeaturesConfig features
) {
    public record DatabaseConfig(
        String host,
        int port,
        String databaseName,
        String username,
        String password,
        ConnectionPool poolConfig
    ) {
        public String jdbcUrl() {
            return String.format("jdbc:postgresql://%s:%d/%s", host, port, databaseName);
        }
        
        public record ConnectionPool(
            int maxSize,
            int minSize,
            Duration connectionTimeout,
            Duration idleTimeout
        ) {}
    }
    
    public record RedisConfig(
        String host,
        int port,
        String password,
        RedisCluster cluster,
        RedisCache cacheConfig
    ) {
        public record RedisCluster(
            List<String> nodes,
            int maxRedirects
        ) {}
        
        public record RedisCache(
            Duration defaultTtl,
            Duration maxTtl,
            int maxSize
        ) {}
    }
    
    public record SecurityConfig(
        JwtConfig jwt,
        RateLimitConfig rateLimit,
        CORSConfig cors
    ) {
        public record JwtConfig(
            String secret,
            Duration expiration,
            String issuer,
            List<String> audiences
        ) {}
        
        public record RateLimitConfig(
            int maxRequests,
            Duration window,
            boolean distributed
        ) {}
        
        public record CORSConfig(
            List<String> allowedOrigins,
            List<String> allowedMethods,
            List<String> allowedHeaders
        ) {}
    }
    
    public record MonitoringConfig(
        boolean metricsEnabled,
        boolean tracingEnabled,
        String metricsEndpoint,
        TraceSampler traceSampler
    ) {
        public record TraceSampler(
            double rate,
            String decisionStrategy
        ) {}
    }
    
    public record FeaturesConfig(
        boolean caching,
        boolean asyncProcessing,
        boolean auditLogging,
        boolean healthChecks
    ) {}
}

// Загрузка конфигурации из различных источников
@Component
public class ConfigurationLoader {
    
    public AppConfig loadFromProperties(Properties props) {
        return new AppConfig(
            loadDatabaseConfig(props),
            loadRedisConfig(props),
            loadSecurityConfig(props),
            loadMonitoringConfig(props),
            loadFeaturesConfig(props)
        );
    }
    
    public AppConfig loadFromYAML(Path yamlPath) {
        // Загрузка из YAML с использованием Jackson
        try {
            ObjectMapper mapper = new ObjectMapper(new YAMLFactory());
            return mapper.readValue(yamlPath.toFile(), AppConfig.class);
        } catch (IOException e) {
            throw new RuntimeException("Failed to load config: " + e.getMessage());
        }
    }
    
    private AppConfig.DatabaseConfig loadDatabaseConfig(Properties props) {
        return new AppConfig.DatabaseConfig(
            props.getProperty("db.host"),
            Integer.parseInt(props.getProperty("db.port")),
            props.getProperty("db.name"),
            props.getProperty("db.username"),
            props.getProperty("db.password"),
            new AppConfig.DatabaseConfig.ConnectionPool(
                Integer.parseInt(props.getProperty("db.pool.maxSize")),
                Integer.parseInt(props.getProperty("db.pool.minSize")),
                Duration.parse(props.getProperty("db.pool.connectionTimeout")),
                Duration.parse(props.getProperty("db.pool.idleTimeout"))
            )
        );
    }
    
    // ... остальные методы загрузки
}

// Использование в приложении
@Component
public class ApplicationInitializer {
    
    @Value("${app.config.path}")
    private String configPath;
    
    @PostConstruct
    public void init() {
        ConfigurationLoader loader = new ConfigurationLoader();
        AppConfig config = loader.loadFromYAML(Path.of(configPath));
        
        // Типобезопасный доступ к настройкам
        String jdbcUrl = config.database().jdbcUrl();
        int maxRequests = config.security().rateLimit().maxRequests();
        boolean caching = config.features().caching();
        
        // IntelliSense и compile-time проверки
        System.out.println("Connected to: " + jdbcUrl);
    }
}

Records в функциональном программировании

3.1. Pattern matching и деструктуризация

// Sealed иерархия с Records для pattern matching
public sealed interface Result<T> {
    record Success<T>(T value, String message) implements Result<T> {}
    record Failure<T>(String error, Throwable cause) implements Result<T> {}
    record Loading<T>() implements Result<T> {}
    record Empty<T>() implements Result<T> {}
}

@Service
public class OrderProcessor {
    
    public String processResult(Result<Order> result) {
        // Полноценное pattern matching с destructuring
        return switch (result) {
            case Result.Success<Order>(var order, var msg) when order.amount().isGreaterThan(Money.ZERO) ->
                "Success: " + msg + " - Order total: " + order.total().formatted();
                
            case Result.Success<Order>(var order, var msg) ->
                "Success with zero amount: " + msg;
                
            case Result.Failure<Order>(var error, var cause) when cause != null ->
                "Error: " + error + " (cause: " + cause.getMessage() + ")";
                
            case Result.Failure<Order>(var error, null) ->
                "Error: " + error;
                
            case Result.Loading<Order>() ->
                "Processing in progress...";
                
            case Result.Empty<Order>() ->
                "No data available";
        };
    }
    
    // Композиция функций с Records
    public Result<Order> fetchOrder(String orderId) {
        try {
            Order order = database.findOrder(orderId);
            if (order == null) {
                return new Result.Empty<>();
            }
            return new Result.Success<>(order, "Order retrieved successfully");
        } catch (SQLException e) {
            return new Result.Failure<>("Database error: " + e.getMessage(), e);
        }
    }
    
    public Result<Order> validateOrder(Result<Order> result) {
        return switch (result) {
            case Result.Success<Order>(var order, var msg) when order.amount().isZero() ->
                new Result.Failure<>("Order amount cannot be zero", null);
            case Result.Success<Order>(var order, var msg) ->
                result; // Valid order
            default -> result; // Propagate other results
        };
    }
}

3.2. Композиция и трансформация с помощью Records

// Функторы и аппликативы с использованием Records
public record Functor<T>(T value) {
    
    public <R> Functor<R> map(Function<T, R> f) {
        return new Functor<>(f.apply(value));
    }
    
    public <R> Functor<R> flatMap(Function<T, Functor<R>> f) {
        return f.apply(value);
    }
    
    public T getOrElse(T defaultValue) {
        return value != null ? value : defaultValue;
    }
}

// Either монада как Record
public sealed interface Either<L, R> {
    record Left<L, R>(L value) implements Either<L, R> {}
    record Right<L, R>(R value) implements Either<L, R> {}
    
    default <T> T fold(Function<L, T> leftFn, Function<R, T> rightFn) {
        return switch (this) {
            case Left<L, R>(var l) -> leftFn.apply(l);
            case Right<L, R>(var r) -> rightFn.apply(r);
        };
    }
    
    default <T> Either<L, T> map(Function<R, T> fn) {
        return match(
            left -> new Either.Left<>(left.value()),
            right -> new Either.Right<>(fn.apply(right.value()))
        );
    }
    
    default Either<L, R> onLeft(Consumer<L> consumer) {
        if (this instanceof Either.Left<L, R>(var l)) {
            consumer.accept(l);
        }
        return this;
    }
    
    default Either<L, R> onRight(Consumer<R> consumer) {
        if (this instanceof Either.Right<L, R>(var r)) {
            consumer.accept(r);
        }
        return this;
    }
    
    private <T> T match(Function<L, T> left, Function<R, T> right) {
        return fold(left, right);
    }
}

// Try монада как Record
public sealed interface Try<T> {
    record Success<T>(T value) implements Try<T> {}
    record Failure<T>(Throwable error) implements Try<T> {}
    
    static <T> Try<T> of(CheckedSupplier<T> supplier) {
        try {
            return new Success<>(supplier.get());
        } catch (Throwable t) {
            return new Failure<>(t);
        }
    }
    
    default Try<T> onSuccess(Consumer<T> consumer) {
        if (this instanceof Success<T>(var v)) {
            consumer.accept(v);
        }
        return this;
    }
    
    default Try<T> onFailure(Consumer<Throwable> consumer) {
        if (this instanceof Failure<T>(var e)) {
            consumer.accept(e);
        }
        return this;
    }
    
    default <R> Try<R> map(Function<T, R> fn) {
        return switch (this) {
            case Success<T>(var v) -> Try.of(() -> fn.apply(v));
            case Failure<T>(var e) -> new Failure<>(e);
        };
    }
    
    default T getOrElse(T defaultValue) {
        return this instanceof Success<T>(var v) ? v : defaultValue;
    }
}

@FunctionalInterface
interface CheckedSupplier<T> {
    T get() throws Throwable;
}

Продвинутые паттерны и кастомизация

4.1. Строитель (Builder) для Records

// Builder паттерн для Records с многими параметрами
public record Product(String id, String name, String description, 
                      Money price, List<String> categories, 
                      List<String> tags, Map<String, String> metadata,
                      LocalDate createdAt, boolean active) {
    
    public static Builder builder() {
        return new Builder();
    }
    
    public static class Builder {
        private String id;
        private String name;
        private String description = "";
        private Money price;
        private List<String> categories = new ArrayList<>();
        private List<String> tags = new ArrayList<>();
        private Map<String, String> metadata = new HashMap<>();
        private LocalDate createdAt = LocalDate.now();
        private boolean active = true;
        
        public Builder id(String id) {
            this.id = id;
            return this;
        }
        
        public Builder name(String name) {
            this.name = name;
            return this;
        }
        
        public Builder description(String description) {
            this.description = description;
            return this;
        }
        
        public Builder price(Money price) {
            this.price = price;
            return this;
        }
        
        public Builder addCategory(String category) {
            this.categories.add(category);
            return this;
        }
        
        public Builder addTag(String tag) {
            this.tags.add(tag);
            return this;
        }
        
        public Builder putMetadata(String key, String value) {
            this.metadata.put(key, value);
            return this;
        }
        
        public Builder createdAt(LocalDate createdAt) {
            this.createdAt = createdAt;
            return this;
        }
        
        public Builder active(boolean active) {
            this.active = active;
            return this;
        }
        
        public Product build() {
            if (id == null || id.isBlank()) {
                throw new IllegalStateException("Product ID is required");
            }
            if (name == null || name.isBlank()) {
                throw new IllegalStateException("Product name is required");
            }
            if (price == null) {
                throw new IllegalStateException("Product price is required");
            }
            return new Product(id, name, description, price, 
                               List.copyOf(categories), 
                               List.copyOf(tags),
                               Map.copyOf(metadata),
                               createdAt, active);
        }
    }
}

// Использование
Product product = Product.builder()
    .id("P-123")
    .name("Ultra Laptop")
    .description("High-performance laptop for developers")
    .price(new Money(new BigDecimal("1299.99"), Currency.getInstance("USD")))
    .addCategory("Electronics")
    .addCategory("Computers")
    .addTag("laptop")
    .addTag("new")
    .putMetadata("color", "silver")
    .putMetadata("processor", "Intel i7")
    .active(true)
    .build();

4.2. Wither методы для «изменения» иммутабельных Records

// Wither методы — создание копий с изменением компонентов
public record Cart(String cartId, List<CartItem> items, Money total, 
                   LocalDateTime lastUpdated) {
    
    public Cart {
        items = List.copyOf(items); // Защитная копия
        if (total == null) {
            total = calculateTotal(items);
        }
    }
    
    public Cart withItems(List<CartItem> newItems) {
        return new Cart(cartId, newItems, null, LocalDateTime.now());
    }
    
    public Cart addItem(CartItem item) {
        List<CartItem> newItems = new ArrayList<>(items);
        newItems.add(item);
        return new Cart(cartId, newItems, null, LocalDateTime.now());
    }
    
    public Cart removeItem(String productId) {
        List<CartItem> newItems = items.stream()
            .filter(i -> !i.productId().equals(productId))
            .collect(Collectors.toList());
        return new Cart(cartId, newItems, null, LocalDateTime.now());
    }
    
    public Cart updateQuantity(String productId, int quantity) {
        List<CartItem> newItems = items.stream()
            .map(item -> item.productId().equals(productId) 
                ? item.withQuantity(quantity) 
                : item)
            .collect(Collectors.toList());
        return new Cart(cartId, newItems, null, LocalDateTime.now());
    }
    
    public Cart clear() {
        return new Cart(cartId, List.of(), null, LocalDateTime.now());
    }
    
    private static Money calculateTotal(List<CartItem> items) {
        return items.stream()
            .map(item -> item.price().multiply(item.quantity()))
            .reduce(new Money(BigDecimal.ZERO, Currency.getInstance("USD")), 
                    Money::add);
    }
}

// CartItem тоже Record с wither-методами
public record CartItem(String productId, String productName, 
                       Money price, int quantity) {
    
    public CartItem {
        if (quantity <= 0) {
            throw new IllegalArgumentException("Quantity must be positive");
        }
    }
    
    public CartItem withQuantity(int newQuantity) {
        return new CartItem(productId, productName, price, newQuantity);
    }
    
    public Money subtotal() {
        return price.multiply(quantity);
    }
}

// Использование в сервисе
@Service
public class CartService {
    
    public Cart updateCartItem(Cart cart, String productId, int newQuantity) {
        return cart.updateQuantity(productId, newQuantity);
    }
    
    public Cart addToCart(Cart cart, CartItem newItem) {
        return cart.addItem(newItem);
    }
}

Интеграция с популярными фреймворками

5.1. Spring Data JPA Projections с Records

// Record как projection для Spring Data JPA
@Repository
public interface OrderRepository extends JpaRepository<OrderEntity, Long> {
    
    // Interface-based projection
    interface OrderSummaryProjection {
        Long getId();
        String getCustomerName();
        BigDecimal getTotalAmount();
        LocalDateTime getCreatedAt();
    }
    
    // Record-based projection — более чистый подход
    record OrderSummary(
        Long id,
        String customerName,
        BigDecimal totalAmount,
        LocalDateTime createdAt
    ) {}
    
    // JPQL с конструктором Record
    @Query("""
        SELECT new com.example.OrderSummary(
            o.id, 
            o.customer.name, 
            o.totalAmount, 
            o.createdAt
        ) FROM OrderEntity o 
        WHERE o.status = :status
        """)
    List<OrderSummary> findOrderSummariesByStatus(@Param("status") OrderStatus status);
    
    // Native query с mapping
    @Query(value = """
        SELECT 
            o.id as id,
            c.name as customerName,
            o.total as totalAmount,
            o.created_at as createdAt
        FROM orders o
        JOIN customers c ON o.customer_id = c.id
        WHERE o.created_at > :date
        """, nativeQuery = true)
    List<OrderSummary> findRecentOrders(@Param("date") LocalDateTime date);
    
    // Использование с Sort и Pageable
    @Query("SELECT o.id, o.customer.name, o.totalAmount, o.createdAt FROM OrderEntity o")
    Page<OrderSummary> findAllOrderSummaries(Pageable pageable);
}

// Record в качестве тела запроса/ответа для REST API
@RestController
@RequestMapping("/api/orders")
public class OrderController {
    
    @PostMapping
    public ResponseEntity<OrderResponse> createOrder(@RequestBody CreateOrderRequest request) {
        Order order = orderService.createOrder(request);
        return ResponseEntity.ok(new OrderResponse(order));
    }
    
    public record CreateOrderRequest(
        String customerId,
        List<OrderItemRequest> items,
        Address shippingAddress
    ) {
        public CreateOrderRequest {
            if (items == null || items.isEmpty()) {
                throw new IllegalArgumentException("Items cannot be empty");
            }
        }
    }
    
    public record OrderItemRequest(String productId, int quantity) {}
    
    public record OrderResponse(
        String orderId,
        String status,
        Money total,
        LocalDateTime createdAt
    ) {
        public OrderResponse(Order order) {
            this(order.getId().toString(), 
                 order.getStatus().name(), 
                 order.getTotal(), 
                 order.getCreatedAt());
        }
    }
}

5.2. Jackson и JSON сериализация

// Кастомизация JSON для Records
@JsonIgnoreProperties(ignoreUnknown = true)
public record UserProfile(
    @JsonProperty(required = true) 
    String userId,
    
    @JsonProperty("full_name")
    String fullName,
    
    @JsonFormat(pattern = "yyyy-MM-dd")
    LocalDate dateOfBirth,
    
    @JsonUnwrapped
    ContactInfo contact,
    
    @JsonIgnore
    boolean internalField
    
) {
    // Кастомный десериализатор
    @JsonCreator
    public static UserProfile of(
        @JsonProperty("user_id") String userId,
        @JsonProperty("name") String name,
        @JsonProperty("dob") String dobStr
    ) {
        LocalDate dob = LocalDate.parse(dobStr);
        return new UserProfile(userId, name, dob, null, false);
    }
    
    // Кастомный сериализатор
    @JsonProperty("age")
    public int getAge() {
        return Period.between(dateOfBirth, LocalDate.now()).getYears();
    }
}

public record ContactInfo(String email, String phone, Address address) {}

// Использование с ObjectMapper
@Test
public void testRecordSerialization() {
    ObjectMapper mapper = JsonMapper.builder()
        .findAndAddModules()
        .build();
    
    UserProfile user = new UserProfile(
        "123", 
        "John Doe", 
        LocalDate.of(1990, 1, 1),
        new ContactInfo("john@example.com", "555-1234", null),
        true
    );
    
    String json = mapper.writeValueAsString(user);
    // {"userId":"123","full_name":"John Doe","dateOfBirth":"1990-01-01",
    //  "email":"john@example.com","phone":"555-1234","age":34}
    
    UserProfile deserialized = mapper.readValue(json, UserProfile.class);
}

Заключение

Java Records — это не просто синтаксический сахар для создания DTO. Это фундаментальный инструмент, меняющий подход к проектированию данных в Java-приложениях. Правильно используя Records, разработчик получает:

  1. Встроенную защиту от ошибок через конструкторы с валидацией — невозможно создать некорректный объект

  2. Безопасность в многопоточных средах — иммутабельность исключает race conditions

  3. Прозрачное представление данных — toString, equals и hashCode гарантированно корректны

  4. Семантическое сравнение объектов — вместо ссылочного сравнения

  5. Идеальную основу для функционального программирования — работа с pattern matching и монадами