Введение
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 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 = 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; } public boolean isLessThan(Money other) { requireSameCurrency(other); return this.amount.compareTo(other.amount) 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 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 { private final ConcurrentHashMap > cache = new ConcurrentHashMap(); private record CacheEntry (V value, LocalDateTime timestamp) {} public void put(K key, V value) { cache.put(key, new CacheEntry(value, LocalDateTime.now())); } public Optional 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 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 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) 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 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 audiences ) {} public record RateLimitConfig( int maxRequests, Duration window, boolean distributed ) {} public record CORSConfig( List allowedOrigins, List allowedMethods, List 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 { record Success (T value, String message) implements Result {} record Failure (String error, Throwable cause) implements Result {} record Loading () implements Result {} record Empty () implements Result {} } @Service public class OrderProcessor { public String processResult(Result result) { // Полноценное pattern matching с destructuring return switch (result) { case Result.Success (var order, var msg) when order.amount().isGreaterThan(Money.ZERO) -> "Success: " + msg + " - Order total: " + order.total().formatted(); case Result.Success (var order, var msg) -> "Success with zero amount: " + msg; case Result.Failure (var error, var cause) when cause != null -> "Error: " + error + " (cause: " + cause.getMessage() + ")"; case Result.Failure (var error, null) -> "Error: " + error; case Result.Loading () -> "Processing in progress..."; case Result.Empty () -> "No data available"; }; } // Композиция функций с Records public Result 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 validateOrder(Result result) { return switch (result) { case Result.Success (var order, var msg) when order.amount().isZero() -> new Result.Failure("Order amount cannot be zero", null); case Result.Success (var order, var msg) -> result; // Valid order default -> result; // Propagate other results }; } }
3.2. Композиция и трансформация с помощью Records
// Функторы и аппликативы с использованием Records public record Functor (T value) { public Functor map(Function f) { return new Functor(f.apply(value)); } public Functor flatMap(Function > f) { return f.apply(value); } public T getOrElse(T defaultValue) { return value != null ? value : defaultValue; } } // Either монада как Record public sealed interface Either { record Left (L value) implements Either {} record Right (R value) implements Either {} default T fold(Function leftFn, Function rightFn) { return switch (this) { case Left (var l) -> leftFn.apply(l); case Right (var r) -> rightFn.apply(r); }; } default Either map(Function fn) { return match( left -> new Either.Left(left.value()), right -> new Either.Right(fn.apply(right.value())) ); } default Either onLeft(Consumer consumer) { if (this instanceof Either.Left (var l)) { consumer.accept(l); } return this; } default Either onRight(Consumer consumer) { if (this instanceof Either.Right (var r)) { consumer.accept(r); } return this; } private T match(Function left, Function right) { return fold(left, right); } } // Try монада как Record public sealed interface Try { record Success (T value) implements Try {} record Failure (Throwable error) implements Try {} static Try of(CheckedSupplier supplier) { try { return new Success(supplier.get()); } catch (Throwable t) { return new Failure(t); } } default Try onSuccess(Consumer consumer) { if (this instanceof Success (var v)) { consumer.accept(v); } return this; } default Try onFailure(Consumer consumer) { if (this instanceof Failure (var e)) { consumer.accept(e); } return this; } default Try map(Function fn) { return switch (this) { case Success (var v) -> Try.of(() -> fn.apply(v)); case Failure (var e) -> new Failure(e); }; } default T getOrElse(T defaultValue) { return this instanceof Success (var v) ? v : defaultValue; } } @FunctionalInterface interface CheckedSupplier { T get() throws Throwable; }
Продвинутые паттерны и кастомизация
4.1. Строитель (Builder) для Records
// Builder паттерн для Records с многими параметрами public record Product(String id, String name, String description, Money price, List categories, List tags, Map 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 categories = new ArrayList(); private List tags = new ArrayList(); private Map 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 items, Money total, LocalDateTime lastUpdated) { public Cart { items = List.copyOf(items); // Защитная копия if (total == null) { total = calculateTotal(items); } } public Cart withItems(List newItems) { return new Cart(cartId, newItems, null, LocalDateTime.now()); } public Cart addItem(CartItem item) { List newItems = new ArrayList(items); newItems.add(item); return new Cart(cartId, newItems, null, LocalDateTime.now()); } public Cart removeItem(String productId) { List 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 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 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
Интеграция с популярными фреймворками
5.1. Spring Data JPA Projections с Records
// Record как projection для Spring Data JPA @Repository public interface OrderRepository extends JpaRepository { // 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 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 findRecentOrders(@Param("date") LocalDateTime date); // Использование с Sort и Pageable @Query("SELECT o.id, o.customer.name, o.totalAmount, o.createdAt FROM OrderEntity o") Page findAllOrderSummaries(Pageable pageable); } // Record в качестве тела запроса/ответа для REST API @RestController @RequestMapping("/api/orders") public class OrderController { @PostMapping public ResponseEntity createOrder(@RequestBody CreateOrderRequest request) { Order order = orderService.createOrder(request); return ResponseEntity.ok(new OrderResponse(order)); } public record CreateOrderRequest( String customerId, List 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, разработчик получает:
- Встроенную защиту от ошибок через конструкторы с валидацией — невозможно создать некорректный объект
- Безопасность в многопоточных средах — иммутабельность исключает race conditions
- Прозрачное представление данных — toString, equals и hashCode гарантированно корректны
- Семантическое сравнение объектов — вместо ссылочного сравнения
- Идеальную основу для функционального программирования — работа с pattern matching и монадами