OOP và SOLID: refactor PaymentService/OrderService từ rối đến dễ mở rộng

7 câu hỏi~30 phútTừ khóa: encapsulation, polymorphism, SRP, OCP, LSP, ISP, DIP, Strategy, access modifier, protected, Spring DI

SOLID trong lập trình là gì?

SOLID là 5 nguyên tắc thiết kế trong OOP. Mục tiêu: code dễ bảo trì, dễ mở rộng, ít phụ thuộc và dễ test. Thay vì học thuộc định nghĩa, mình sẽ lấy một codebase e-commerce (đặt hàng + thanh toán) và refactor dần qua cả bài này lẫn bài 1.2. Phần này đi qua nền tảng OOP và chữ S.

1. Bài toán: một project e-commerce đang "chạy được"

Project shop-backend có một class lo hết việc đặt hàng:

public class OrderService {

    public void placeOrder(OrderRequest req) {
        // 1. validate + tính tiền
        if (req.items().isEmpty()) throw new IllegalArgumentException("Giỏ hàng trống");
        BigDecimal total = req.items().stream()
                .map(i -> i.price().multiply(BigDecimal.valueOf(i.qty())))
                .reduce(BigDecimal.ZERO, BigDecimal::add);

        // 2. thanh toán theo loại
        if (req.paymentType().equals("VNPAY")) { /* gọi API VNPay */ }
        else if (req.paymentType().equals("MOMO")) { /* gọi API Momo */ }

        // 3. lưu DB
        MySqlOrderRepository repo = new MySqlOrderRepository();
        repo.save(new Order(req.customerId(), total));

        // 4. gửi email xác nhận
        SmtpMailer mailer = new SmtpMailer();
        mailer.send(req.email(), "Đặt hàng thành công");
    }
}

Chạy đúng. Demo ổn. Nhưng sau 6 tháng, file này dài 1500 dòng.

2. Vấn đề

3. Giải pháp: 4 tính chất OOP + 5 nguyên tắc SOLID

Nền tảng là 4 tính chất OOP, lấy ví dụ ngay trên project:

Tính chấtHiểu đơn giảnVí dụ trong shop-backend
EncapsulationGiấu dữ liệu, chỉ cho đổi qua method có kiểm traOrder.status private, chỉ đổi qua markPaid()
InheritanceClass con dùng lại code class chaOrder extends BaseEntity (có id, createdAt)
PolymorphismCùng lời gọi, mỗi object chạy cách riêngpaymentMethod.pay() chạy VNPay hay Momo tùy object thật
AbstractionChỉ lộ "làm gì", giấu "làm thế nào"interface OrderRepository, service không biết SQL bên dưới

Còn SOLID là 5 nguyên tắc dựa trên 4 tính chất đó:

ChữNguyên tắcHiểu đơn giản
SSingle Responsibility PrincipleMột class chỉ nên có một trách nhiệm chính
OOpen/Closed PrincipleCó thể mở rộng mà hạn chế sửa code cũ
LLiskov Substitution PrincipleClass con thay được class cha mà chương trình vẫn đúng
IInterface Segregation PrincipleNhiều interface nhỏ, chuyên biệt thay vì một interface quá lớn
DDependency Inversion PrinciplePhụ thuộc vào abstraction, không phụ thuộc trực tiếp implementation

4. Code từng bước: Encapsulation và SRP

Bước 1: Đóng gói Order. Đóng gói là để bảo vệ invariant, không chỉ là "viết getter/setter":

public class Order {
    private final Long customerId;
    private final BigDecimal total;
    private OrderStatus status = OrderStatus.CREATED;

    public Order(Long customerId, BigDecimal total) {
        if (total.signum() <= 0) throw new IllegalArgumentException("total phải > 0");
        this.customerId = customerId;
        this.total = total;
    }

    public void markPaid() {
        if (status != OrderStatus.CREATED) throw new IllegalStateException("Đơn đã xử lý");
        status = OrderStatus.PAID;
    }

    public BigDecimal getTotal()     { return total; }
    public OrderStatus getStatus()   { return status; }
    // không có setStatus(): không ai đưa đơn từ CANCELLED về PAID được
}

Bước 2: Tách trách nhiệm (SRP). Định nghĩa chuẩn: một class chỉ nên có một lý do để thay đổi.

class PriceCalculator {                       // lý do đổi: rule tính tiền
    BigDecimal total(List<OrderItem> items) { /* ... */ }
}
class OrderRepository {                        // lý do đổi: cách lưu
    void save(Order order) { /* ... */ }
}
class NotificationService {                    // lý do đổi: kênh gửi
    void sendOrderConfirmed(String email) { /* ... */ }
}
class OrderService {                           // lý do đổi: luồng đặt hàng
    OrderService(PriceCalculator calc, OrderRepository repo, NotificationService noti) { ... }

    Order placeOrder(OrderRequest req) {
        Order order = new Order(req.customerId(), calc.total(req.items()));
        repo.save(order);
        noti.sendOrderConfirmed(req.email());
        return order;
    }
}

Bước 3: Test. Giờ test PriceCalculator chỉ cần new PriceCalculator().total(items), không DB, không SMTP.

5. Cách hoạt động

TRƯỚC                                   SAU
┌──────────────────────────┐            ┌──────────────┐
│ OrderService (1500 dòng) │            │ OrderService │  (điều phối)
│  - tính tiền             │            └──────┬───────┘
│  - if/else thanh toán    │     ==>     ┌─────┼──────────────┬───────────────┐
│  - new MySqlRepo         │             ▼     ▼              ▼               ▼
│  - new SmtpMailer        │      PriceCalc  PaymentService  OrderRepo  NotificationService
└──────────────────────────┘      (S)        (O, L: phần sau) (D)       (S)
4 lý do thay đổi / 1 file           mỗi class 1 lý do thay đổi

6. So sánh

God classTách theo SRP
Sửa emailCó thể làm hỏng logic đặt hàngChỉ chạm NotificationService
Unit testCần DB + SMTP thậtTest từng class độc lập
Làm việc nhómConflict liên tục trên 1 fileMỗi người một class
Số classÍtNhiều hơn, mỗi class nhỏ

Đây chính là lý do Spring chia @Controller / @Service / @Repository.

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

Cách nhớ nhanh: S = một nhiệm vụ, O = thêm mới hơn sửa cũ, L = con thay được cha, I = interface nhỏ gọn, D = phụ thuộc abstraction.

Tiếp đi: Open/Closed và Liskov.

Giờ đi chữ O và L. Hai chữ này đi cùng nhau: O bảo "mở rộng bằng class mới", L bảo "class mới phải cư xử đúng".

1. Bài toán: PaymentService if/else theo loại ví

public class PaymentService {
    public void pay(String type, BigDecimal amount) {
        if (type.equals("VNPAY")) {
            // gọi API VNPay
        } else if (type.equals("MOMO")) {
            // gọi API Momo
        } else if (type.equals("CARD")) {
            // gọi cổng thẻ
        }
        // thêm ZaloPay? lại sửa ở đây
    }
}

2. Vấn đề

3. Giải pháp: Strategy pattern + polymorphism

Đưa phần hay thay đổi ra sau một interface. Mỗi cổng một class. Service chỉ gọi interface, polymorphism chọn code đúng lúc runtime. Ví dụ cùng ý tưởng: tính giảm giá VIP/NORMAL bằng interface Discount, muốn thêm StudentDiscount thì tạo class mới.

4. Code từng bước

Bước 1: Interface

public interface PaymentMethod {
    String type();
    PaymentResult pay(Order order);
}

Bước 2: Mỗi cổng một bean

@Component
class VnPayPayment implements PaymentMethod {
    public String type() { return "VNPAY"; }
    public PaymentResult pay(Order order) { /* gọi VNPay */ return PaymentResult.ok(); }
}

@Component
class MomoPayment implements PaymentMethod {
    public String type() { return "MOMO"; }
    public PaymentResult pay(Order order) { /* gọi Momo */ return PaymentResult.ok(); }
}

Bước 3: Service không còn if/else

@Service
public class PaymentService {
    private final Map<String, PaymentMethod> methods;

    // Spring inject tất cả bean implement PaymentMethod
    public PaymentService(List<PaymentMethod> list) {
        this.methods = list.stream()
                .collect(Collectors.toMap(PaymentMethod::type, m -> m));
    }

    public PaymentResult pay(String type, Order order) {
        PaymentMethod m = methods.get(type);
        if (m == null) throw new IllegalArgumentException("Không hỗ trợ: " + type);
        return m.pay(order);
    }
}

Thêm ZaloPay giờ chỉ là tạo ZaloPayPayment mới, PaymentService không sửa một dòng.

Bước 4: Liskov, khi class con "implement cho có". Giả sử có thêm thanh toán khi nhận hàng (COD) và interface có refund():

public interface PaymentMethod {
    String type();
    PaymentResult pay(Order order);
    void refund(Order order);
}

class CodPayment implements PaymentMethod {
    public String type() { return "COD"; }
    public PaymentResult pay(Order order) { return PaymentResult.pending(); }
    public void refund(Order order) {
        throw new UnsupportedOperationException("COD không hoàn tiền online");
    }
}

// code chỗ khác tin vào hợp đồng của PaymentMethod
void cancelOrder(Order order, PaymentMethod method) {
    method.refund(order);              // COD → nổ lúc runtime
}

LSP: chỗ nào dùng kiểu cha, đưa object con vào thì hành vi vẫn đúng như kỳ vọng (đúng "hợp đồng" của cha). Dấu hiệu hay gặp nhất: class con override rồi throw new UnsupportedOperationException(). Ví dụ kinh điển Rectangle/Square cũng vậy:

class Square extends Rectangle {
    @Override void setWidth(int w)  { width = w; height = w; }
    @Override void setHeight(int h) { width = h; height = h; }
}
void resize(Rectangle r) {
    r.setWidth(5);
    r.setHeight(4);
    assert r.area() == 20;                // Square cho ra 16 → sai
}

Chính JDK cũng dính: List.of(...) ném UnsupportedOperationException khi add. Cách sửa: tách khả năng hoàn tiền thành interface riêng Refundable (xem lượt sau, chữ I), không ép mọi cổng phải có.

5. Cách hoạt động

PaymentService.pay("MOMO", order)
        │
        ▼
 methods (Map do Spring build lúc khởi động)
 ┌─────────┬───────────────┐
 │ "VNPAY" │ VnPayPayment  │
 │ "MOMO"  │ MomoPayment   │ ◄── chọn
 │ "ZALO"  │ ZaloPayPayment│ ◄── thêm class mới, không sửa service
 └─────────┴───────────────┘
        │  polymorphism
        ▼
 MomoPayment.pay(order)

6. So sánh

if/else theo typeStrategy (OCP)
Thêm cổng mớiSửa hàm cũThêm class mới
Rủi ro hỏng nhánh cũCaoThấp
TestTest cả hàm lớnTest từng class
Khi chỉ có 2 nhánh cố địnhĐơn giản, đủ dùngHơi thừa

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

Tiếp đi: Interface Segregation và Dependency Inversion.

Hai chữ cuối: I (interface nhỏ gọn) và D (phụ thuộc abstraction). Đây là hai chữ dính trực tiếp nhất tới Spring.

1. Bài toán

Sau khi gom chức năng, team viết một interface "cho tiện":

interface PaymentMethod {
    PaymentResult pay(Order order);
    void refund(Order order);
    void cancel(Order order);
    PaymentStatus checkStatus(String txId);
}

Còn OrderService thì:

class OrderService {
    private final MySqlOrderRepository repo = new MySqlOrderRepository();   // gắn chặt MySQL
}

2. Vấn đề

3. Giải pháp

4. Code từng bước

Bước 1: Chia nhỏ interface (ISP)

public interface PaymentMethod { String type(); PaymentResult pay(Order order); }
public interface Refundable    { void refund(Order order); }
public interface Cancellable   { void cancel(Order order); }

class CardPayment implements PaymentMethod, Refundable, Cancellable { /* đủ cả 3 */ }
class CodPayment  implements PaymentMethod { /* chỉ pay, không phải giả vờ refund */ }

void cancelOrder(Order order, PaymentMethod method) {
    if (method instanceof Refundable r) r.refund(order);   // chỉ gọi khi thật sự hỗ trợ
}

Spring Data cũng thiết kế kiểu này: Repository → CrudRepository → PagingAndSortingRepository, bạn chọn đúng mức mình cần.

Bước 2: Đảo phụ thuộc (DIP)

public interface OrderRepository {
    void save(Order order);
}
class MySqlOrderRepository    implements OrderRepository { /* ... */ }
class PostgresOrderRepository implements OrderRepository { /* ... */ }

class OrderService {
    private final OrderRepository repo;
    OrderService(OrderRepository repo) {     // nhận từ ngoài
        this.repo = repo;
    }
}

Bước 3: Ai truyền vào? Tự tay hoặc để Spring làm:

new OrderService(new MySqlOrderRepository());
new OrderService(new PostgresOrderRepository());
new OrderService(order -> {});              // test: repo giả bằng lambda, không cần DB

@Repository
class JpaOrderRepository implements OrderRepository { /* ... */ }

@Service
class OrderService {
    private final OrderRepository repo;
    OrderService(OrderRepository repo) { this.repo = repo; }  // constructor injection
}

Bước 4: Test

@Test
void placeOrder_savesOrder() {
    List<Order> saved = new ArrayList<>();
    OrderService service = new OrderService(saved::add);   // không Spring, không DB
    service.placeOrder(sampleRequest());
    assertEquals(1, saved.size());
}

5. Cách hoạt động

TRƯỚC (phụ thuộc đi xuống chi tiết)      SAU (cả hai cùng trỏ vào abstraction)

OrderService                             OrderService ──────►  «interface»
     │                                                         OrderRepository
     ▼                                                              ▲
MySqlOrderRepository                     MySqlOrderRepository ──────┤
                                         JpaOrderRepository   ──────┘
"Inversion": trước service tự chọn class nào; giờ service chỉ khai báo cần gì,
bên ngoài (Spring IoC container) quyết định và inject vào.

6. So sánh

Khái niệmLà gìVí dụ
ISPNguyên tắc: interface nhỏ theo nhu cầu clientRefundable, Cancellable tách riêng
DIPNguyên tắc: phụ thuộc abstractionOrderService biết OrderRepository
DIKỹ thuật truyền dependency từ ngoài vàoConstructor injection
IoC containerFramework tự tạo và nối objectSpring ApplicationContext

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

Theo quy định của Java, protected có phạm vi rộng hơn default (package-private) à?

Đúng. Trong Java, protected có phạm vi rộng hơn default (package-private).

ModifierCùng classCùng packageClass con khác packageBất kỳ đâu
private✅❌❌❌
(default)✅✅❌❌
protected✅✅✅❌
public✅✅✅✅
package com.shop.payment;
public class BasePayment {
    void audit() {}               // default
    protected void doPay() {}     // protected
}

package com.shop.payment.momo;
public class MomoPayment extends BasePayment {
    void test() {
        doPay();   // ✅ protected
        audit();   // ❌ default: khác package
    }
}
private < default < protected < public

Tên protected dễ gây nhầm. Nó nghĩa là "được bảo vệ khỏi truy cập công khai, nhưng vẫn cho class con dùng", chứ không phải "hạn chế hơn default". Còn default không phải keyword default; nó chỉ là: không viết modifier nào thì Java áp dụng package-private.

Cách nhớ: private → class, default → package, protected → package + inheritance, public → everywhere.

Vậy protected có ý nghĩa gì? Class con đã extends class cha thì đương nhiên được dùng method của cha rồi, cần protected làm gì?

Đây chính là chỗ dễ nhầm: extends không có nghĩa là class con được phép truy cập tất cả mọi thứ của class cha. extends tạo quan hệ kế thừa; truy cập được member hay không vẫn do access modifier quyết định.

package com.shop.payment;
public class PaymentService {
    public void pay(Order order) {          // API cho bên ngoài
        validate(order);
        processPayment(order);
    }
    protected void processPayment(Order order) {
        // logic mặc định, class con được override
    }
    private void validate(Order order) {
        // logic nội bộ, class con không được đụng
    }
}

package com.shop.payment.paypal;
public class PaypalPaymentService extends PaymentService {
    @Override
    protected void processPayment(Order order) {
        // custom cách thanh toán PayPal
    }
}

Nếu để processPayment là public thì class con dùng được, nhưng mọi class khác cũng gọi được:

payment.pay(order);             // ✅ muốn bên ngoài gọi
payment.processPayment(order);  // ❌ không muốn: bỏ qua validate
public     → ai cũng được gọi
protected  → class này + class con mở rộng (+ cùng package)
default    → các class cùng package
private    → chỉ nội bộ class này

Nhớ: inheritance ≠ access permission. extends xác định quan hệ; access modifier xác định "được đụng vào cái gì". Khung "pay() cố định, processPayment() cho con override" này chính là Template Method, sẽ dùng lại ở bài 1.2.

Vậy DIP liên quan gì tới Dependency Injection của Spring? Hai cái có phải là một không?

Bạn hiểu gần đúng là hai cái liên quan, chỉ cần chỉnh một điểm: không phải một. DIP là nguyên tắc (nên thiết kế thế nào), DI là kỹ thuật (làm thế nào để truyền dependency), còn Spring là công cụ làm DI tự động.

Nguyên tắc        Kỹ thuật                 Công cụ
   DIP     ──►  Dependency Injection  ──►  Spring IoC container
"phụ thuộc       "truyền từ ngoài vào"     "tự new và inject"
 abstraction"
@Repository
class JpaOrderRepository implements OrderRepository { /* ... */ }

@Service
class OrderService {
    private final OrderRepository repo;
    OrderService(OrderRepository repo) {     // 1 constructor: không cần @Autowired
        this.repo = repo;
    }
}

Spring thấy JpaOrderRepository là bean duy nhất kiểu OrderRepository nên inject vào. Nhiều bean cùng kiểu thì dùng @Primary hoặc @Qualifier; cần tất cả thì inject List<PaymentMethod> như ví dụ OCP.

Ưu tiên constructor injection thay vì @Autowired trên field: field được final, không thể thiếu dependency, test chỉ cần new OrderService(mockRepo). Lưu ý: dùng DI mà service vẫn phụ thuộc class cụ thể (MySqlOrderRepository) thì vẫn có DI nhưng không có DIP.

Phỏng vấn hỏi SOLID, áp dụng ra sao trong dự án thì trả lời sao?

Đừng đọc định nghĩa 5 chữ rồi dừng. Mỗi chữ gắn một ví dụ mình từng làm.

Câu 1: Bạn hiểu SOLID thế nào, áp dụng ra sao?

🇻🇳 Tiếng Việt:

SOLID là 5 nguyên tắc giúp code OOP dễ bảo trì, mở rộng và test. SRP: em tách OrderService thành tính tiền, lưu trữ, gửi thông báo riêng. OCP: thay if/else theo loại thanh toán bằng interface PaymentMethod, mỗi cổng một class, Spring inject cả list vào map, thêm cổng mới không sửa code cũ. LSP: class con phải giữ hợp đồng của cha; nếu phải ném UnsupportedOperationException là thiết kế sai. ISP: em tách Refundable, Cancellable khỏi interface thanh toán. DIP: service phụ thuộc interface repository, Spring inject qua constructor, nhờ vậy unit test bằng mock.

🇬🇧 English:

SOLID is five design principles that keep OO code maintainable, extensible and testable. For SRP I split a large OrderService into pricing, persistence and notification. For OCP I replaced an if/else on payment type with a PaymentMethod interface and one class per provider, injected as a list by Spring, so a new provider means a new class only. LSP means a subtype must honor the parent's contract; throwing UnsupportedOperationException is a red flag. For ISP I moved refund and cancel into separate interfaces. For DIP my services depend on repository interfaces, injected through constructors, so I can unit test them with mocks.

Câu 2: DIP và DI khác nhau thế nào?

🇻🇳 Tiếng Việt:

DIP là nguyên tắc thiết kế: module cấp cao và cấp thấp đều phụ thuộc abstraction. DI là kỹ thuật truyền dependency từ ngoài vào, qua constructor hoặc setter. Spring IoC container thực hiện DI tự động. Có thể dùng DI mà vẫn vi phạm DIP nếu inject class cụ thể.

🇬🇧 English:

DIP is a design principle: both high-level and low-level modules depend on abstractions. DI is the technique of passing dependencies in from outside, usually via the constructor. Spring's IoC container does DI for you. You can use DI and still break DIP if you inject a concrete class.

Câu 3: protected khác default thế nào, khi nào dùng protected?

🇻🇳 Tiếng Việt:

Java có 4 mức: private chỉ trong class, default trong package, protected thêm quyền cho subclass ở package khác, public ở mọi nơi. Vậy protected rộng hơn default. Em dùng protected khi muốn một method không public ra ngoài nhưng vẫn cho subclass override để mở rộng, ví dụ processPayment() trong template method.

🇬🇧 English:

Java has four levels: private is class-only, default is package-only, protected adds access for subclasses in other packages, and public is everywhere. So protected is wider than default. I use protected when a member should not be public but subclasses need to override it, like a processPayment() hook in a template method.

Cách nhớ ngắn gọn


Tự kiểm tra

Encapsulation có phải chỉ là để field private rồi viết getter/setter?

Không. Mục đích là bảo vệ invariant: chỉ cho đổi trạng thái qua method có kiểm tra (ví dụ Order.markPaid() không cho trả lại đơn đã xử lý). Setter public cho mọi field thì gần như không đóng gói gì.

Chuỗi if/else theo type vi phạm nguyên tắc nào, sửa bằng pattern gì?

Open/Closed. Sửa bằng Strategy: một interface, mỗi loại một class, chọn implementation qua map hoặc Spring inject List.

Dấu hiệu dễ nhận nhất của vi phạm LSP?

Class con override method rồi ném UnsupportedOperationException hoặc làm trái kỳ vọng của cha (CodPayment.refund(), Square đổi cả hai cạnh khi setWidth).

Class con ở package khác gọi được method default của cha không?

Không. Default chỉ trong package. Muốn subclass khác package dùng được mà không public ra ngoài thì để protected.

Vì sao nên dùng constructor injection thay vì field injection?

Field final, dependency bắt buộc có khi tạo object, dễ test bằng new Service(mock) mà không cần Spring context.