Optional, record, sealed: Java hiện đại trong shop-backend

4 câu hỏi~25 phútTừ khóa: Optional, orElse, orElseGet, orElseThrow, map, record, DTO, compact constructor, sealed, permits, pattern matching, switch, var, text block

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 đề

3. Giải phá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

recordclass thườngLombok @Data
Immutable✅ field finalTùy❌ có setter
Kế thừaKhông extends được (ngầm extends Record, là final); implement interface đượcĐượcĐược
BoilerplateKhôngNhiềuKhông (cần thư viện)
JPA entity❌ (cần no-arg constructor, mutable, proxy)✅✅ (cẩn thận equals)
Hợp nhất choDTO, request/response, value object, key của MapEntity, object có hành viEntity

7. Khi nào nên dùng / không nên

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));

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.

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, orElseGet thay vì isPresent + get, ví dụ findById(id).map(OrderResponse::from).orElseThrow(...). Không dùng Optional cho field, tham số hay bọc collection. orElse luôn tính tham số, còn orElseGet nhậ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, orElseThrow and orElseGet instead of isPresent plus get, e.g. findById(id).map(OrderResponse::from).orElseThrow(...). I don't use it for fields, parameters or to wrap collections. orElse always evaluates its argument, while orElseGet takes 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, hashCode and toString. 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 matching switch ở Java 21, compiler biết đủ mọi trường hợp nên không cần default, 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 matching switch in Java 21, the compiler knows all cases, so no default is 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


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ể.