Trang chủ » Blog » Java » Composition over Inheritance trong Java: Từ Effective Java Item 18 đến Kiến trúc Backend Linh hoạt

Composition over Inheritance trong Java: Từ Effective Java Item 18 đến Kiến trúc Backend Linh hoạt

I. Mở đầu: Bài toán Tái sử dụng Mã nguồn trong Java

Trong kỹ nghệ phần mềm hướng đối tượng (OOP) với Java, tái sử dụng mã nguồn và kiểm soát độ phức tạp kiến trúc luôn là bài toán sống còn. Đứng trước yêu cầu thiết kế một tính năng mới, các kỹ sư phần mềm thường cân nhắc giữa hai cơ chế cốt lõi: mở rộng phân cấp lớp thông qua từ khóa extends hoặc liên kết các thành phần độc lập thông qua tham chiếu thuộc tính.

Để ánh xạ thế giới thực vào mã nguồn, kiến trúc Java hiện đại xoay quanh ba góc nhìn tư duy:

  • Kế thừa (Implementation Inheritance) — Quan hệ “Is-A” (Là một): Xác định bản chất phân cấp của đối tượng thông qua lớp cơ sở (Superclass) và lớp dẫn xuất (Subclass).
  • Thành phần (Object Composition) — Quan hệ “Has-A” (Có một): Nhúng các tham chiếu đối tượng làm thuộc tính private bên trong một lớp để ủy quyền và tận dụng chức năng của chúng.
  • Giao diện (Interface / Subtyping) — Quan hệ “Can-Do” (Có thể làm gì): Khai báo các bản hợp đồng hành vi (behavioral contracts) qua interface, cho phép thực thi đa hình mà không bị trói buộc vào một cây phân cấp lớp cố định.

Trong tác phẩm kinh điển Effective Java (Item 18), Joshua Bloch — Kiến trúc sư trưởng đứng sau nhiều API cốt lõi của Java — đã đúc kết nguyên lý vàng: “Favor composition over inheritance” (Ưu tiên sử dụng thành phần hơn kế thừa). Đây không phải là một khẩu hiệu lý thuyết suông, mà là kim chỉ nam giúp các hệ thống Backend vượt qua sự biến đổi khốc liệt của nghiệp vụ trong dài hạn.

Sự khác biệt giữa việc mở rộng hành vi bằng phân cấp lớp (Inheritance) và lắp ráp mô-đun (Composition).
Hình minh họa: Sự khác biệt giữa việc mở rộng hành vi bằng phân cấp lớp (Inheritance) và lắp ráp mô-đun (Composition).

II. Kế thừa (extends) trong Java và các Cạm bẫy Kiến trúc

1. Ràng buộc Kỹ thuật của Ngôn ngữ Java

Cơ chế kế thừa lớp (class inheritance) cho phép lớp con tiếp nhận và ghi đè (@Override) các phương thức từ lớp cha. Tuy nhiên, Java áp dụng quy tắc Đơn kế thừa (Single Inheritance) đối với class — một lớp chỉ có thể extends trực tiếp một lớp cha duy nhất. Khi cố gắng nhồi nhét nhiều chiều biến đổi hành vi vào cùng một cây phân cấp extends, kiến trúc hệ thống sẽ nhanh chóng rơi vào ngõ cụt.

2. Bốn Cạm bẫy Kiến trúc Kinh điển

  • Sự cố Lớp Cơ sở Mỏng giòn (Fragile Base Class Problem): Kế thừa phá vỡ tính đóng gói (encapsulation) vì lớp con bị buộc phải phụ thuộc vào chi tiết cài đặt nội bộ của lớp cha. Ví dụ kinh điển nhất trong Effective Java Item 18 là lớp InstrumentedHashSet extends HashSet dùng để đếm số phần tử được thêm vào. Do lập trình viên không biết phương thức addAll() của lớp cha HashSet bên dưới lại tự gọi lặp qua phương thức add(), việc ghi đè cả hai hàm ở lớp con đã khiến bộ đếm bị cộng dồn gấp đôi so với thực tế! Một thay đổi nhỏ ở lớp cha trong phiên bản sau có thể âm thầm phá hỏng toàn bộ lớp con.
  • Bùng nổ Tổ hợp Lớp con (Combinatorial Explosion): Khi hệ thống cần kết hợp N biến thể hành vi độc lập, việc dùng kế thừa đòi hỏi phải tạo ra cấp số nhân các lớp con cho từng tổ hợp, biến codebase thành một “mê cung” các class.
  • Vi phạm Nguyên lý Thay thế Liskov (LSP Violation): Khi dùng kế thừa chỉ để tận dụng lại vài dòng code nhưng lớp con lại không hỗ trợ trọn vẹn hành vi của lớp cha, lập trình viên thường chữa cháy bằng cách ghi đè phương thức rồi ném ra UnsupportedOperationException. Điều này làm gãy hoàn toàn tính đa hình an toàn của hệ thống.
  • Hội chứng “Yo-Yo” khi đọc hiểu mã nguồn: Việc phân tán logic qua 4–5 tầng abstract class buộc lập trình viên phải liên tục nhảy lên nhảy xuống (như con quay Yo-Yo) giữa các file lớp cha – lớp con mới nắm được luồng chạy của một hàm duy nhất
Cạm bẫy Fragile Base Class: Lớp con bị hỏng logic chỉ vì cách cài đặt ẩn bên trong lớp cha.
Hình minh họa: Cạm bẫy Fragile Base Class: Lớp con bị hỏng logic chỉ vì cách cài đặt ẩn bên trong lớp cha.

3. Minh họa Thực tế: “Mùi Code” (Code Smell) trong Hệ thống Nhân sự

Xét bài toán tính lương nhân viên trong một ứng dụng Enterprise Backend. Ban đầu, đội ngũ phát triển mô hình hóa theo tư duy phân loại “Is-A” truyền thống:

// Lớp cơ sở trừu tượng
public abstract class Employee {
    protected String id;
    protected String name;

    protected Employee(String id, String name) {
        this.id = id;
        this.name = name;
    }

    public abstract double calculatePay();
}

// Nhân viên trả lương theo giờ
public class HourlyEmployee extends Employee {
    private double hoursWorked;
    private double hourlyRate;

    public HourlyEmployee(String id, String name, double hoursWorked, double hourlyRate) {
        super(id, name);
        this.hoursWorked = hoursWorked;
        this.hourlyRate = hourlyRate;
    }

    @Override
    public double calculatePay() {
        return hoursWorked * hourlyRate;
    }
}

// Nhân viên nhận lương cố định theo tháng
public class SalariedEmployee extends Employee {
    private double monthlySalary;

    public SalariedEmployee(String id, String name, double monthlySalary) {
        super(id, name);
        this.monthlySalary = monthlySalary;
    }

    @Override
    public double calculatePay() {
        return monthlySalary;
    }
}
// Ô code mẫu (chèn thử)
public class Demo {
    public static void main(String[] args) {
        System.out.println("Hello CodeGym");
    }
}

Điểm gãy nghiệp vụ (Point of Failure): Hệ thống vận hành trơn tru cho đến khi phòng Nhân sự đưa ra hai yêu cầu mới:

  • Bổ sung chính sách Thưởng hoa hồng (Commission) cho cả nhân viên theo giờ lẫn nhân viên lương cứng.
  • Cho phép nhân viên chuyển đổi chế độ từ lương theo giờ sang lương cứng ngay trên hệ thống mà không phải tạo lại hồ sơ nhân sự mới.

Lúc này, kiến trúc extends lập tức bộc lộ tử huyệt:

  • Bạn buộc phải đẻ thêm các lớp lai ghép như CommissionedHourlyEmployee, CommissionedSalariedEmployee… Nếu tháng sau có thêm phụ cấp độc hại hay thưởng dự án, số lượng class sẽ phình to mất kiểm soát.
  • Trong Java, kiểu của một đối tượng (new HourlyEmployee(…)) bị đóng đinh từ lúc khởi tạo. Bạn không thể đổi lớp cha của một instance đang tồn tại trên vùng nhớ Heap tại thời điểm Runtime.
Hiện tượng Bùng nổ Tổ hợp Lớp con khi cố gắng mở rộng tính năng bằng từ khóa extends.
Hình minh họa: Hiện tượng Bùng nổ Tổ hợp Lớp con khi cố gắng mở rộng tính năng bằng từ khóa extends.

III. Tái cấu trúc sang Composition & Interface trong Java

Để giải quyết triệt để sự cứng nhắc trên, chúng ta áp dụng tư duy Phân rã bài toán — tách phần biến đổi (thuật toán tính lương) ra khỏi phần cố định (thông tin định danh nhân viên).

1. Refactoring từng bước sang Composition & Delegation

Bước 1: Định nghĩa Hợp đồng Hành vi (“Can-Do”) kết hợp Functional Combinator

Thay vì viết cứng phương thức calculatePay() trong lớp cha, ta tách nó thành một interface độc lập. Đặc biệt, ta tận dụng default method của Java 8+ để cho phép cộng gộp nhiều chính sách lương mà không cần tạo thêm bất kỳ lớp trung gian nào:

@FunctionalInterface
public interface PaymentStrategy {
    double calculatePayment();

    // Kỹ thuật Combinator: Ghép nối linh hoạt nhiều nguồn thu nhập cho cùng 1 nhân viên
    default PaymentStrategy andThenAdd(PaymentStrategy other) {
        return () -> this.calculatePayment() + other.calculatePayment();
    }
}

Bước 2: Triển khai các Mô-đun Tính lương Độc lập

Mỗi công thức tính lương giờ đây là một Component nhỏ gọn, tuân thủ tuyệt đối Nguyên lý Đơn trách nhiệm (Single Responsibility Principle):

public class HourlyPayment implements PaymentStrategy {
    private final double hoursWorked;
    private final double hourlyRate;

    public HourlyPayment(double hoursWorked, double hourlyRate) {
        this.hoursWorked = hoursWorked;
        this.hourlyRate = hourlyRate;
    }

    @Override
    public double calculatePayment() {
        return hoursWorked * hourlyRate;
    }
}

public class SalariedPayment implements PaymentStrategy {
    private final double monthlySalary;

    public SalariedPayment(double monthlySalary) {
        this.monthlySalary = monthlySalary;
    }

    @Override
    public double calculatePayment() {
        return monthlySalary;
    }
}

public class CommissionPayment implements PaymentStrategy {
    private final double salesAmount;
    private final double commissionRate;

    public CommissionPayment(double salesAmount, double commissionRate) {
        this.salesAmount = salesAmount;
        this.commissionRate = commissionRate;
    }

    @Override
    public double calculatePayment() {
        return salesAmount * commissionRate;
    }
}

Bước 3: Lắp ráp vào Lớp chính (“Has-A”) và Ủy quyền (Delegation)

Lớp Employee giờ đây là một lớp cụ thể (concrete class) hoàn chỉnh, sở hữu một tham chiếu tới PaymentStrategy và chuyển tiếp (forward) yêu cầu tính lương cho thành phần này xử lý:

import java.util.Objects;

public class Employee {
    private final String id;
    private String name;
    
    // Composition: Nhúng tham chiếu Interface ("Has-A")
    private PaymentStrategy paymentStrategy;

    public Employee(String id, String name, PaymentStrategy paymentStrategy) {
        this.id = id;
        this.name = name;
        this.paymentStrategy = Objects.requireNonNull(paymentStrategy);
    }

    // Linh hoạt thay đổi hoặc nâng cấp chế độ lương ngay tại Runtime!
    public void setPaymentStrategy(PaymentStrategy paymentStrategy) {
        this.paymentStrategy = Objects.requireNonNull(paymentStrategy);
    }

    // Method Forwarding (Delegation - Ủy quyền thực thi)
    public double processPay() {
        return paymentStrategy.calculatePayment();
    }
}

Bước 4: Sức mạnh khi Sử dụng Thực tế

Hãy xem cách kiến trúc mới giải quyết gọn nhẹ “Điểm gãy nghiệp vụ” ở Phần II chỉ bằng vài dòng lắp ghép:

// 1. Khởi tạo nhân viên làm theo giờ (40 giờ, $20/giờ)
Employee dev = new Employee("E01", "Minh", new HourlyPayment(40, 20));
System.out.println(dev.processPay()); // Output: 800.0

// 2. Nhân viên này đạt KPI, được cộng thêm Hoa hồng dự án ($5000 doanh số, 10%)
// Không cần tạo class CommissionedHourlyEmployee!
PaymentStrategy hourlyWithCommission = new HourlyPayment(40, 20)
        .andThenAdd(new CommissionPayment(5000, 0.10));

dev.setPaymentStrategy(hourlyWithCommission);
System.out.println(dev.processPay()); // Output: 1300.0

// 3. Tháng sau chuyển sang nhân viên chính thức ăn lương cứng ($2500/tháng) ngay tại Runtime
dev.setPaymentStrategy(new SalariedPayment(2500));
System.out.println(dev.processPay()); // Output: 2500.0
Kiến trúc sau khi Refactor sang Composition: Tách biệt hoàn toàn đối tượng Employee khỏi sự biến thiên của các thuật toán tính lương.
Hình minh họa: Kiến trúc sau khi Refactor sang Composition: Tách biệt hoàn toàn đối tượng Employee khỏi sự biến thiên của các thuật toán tính lương.

2. Lợi ích Kiến trúc Vượt trội cho Dự án Java Backend

  • Độ gắn kết lỏng (Loose Coupling) & Cô lập rủi ro: Lớp Employee hoàn toàn “mù” về thuật toán tính lương cụ thể. Mọi chỉnh sửa về công thức thuế hay hoa hồng đều bị cô lập trong từng class nhỏ, không bao giờ gây hiệu ứng dây chuyền.
  • Thay đổi hành vi động tại Runtime: Dễ dàng tráo đổi hoặc kết hợp (compose) các hành vi ngay khi chương trình đang chạy thông qua setter hoặc hàm kết hợp mà vẫn giữ nguyên định danh (id) của đối tượng.
  • Tối ưu hóa khả năng Kiểm thử (High Testability): Khi viết Unit Test cho Employee, kỹ sư có thể dễ dàng dùng Mockito (@Mock PaymentStrategy) hoặc truyền thẳng một biểu thức Lambda new Employee(“1”, “Test”, () -> 1000.0) mà không cần khởi tạo cả một phả hệ lớp cha nặng nề.

3. Giải pháp Giảm thiểu Mã thừa (Boilerplate Forwarding Code) trong Java

Một đánh đổi nhỏ của Composition là lập trình viên phải viết các phương thức chuyển tiếp (forwarding methods) trung gian. Tuy nhiên, hệ sinh thái Java hiện đại cung cấp nhiều công cụ để hóa giải sự bất tiện này:

  • Java 8+ Default Interface Methods & Lambdas: Như minh họa với hàm andThenAdd() ở trên, việc gắn @FunctionalInterface và viết logic mặc định ngay trong interface giúp loại bỏ hàng loạt lớp trung gian (Decorator/Composite classes).
  • Spring Dependency Injection (DI): Trong kiến trúc doanh nghiệp, Spring IoC Container tự động hóa việc khởi tạo và lắp ráp (wiring) các thành phần lại với nhau, xóa bỏ hoàn toàn gánh nặng quản lý vòng đời đối tượng thủ công.
  • Project Lombok @Delegate: Tự động sinh ra toàn bộ phương thức chuyển tiếp tại thời điểm biên dịch (compile-time).
public class Employee {
    private String id;
    @ lombok.experimental.Delegate // Tự động expose các hàm của PaymentStrategy ra ngoài Employee
    private PaymentStrategy paymentStrategy;
}

Lưu ý: Annotation @Delegate hiện vẫn nằm trong gói experimental của Lombok và có thể gây khó khăn khi theo dõi tham chiếu trên IDE; các đội ngũ Senior thường ưu tiên viết rõ phương thức forwarding để giữ code tường minh.

IV. Khung quyết định trong Java: “Khi nào dùng gì?”

Nguyên lý “Composition over Inheritance” không đồng nghĩa với việc khai tử từ khóa extends. Một kỹ sư giỏi là người biết chọn đúng công cụ cho đúng bài toán.

1. Khi nào NÊN dùng Kế thừa (extends)?

Chỉ nên sử dụng kế thừa khi mối quan hệ giữa hai lớp thực sự là “Is-A” bất biến, thỏa mãn hoàn toàn Nguyên lý Liskov (LSP), hoặc nằm trong một phạm vi được kiểm soát chặt chẽ:

  • Phân cấp Ngoại lệ (Exception Hierarchy): Xây dựng các ngoại lệ nghiệp vụ như public class InsufficientFundsException extends RuntimeException. Bản chất một lỗi thiếu số dư chắc chắn là một ngoại lệ lúc chạy, không bao giờ biến đổi thành thứ khác.
  • Mẫu thiết kế Template Method Pattern: Khi lớp cha đóng vai trò định nghĩa một khung xương thuật toán bất biến (thường đánh dấu final ở hàm chính) và chỉ để mở một vài bước nhỏ (protected abstract) cho lớp con tùy biến — điển hình như AbstractList trong Java Collections hay OncePerRequestFilter trong Spring Security.
  • Mô hình hóa miền khép kín với sealed classes (Java 17+): Từ Java 17 LTS, từ khóa sealed cho phép bạn dùng kế thừa một cách an toàn tuyệt đối bằng cách giới hạn cứng danh sách các lớp con được phép extends (public sealed class Shape permits Circle, Rectangle). Khi kết hợp với Pattern Matching for switch trong Java 21, đây là vũ khí cực mạnh để xử lý cấu trúc dữ liệu đại số (Algebraic Data Types) như cây cú pháp AST hay các trạng thái giao dịch.

2. Khi nào NÊN dùng Thành phần (Composition)?

  • Lựa chọn mặc định cho Business Logic & Service Layer: Mọi luồng xử lý nghiệp vụ, tích hợp bên thứ ba, hoặc các quy tắc có xác suất thay đổi theo thời gian đều phải ưu tiên dùng Composition kết hợp Interface.
  • Khi đối tượng cần kết hợp nhiều trục hành vi độc lập: Thay vì tạo cây kế thừa đa tầng, hãy tách mỗi trục hành vi thành một thuộc tính thành phần riêng biệt.
  • Khi bạn định dùng extends chỉ để “tái sử dụng vài hàm tiện ích”: Nếu lớp B muốn dùng lại các hàm của lớp A nhưng về mặt ngữ nghĩa B không phải là A (ví dụ: Stack extends Vector trong JDK đời đầu — một sai lầm thiết kế nổi tiếng của chính Java), hãy lập tức chuyển sang dùng Composition.

3. Bảng so sánh Tổng hợp (Đặc thù Hệ sinh thái Java)

Tiêu chí Kế thừa (extends) Thành phần (Composition)
Bản chất ngữ nghĩa Is-A (Phân loại bản chất tĩnh) Has-A / Can-Do (Lắp ráp năng lực)
Mức độ phụ thuộc White-box reuse: Gắn kết chặt (Tight Coupling), lộ chi tiết lớp cha Black-box reuse: Gắn kết lỏng (Loose Coupling), đóng gói kín
Thời điểm ràng buộc Cố định lúc biên dịch (Compile-time) Linh hoạt thay đổi lúc chạy (Runtime)
Khả năng Kiểm thử Khó cô lập, phải kéo theo trạng thái lớp cha Rất dễ Mocking / Stubbing hoặc truyền Lambda
Rủi ro kiến trúc Fragile Base Class, Bùng nổ tổ hợp lớp con Tăng số lượng code chuyển tiếp (Boilerplate forwarding)
Công cụ hỗ trợ trong Java extends, abstract, sealed / permits (Java 17+) interface, Default Methods, Spring DI

V. Xu hướng Thiết kế & Ecosystem Java Hiện đại

Nếu quan sát kỹ hệ sinh thái Java từ Core SDK cho đến các Framework hiện đại, bạn sẽ thấy tư duy Composition xuất hiện ở mọi ngóc ngách.

1. Hạt nhân của các Design Pattern kinh điển (GoF)

Phần lớn các mẫu thiết kế trong cuốn Design Patterns của nhóm Gang of Four thực chất là các công thức áp dụng Composition theo những mục đích khác nhau:

  • Strategy Pattern: Đóng gói và hoán đổi linh hoạt các họ thuật toán (chính là kiến trúc PaymentStrategy chúng ta vừa xây dựng).
  • Decorator Pattern: Bọc (wrap) đối tượng này bên trong đối tượng khác có cùng interface để cộng dồn tính năng động — nền tảng của toàn bộ thư viện java.io (ví dụ: new BufferedReader(new FileReader(“data.csv”))).
  • Adapter & Facade Pattern: Sử dụng Composition để chuyển đổi giao diện tương thích hoặc che giấu sự phức tạp của các hệ thống con.

2. Kiến trúc Spring Boot & Dependency Injection

Trong các dự án Java Backend sử dụng Spring Boot, cách triển khai chuẩn mực nhất hiện nay là Constructor Injection (tiêm phụ thuộc qua hàm khởi tạo):

@Service
public class OrderProcessingService {
    private final InventoryClient inventoryClient;
    private final PaymentGateway paymentGateway;
    private final NotificationPublisher notificationPublisher;

    // Bản chất của Spring DI chính là Object Composition ở quy mô kiến trúc!
    public OrderProcessingService(
            InventoryClient inventoryClient,
            PaymentGateway paymentGateway,
            NotificationPublisher notificationPublisher) {
        this.inventoryClient = inventoryClient;
        this.paymentGateway = paymentGateway;
        this.notificationPublisher = notificationPublisher;
    }

    public void checkout(Order order) {
        inventoryClient.reserveStock(order);
        paymentGateway.charge(order);
        notificationPublisher.sendReceipt(order);
    }
}

Hãy để ý rằng OrderProcessingService không hề extends BasePaymentService hay extends BaseNotificationService. Nó sở hữu năng lực kiểm kho, thanh toán và gửi thông báo bằng cách kết hợp các thành phần thông qua interface. Nhờ vậy, khi doanh nghiệp muốn đổi cổng thanh toán từ Stripe sang VNPay, hay chuyển hệ thống thông báo từ Email sang Kafka, toàn bộ mã nguồn của OrderProcessingService được giữ nguyên vẹn 100% — hiện thực hóa hoàn hảo Nguyên lý Đóng/Mở (Open-Closed Principle) trong bộ quy tắc SOLID.

Spring Dependency Injection hiện thực hóa tư duy Composition ở tầng kiến trúc hệ thống.
Hình minh họa: Spring Dependency Injection hiện thực hóa tư duy Composition ở tầng kiến trúc hệ thống.

VI. Kết luận

Nguyên lý “Composition over Inheritance” đánh dấu bước trưởng thành quan trọng trong tư duy thiết kế phần mềm của mỗi kỹ sư Java: chuyển dịch góc nhìn từ việc cố gắng phân loại “Đối tượng này LÀ GÌ?” (Is-A) sang việc lắp ghép “Đối tượng này CÓ GÌ?” (Has-A) và “Nó CÓ THỂ LÀM GÌ?” (Can-Do).

Kế thừa (extends) vẫn giữ nguyên giá trị khi bạn cần mô hình hóa các phân cấp kiểu dữ liệu tĩnh, rõ ràng hoặc khép kín với sealed classes. Tuy nhiên, đứng trước những bài toán nghiệp vụ luôn biến động của các hệ thống phần mềm doanh nghiệp, việc lấy Composition kết hợp cùng Interface làm lựa chọn mặc định sẽ là chiếc chìa khóa vàng giúp bạn xây dựng nên những kiến trúc Backend thanh lịch, dễ kiểm thử và bền vững trước thời gian.

Tags:

0 Lời bình

Gửi Lời bình

Email của bạn sẽ không được hiển thị công khai. Các trường bắt buộc được đánh dấu *

BÀI VIẾT LIÊN QUAN

BẠN MUỐN HỌC LẬP TRÌNH?

GỌI NGAY

098 953 44 58

Đăng ký tư vấn lộ trình học lập trình

Đăng ký tư vấn, định hướng lộ trình học và giải đáp các thắc mắc về ngành nghề – Miễn phí – Online.

13 + 9 =