Optional, record, sealed: Java hiện đại trong shop-backend
Tiếp đi: Optional và mấy tính năng Java mới (record, sealed).
Bài 1.12 đã gom doanh thu theo khách. Giờ endpoint GET /orders/{id} cần: tìm đơn (có thể không có), trả DTO, và xử lý các kiểu thanh toán khác nhau. Ba công cụ: Optional (Java 8), record (Java 16), sealed + pattern matching switch (Java 17/21).
1. Bài toán: null ở khắp nơi
Order order = orderRepository.findById(id); // có thể null
String email = order.getCustomer().getEmail(); // NPE nếu order hoặc customer null
return email.toLowerCase();
2. Vấn đề
- Nhìn chữ ký
Order findById(...)không biết có trả null không. Quên check là NPE trên production. - Check null lồng nhau (
if != null3 tầng) làm code khó đọc. - DTO viết tay: field, constructor, getter,
equals,hashCode,toString→ 50 dòng cho 3 field.
3. Giải pháp
- Optional<T>: hộp chứa 0 hoặc 1 giá trị, dùng làm kiểu trả về để nói rõ "có thể không có kết quả". Spring Data trả
Optional<Order> findById. - record: class immutable chỉ để mang dữ liệu; compiler sinh constructor, accessor,
equals/hashCode/toString. - sealed: giới hạn ai được kế thừa; kết hợp pattern matching
switchđể compiler kiểm tra đủ mọi trường hợp.
4. Code từng bước
Bước 1: Optional đúng cách
public OrderResponse getOrder(String id) {
return orderRepository.findById(id)
.map(OrderResponse::from) // có thì map sang DTO
.orElseThrow(() -> new OrderNotFoundException(id)); // không có thì 404
}
String email = orderRepository.findById(id)
.map(Order::getCustomer)
.map(Customer::getEmail) // null ở giữa → empty
.map(String::toLowerCase)
.orElse("unknown");
Bước 2: orElse vs orElseGet
// orElse: tham số LUÔN được tính, kể cả khi Optional có giá trị
Customer c1 = findCached(id).orElse(customerClient.fetch(id)); // ❌ luôn gọi HTTP
// orElseGet: Supplier, chỉ chạy khi rỗng
Customer c2 = findCached(id).orElseGet(() -> customerClient.fetch(id)); // ✅ lazy
// orElse chỉ dùng với hằng/giá trị có sẵn: .orElse(BigDecimal.ZERO)
Bước 3: record cho DTO
public record OrderResponse(String id, String customerId, BigDecimal total, OrderStatus status) {
public OrderResponse { // compact constructor: validate
Objects.requireNonNull(id);
if (total.signum() < 0) throw new IllegalArgumentException("total < 0");
}
public static OrderResponse from(Order o) {
return new OrderResponse(o.getId(), o.getCustomerId(), o.getTotal(), o.getStatus());
}
}
// dùng: r.total() (không phải getTotal()); Jackson 2.12+ serialize/deserialize được
Bước 4: sealed + pattern matching switch (Java 21)
public sealed interface PaymentInfo permits Momo, VnPay, Card {}
public record Momo(String phone) implements PaymentInfo {}
public record VnPay(String bankCode) implements PaymentInfo {}
public record Card(String last4, boolean international) implements PaymentInfo {}
BigDecimal fee(PaymentInfo p, BigDecimal total) {
return switch (p) { // không cần default: compiler biết đủ 3 loại
case Momo m -> BigDecimal.ZERO;
case VnPay v -> new BigDecimal("1100");
case Card c when c.international() -> total.multiply(new BigDecimal("0.03"));
case Card c -> total.multiply(new BigDecimal("0.02"));
};
}
// thêm record ApplePay implements PaymentInfo → switch trên báo compile error đến khi xử lý
Bước 5: var và text block (ngắn)
var orders = new ArrayList<Order>(); // Java 10: chỉ biến local, kiểu vẫn tĩnh
var byCustomer = report.byCustomer(); // ❌ tránh khi vế phải không lộ kiểu
String sql = """
SELECT customer_id, SUM(total)
FROM orders
WHERE status = 'PAID'
GROUP BY customer_id
"""; // Java 15: chuỗi nhiều dòng, không cần \n và +
5. Cách hoạt động
findById(id) ──► Optional[Order] ──map──► Optional[OrderResponse] ──orElseThrow──► OrderResponse
└─► Optional.empty ──map (bỏ qua)──► empty ──orElseThrow──► OrderNotFoundException → 404
switch (p) sealed PaymentInfo = { Momo | VnPay | Card }
├─ Momo → 0
├─ VnPay → 1100
└─ Card → when international ? 3% : 2% (exhaustive, compiler kiểm tra)
6. So sánh
| record | class thường | Lombok @Data | |
|---|---|---|---|
| Immutable | ✅ field final | Tùy | ❌ có setter |
| Kế thừa | Không extends được (ngầm extends Record, là final); implement interface được | Được | Được |
| Boilerplate | Không | Nhiều | Không (cần thư viện) |
| JPA entity | ❌ (cần no-arg constructor, mutable, proxy) | ✅ | ✅ (cẩn thận equals) |
| Hợp nhất cho | DTO, request/response, value object, key của Map | Entity, object có hành vi | Entity |
7. Khi nào nên dùng / không nên
- Optional: chỉ làm kiểu trả về. Không làm field (không Serializable, tốn object), không làm tham số method (người gọi vẫn truyền
nullđược), không bọc collection (trả list rỗng). - Không gọi
get()khi chưa chắc có giá trị; tránhisPresent()+get(), dùngmap/orElseThrow/ifPresent. - record: DTO, event, kết quả query; không dùng cho JPA entity.
- sealed: tập kiểu đóng mà bạn kiểm soát (loại thanh toán, kết quả
Success | Failure); không dùng cho chỗ cần plugin mở rộng.
Sao không cho Optional làm tham số với field? Mình thấy để Optional<String> voucherCode cho rõ là có thể không có mà.
Ý định đúng, nhưng Optional làm tham số không đạt được mục đích đó:
BigDecimal checkout(Cart cart, Optional<String> voucherCode)
checkout(cart, null); // vẫn compile → NPE khi voucherCode.isPresent()
checkout(cart, Optional.empty()); // người gọi phải bọc, rườm rà
checkout(cart, Optional.of(code));
- Giờ có 3 trạng thái: null, empty, có giá trị. Tệ hơn trước.
- Cách tốt hơn: overload
checkout(cart)vàcheckout(cart, code), hoặc tham số nullable có@Nullable. - Field:
Optionalkhông implementSerializable, JPA/Jackson không coi là cột/thuộc tính thường, mỗi field tốn thêm object. Để field nullable và cho getter trảOptionalnếu muốn:@Column private String voucherCode; public Optional<String> voucherCode() { return Optional.ofNullable(voucherCode); }
Brian Goetz (kiến trúc sư Java) mô tả Optional là cơ chế giới hạn cho kiểu trả về của library, đó là thiết kế gốc.
record có thêm method, validate hay field static được không? Có thay hẳn Lombok không?
Gần đúng, chỉnh một chút: record thay được Lombok cho DTO, không thay cho entity.
- ✅ Thêm method instance/static, static field, static factory, implement interface, compact constructor để validate/chuẩn hóa.
- ❌ Không có instance field ngoài các component, không extends class, không setter.
- Immutable là nông (shallow):
record Cart(List<Item> items)thì list bên trong vẫn sửa được. Copy phòng thủ:
public record Cart(String customerId, List<CartItem> items) {
public Cart {
items = List.copyOf(items); // immutable thật, chặn null element
}
public BigDecimal total() {
return items.stream().map(CartItem::subtotal).reduce(BigDecimal.ZERO, BigDecimal::add);
}
}
Nếu cần builder cho DTO nhiều field thì Lombok @Builder vẫn dùng được trên record.
Phỏng vấn hỏi Optional, record, sealed thì trả lời sao?
Mỗi cái nói: giải quyết vấn đề gì, dùng ở đâu, lưu ý.
Câu 1: Optional là gì, dùng thế nào cho đúng?
🇻🇳 Tiếng Việt:
Optional là container chứa 0 hoặc 1 giá trị, có từ Java 8, dùng làm kiểu trả về để nói rõ kết quả có thể không có, giảm NPE. Em dùng
map,orElseThrow,orElseGetthay vìisPresent+get, ví dụfindById(id).map(OrderResponse::from).orElseThrow(...). Không dùng Optional cho field, tham số hay bọc collection.orElseluôn tính tham số, cònorElseGetnhận Supplier nên lazy, dùng khi giá trị mặc định tốn kém.
🇬🇧 English:
Optional, added in Java 8, is a container holding zero or one value. It's meant as a return type that makes "no result" explicit and reduces NPEs. I chain
map,orElseThrowandorElseGetinstead ofisPresentplusget, e.g.findById(id).map(OrderResponse::from).orElseThrow(...). I don't use it for fields, parameters or to wrap collections.orElsealways evaluates its argument, whileorElseGettakes a Supplier and is lazy, so it's the right choice for expensive defaults.
Câu 2: Record là gì, khác class thường?
🇻🇳 Tiếng Việt:
Record (Java 16) là class immutable để mang dữ liệu; compiler tự sinh constructor, accessor,
equals,hashCode,toString. Record là final, không extends được nhưng implement interface được, có thể validate trong compact constructor. Em dùng cho DTO request/response và value object, không dùng cho JPA entity vì entity cần no-arg constructor và mutable.
🇬🇧 English:
A record, since Java 16, is an immutable data carrier; the compiler generates the constructor, accessors,
equals,hashCodeandtoString. Records are final and can't extend a class but can implement interfaces, and you can validate in a compact constructor. I use them for request/response DTOs and value objects, not JPA entities, which need a no-arg constructor and mutability.
Câu 3: Sealed class và pattern matching switch dùng làm gì?
🇻🇳 Tiếng Việt:
Sealed (Java 17) giới hạn đúng những class được kế thừa qua
permits. Kết hợp với pattern matchingswitchở Java 21, compiler biết đủ mọi trường hợp nên không cầndefault, và khi thêm kiểu mới thì báo lỗi ở mọi switch chưa xử lý. Em dùng cho các loại thanh toán Momo, VnPay, Card để tính phí an toàn hơn chuỗi if-instanceof.
🇬🇧 English:
Sealed types, since Java 17, restrict which classes may extend them via
permits. Combined with pattern matchingswitchin Java 21, the compiler knows all cases, so nodefaultis needed, and adding a new subtype breaks every switch that doesn't handle it. I used it for payment types Momo, VnPay and Card to compute fees more safely than an if-instanceof chain.
Cách nhớ ngắn gọn
- Optional = kiểu trả về; không field, không param, không bọc list.
orElseluôn chạy,orElseGetlazy,orElseThrowcho 404.- record = DTO immutable, final, shallow immutable, không cho entity.
- sealed + switch Java 21 = exhaustive, không cần default.
varchỉ cho biến local; text block"""cho SQL/JSON.
Tự kiểm tra
Optional.of(null) và Optional.ofNullable(null) khác gì?
of(null) ném NPE ngay; ofNullable(null) trả Optional.empty().
.orElse(loadDefault()) có gọi loadDefault() khi Optional có giá trị không?
Có, vì tham số được tính trước khi gọi method. Dùng orElseGet(this::loadDefault) để lazy.
Vì sao không dùng record làm JPA entity?
Entity cần no-arg constructor, field mutable và class không final để Hibernate tạo proxy; record ngược lại cả ba.
Thêm record ApplePay implements PaymentInfo (đã khai báo trong permits) thì switch không default ở trên thế nào?
Compile error: switch không còn exhaustive. Đó chính là lợi ích, compiler chỉ ra chỗ quên xử lý.
var x = null; có hợp lệ không?
Không. var cần vế phải để suy ra kiểu; null không có kiểu cụ thể.