+1

Composition Hay Inheritance? Bài Học Thực Chiến Từ Strategy Pattern

Có một cái cây inheritance mà mình từng nhìn vào và không biết nên khóc hay nên cười. Nó sâu 7 tầng. VNPayFlashSaleDiscountPaymentService extends VNPayDiscountPaymentService extends VNPayPaymentService extends DiscountPaymentService extends BasePaymentService extends AbstractService extends Object. Người tạo ra nó đã nghỉ việc từ lâu. Không ai dám sửa.


Mở đầu — Class càng lớn, bug càng nhiều

Mọi chuyện thường bắt đầu rất hợp lý.

Sprint 1: Cần xử lý thanh toán qua MoMo. Ổn, tạo PaymentService.

Sprint 3: Thêm PayPal. "Có một số logic dùng chung, để mình extract ra BasePaymentService rồi extend." Nghe hợp lý.

Sprint 7: Thêm VNPay. Extend tiếp.

Sprint 12: MoMo có thêm flash sale logic riêng. MomoFlashSalePaymentService extends MomoPaymentService. Vẫn còn kiểm soát được.

Sprint 20: Ai đó cần thêm regional tax logic cho VNPay ở thị trường miền Bắc. VNPayNorthRegionPaymentService extends VNPayPaymentService. OK...

Sprint 31: Bug report — fix logic discount ở BasePaymentService làm vỡ MomoFlashSalePaymentService vì override không đúng. Hotfix lúc 11 giờ đêm.

Sprint 45: Không ai còn hiểu cái cây đó nữa. Mỗi lần có yêu cầu mới, developer mở file lên nhìn 5 phút rồi hỏi "mình nên extend class nào?". Câu trả lời thường là: thêm một if-else vào method đang có, rồi tính sau.

Cái "tính sau" đó không bao giờ đến.

Bài này mình sẽ không nói "inheritance xấu, composition tốt" — câu đó quá đơn giản và cũng không đúng. Mình sẽ phân tích tại sao inheritance thất bại theo cách cụ thể, Strategy Pattern giải quyết nó thế nào, và khi nào nên dùng cái gì — với code Java đủ để bạn implement được ngay, không phải chỉ hiểu concept.


Phần 1 — Inheritance từng là "ngôi sao"

Inheritance giải quyết điều gì?

Inheritance ra đời để giải quyết một bài toán thực sự: code reuseshared behavior.

Ý tưởng cốt lõi: nếu DogCat đều là Animal, tại sao phải viết lại method breathe()sleep() hai lần? Để chúng extend Animal, share behavior, chỉ override những gì khác biệt.

abstract class Animal {
    public void breathe() {
        System.out.println("Inhale... Exhale...");
    }

    public void sleep() {
        System.out.println("Zzz...");
    }

    public abstract void makeSound();
}

class Dog extends Animal {
    @Override
    public void makeSound() {
        System.out.println("Woof!");
    }
}

class Cat extends Animal {
    @Override
    public void makeSound() {
        System.out.println("Meow!");
    }
}

Đây là inheritance đúng chỗ. Quan hệ Dog is-a Animal là thật về mặt domain. Dog không cần biến thành Cat ở runtime. Hierarchy ổn định — không ai sắp yêu cầu thêm Dog có thể vừa là Fish vừa là Bird.

Vấn đề không phải inheritance nói chung. Vấn đề là dùng inheritance để model những thứ không phải là quan hệ is-a thật sự — chỉ là "tao muốn reuse code của mày".

Business thực tế hiếm khi ổn định

Domain ổn định như Animal → Dog là trường hợp may mắn. Business thực tế thì khác.

Một payment system thực tế phải đối mặt với:

  • Thêm payment provider mới (Stripe, ZaloPay, Apple Pay...)
  • Mỗi provider có quirk riêng (timeout khác nhau, retry logic khác nhau, error code khác nhau)
  • Discount logic thay đổi theo campaign, region, user tier
  • Fraud check rules cập nhật hàng tuần
  • Regulatory requirement thay đổi theo quốc gia

Mỗi thay đổi là một lý do để "hơi sửa" class. Và mỗi lần "hơi sửa" class trong inheritance tree, bạn có nguy cơ break toàn bộ subtree bên dưới.

The Fragile Base Class Problem

Đây là tên học thuật của cái bug mà mình gặp lúc hotfix 11 giờ đêm kể trên.

Xem ví dụ cụ thể:

class BasePaymentService {
    public PaymentResult process(PaymentRequest request) {
        validate(request);          // Bước 1
        applyDiscount(request);     // Bước 2
        executePayment(request);    // Bước 3
        sendNotification(request);  // Bước 4
        return buildResult(request);
    }

    protected void validate(PaymentRequest request) {
        if (request.getAmount() <= 0) throw new InvalidAmountException();
    }

    protected void applyDiscount(PaymentRequest request) {
        // Base: không discount
    }
}

class MomoPaymentService extends BasePaymentService {
    @Override
    protected void applyDiscount(PaymentRequest request) {
        // MoMo: giảm 10% nếu là lần đầu
        if (userService.isFirstTime(request.getUserId())) {
            request.applyDiscount(0.1);
        }
    }
}

class MomoFlashSalePaymentService extends MomoPaymentService {
    @Override
    protected void applyDiscount(PaymentRequest request) {
        super.applyDiscount(request); // Gọi MoMo discount trước
        // Rồi thêm flash sale discount
        if (flashSaleService.isActive()) {
            request.applyDiscount(0.2);
        }
    }
}

Nhìn có vẻ ổn. Bây giờ requirement mới: validate() cần check thêm một điều kiện — đơn hàng không được vượt limit hàng ngày của user.

Developer sửa BasePaymentService.validate():

protected void validate(PaymentRequest request) {
    if (request.getAmount() <= 0) throw new InvalidAmountException();
    // Thêm mới:
    BigDecimal dailyTotal = orderRepo.getDailyTotal(request.getUserId());
    if (dailyTotal.add(request.getAmount()).compareTo(DAILY_LIMIT) > 0) {
        throw new DailyLimitExceededException();
    }
}

Hợp lý. Test BasePaymentService — pass. Nhưng MomoFlashSalePaymentService thì sao?

Hóa ra trong MomoFlashSalePaymentService, có một developer khác đã override validate() để bỏ qua limit check cho flash sale (vì flash sale items có giá cao, cần bypass limit). Nhưng họ gọi super.validate() rồi mới add thêm logic của mình. Khi base class thêm logic mới vào validate(), override trong subclass chạy theo sequence khác với ý định ban đầu.

Bug xuất hiện. Ai cũng ngạc nhiên. Ai cũng phải đọc lại toàn bộ chain để hiểu điều gì đang xảy ra.

Đây là Fragile Base Class Problem: Class cha không thể thay đổi implementation detail mà không nguy cơ break subclass — dù subclass không trực tiếp depend vào detail đó.

Coupling ở inheritance level cao đến mức bạn phải hiểu toàn bộ subtree trước khi dám sửa base class. Khi subtree có 20 class, điều đó gần như không khả thi trong thực tế.


Phần 2 — Composition xuất hiện

Composition là gì?

Composition là nguyên tắc thiết kế: thay vì một thứ gì đó (inheritance), hãy sở hữu hành vi của nó.

// Inheritance: Dog "là" một Animal với behavior cố định
class Dog extends Animal { ... }

// Composition: Dog "có" behavior, và behavior có thể thay đổi
class Dog {
    private BarkBehavior barkBehavior;
    private MoveBehavior moveBehavior;

    public Dog(BarkBehavior bark, MoveBehavior move) {
        this.barkBehavior = bark;
        this.moveBehavior = move;
    }

    public void performBark() {
        barkBehavior.bark();
    }
}

Sự khác biệt nghe có vẻ nhỏ, nhưng hệ quả rất lớn:

  • BarkBehaviorMoveBehavior là interface — có thể có nhiều implementation.
  • Behavior có thể thay đổi lúc runtime, không cần tạo subclass mới.
  • Dog không bị coupling vào một implementation cụ thể.
  • Test dễ hơn nhiều — inject mock behavior vào.

"Favor Composition Over Inheritance" — GoF nói gì và tại sao

Gang of Four viết điều này năm 1994 trong Design Patterns. Ba mươi năm sau, nó vẫn đúng — thậm chí còn đúng hơn trong thế giới microservice và agile hiện đại.

Lý do GoF ưu tiên composition:

Loose coupling: Object không biết implementation detail của behavior nó đang dùng. Chỉ biết interface. Thay implementation khác không ảnh hưởng object.

Thay đổi lúc runtime: Inheritance quyết định behavior tại compile time (bạn extend class nào là cố định). Composition cho phép thay behavior lúc runtime — inject implementation khác vào.

Reuse linh hoạt hơn: Một behavior có thể được share giữa nhiều class không liên quan nhau trong hierarchy. Với inheritance, chỉ share được trong subtree.

Dễ test hơn: Inject mock behavior vào — không cần mock toàn bộ base class với tất cả side effect của nó.

Điểm quan trọng mà nhiều người bỏ qua: "Favor" không có nghĩa là "luôn luôn". GoF không nói inheritance sai. Họ nói: khi bạn đang chọn giữa hai, hãy lean về phía composition vì nó tạo ra less coupling. Khi inheritance thực sự đúng — dùng nó.


Phần 3 — Strategy Pattern bước vào sân khấu

Strategy Pattern giải quyết bài toán gì?

Strategy Pattern giải quyết bài toán: một hành vi có nhiều cách thực hiện, và cách thực hiện cần có thể thay đổi độc lập với object đang dùng nó.

Không phải mọi if-else đều cần Strategy Pattern. Nhưng khi bạn thấy:

  • if-else theo type, method, region, userTier... mà ngày càng dài ra
  • Mỗi nhánh của if-else có logic riêng biệt, không share gì nhiều
  • Business yêu cầu thêm case mới thường xuyên
  • Mỗi lần thêm case là phải sửa một file đang có (vi phạm Open/Closed Principle)

Đó là signal để dùng Strategy.

Cách junior thường viết — và tại sao nó vỡ

public class PaymentService {

    public PaymentResult process(PaymentRequest request) {
        String method = request.getPaymentMethod();

        if (method.equals("MOMO")) {
            // 30 dòng logic MoMo
            MomoClient client = new MomoClient(MOMO_CONFIG);
            MomoRequest momoReq = buildMomoRequest(request);
            MomoResponse response = client.charge(momoReq);
            if (!response.isSuccess()) {
                throw new PaymentFailedException(response.getErrorCode());
            }
            return new PaymentResult(response.getTransactionId(), "MOMO");

        } else if (method.equals("PAYPAL")) {
            // 40 dòng logic PayPal
            PaypalClient client = new PaypalClient(PAYPAL_KEY, PAYPAL_SECRET);
            // ...

        } else if (method.equals("VNPAY")) {
            // 25 dòng logic VNPay
            // ...

        } else if (method.equals("STRIPE")) {
            // 35 dòng logic Stripe
            // ...

        } else {
            throw new UnsupportedPaymentMethodException(method);
        }
    }
}

Vấn đề cụ thể:

Vi phạm Open/Closed Principle: Mỗi lần thêm payment method mới, bạn phải sửa PaymentService. Class này không "closed for modification". Risk: sửa file có 200 dòng logic, dễ introduce bug cho method cũ.

God class: PaymentService biết quá nhiều — biết MoMo, biết PayPal, biết VNPay. Single Responsibility Principle bị phá vỡ.

Không thể test riêng lẻ: Muốn test logic MoMo phải instantiate toàn bộ PaymentService với tất cả dependency của nó.

Không thể deploy riêng lẻ: Thay đổi logic PayPal phải deploy cả file. Regression test phải chạy tất cả.

Khó phân chia công việc: Hai developer không thể cùng làm MoMo logic và PayPal logic mà không conflict trên cùng file.

Strategy Pattern — Cấu trúc và implementation

Bước 1: Định nghĩa interface (Strategy)

public interface PaymentStrategy {
    PaymentResult pay(PaymentRequest request);
    boolean supports(String paymentMethod);
}

Bước 2: Implement từng strategy

@Component
public class MomoPaymentStrategy implements PaymentStrategy {

    private final MomoClient momoClient;
    private final MomoRequestBuilder requestBuilder;

    public MomoPaymentStrategy(MomoClient momoClient,
                                MomoRequestBuilder requestBuilder) {
        this.momoClient = momoClient;
        this.requestBuilder = requestBuilder;
    }

    @Override
    public PaymentResult pay(PaymentRequest request) {
        MomoRequest momoReq = requestBuilder.build(request);
        MomoResponse response = momoClient.charge(momoReq);

        if (!response.isSuccess()) {
            throw new PaymentFailedException(
                "MOMO", response.getErrorCode(), response.getMessage()
            );
        }

        return PaymentResult.builder()
            .transactionId(response.getTransactionId())
            .method("MOMO")
            .amount(request.getAmount())
            .build();
    }

    @Override
    public boolean supports(String paymentMethod) {
        return "MOMO".equalsIgnoreCase(paymentMethod);
    }
}

@Component
public class PaypalPaymentStrategy implements PaymentStrategy {

    private final PaypalClient paypalClient;

    public PaypalPaymentStrategy(PaypalClient paypalClient) {
        this.paypalClient = paypalClient;
    }

    @Override
    public PaymentResult pay(PaymentRequest request) {
        // PayPal-specific logic: convert amount to USD, handle PayPal error codes
        BigDecimal amountUSD = currencyService.convertToUSD(request.getAmount());
        PaypalOrder order = paypalClient.createOrder(amountUSD, request.getCurrency());
        PaypalCapture capture = paypalClient.captureOrder(order.getId());

        if (!"COMPLETED".equals(capture.getStatus())) {
            throw new PaymentFailedException(
                "PAYPAL", capture.getStatus(), "PayPal capture failed"
            );
        }

        return PaymentResult.builder()
            .transactionId(capture.getId())
            .method("PAYPAL")
            .amount(request.getAmount())
            .build();
    }

    @Override
    public boolean supports(String paymentMethod) {
        return "PAYPAL".equalsIgnoreCase(paymentMethod);
    }
}

Bước 3: Context — chọn và dùng strategy

@Service
public class PaymentService {

    private final List<PaymentStrategy> strategies;

    // Spring inject tất cả implementation của PaymentStrategy
    public PaymentService(List<PaymentStrategy> strategies) {
        this.strategies = strategies;
    }

    public PaymentResult process(PaymentRequest request) {
        PaymentStrategy strategy = strategies.stream()
            .filter(s -> s.supports(request.getPaymentMethod()))
            .findFirst()
            .orElseThrow(() -> new UnsupportedPaymentMethodException(
                request.getPaymentMethod()
            ));

        return strategy.pay(request);
    }
}

Đây là điểm hay của Spring DI: bạn không cần maintain một map hay factory thủ công. Khai báo thêm một @Component implement PaymentStrategy là nó tự được inject vào list. PaymentService không cần thay đổi.

Thêm Stripe? Không sửa file nào cũ:

@Component
public class StripePaymentStrategy implements PaymentStrategy {

    @Override
    public PaymentResult pay(PaymentRequest request) {
        // Stripe logic
    }

    @Override
    public boolean supports(String paymentMethod) {
        return "STRIPE".equalsIgnoreCase(paymentMethod);
    }
}

Deploy chỉ class này. Test chỉ class này. Merge conflict với team khác? Không có vì không ai chạm vào file cũ.

Điều mạnh nhất của Strategy: thay đổi behavior lúc runtime

Inheritance quyết định behavior tại compile time. Strategy cho phép inject behavior khác nhau lúc runtime.

@Service
public class PricingService {

    public BigDecimal calculateFinalPrice(Order order, User user) {
        // Chọn discount strategy lúc runtime dựa trên context
        DiscountStrategy strategy = selectStrategy(order, user);
        return strategy.apply(order.getBasePrice());
    }

    private DiscountStrategy selectStrategy(Order order, User user) {
        if (flashSaleService.isActive(order.getProductId())) {
            return new FlashSaleDiscountStrategy(flashSaleService.getDiscount());
        }
        if (user.getTier() == UserTier.VIP) {
            return new VipDiscountStrategy(user.getVipLevel());
        }
        if (couponService.isValid(order.getCouponCode())) {
            return new CouponDiscountStrategy(order.getCouponCode());
        }
        return new NoDiscountStrategy();
    }
}

Logic chọn strategy và logic apply discount tách hoàn toàn. Thêm discount type mới? Tạo class mới implement DiscountStrategy. Thay đổi logic chọn strategy? Chỉ sửa selectStrategy(). Hai việc độc lập nhau.


Phần 4 — Case Study thực chiến

Case 1: Payment System

Trước Strategy, codebase mình join có dạng:

// File: PaymentService.java — 847 dòng
public class PaymentService extends BasePaymentService {
    public void processMomo(...) { ... }    // 120 dòng
    public void processPaypal(...) { ... }  // 150 dòng
    public void processVnpay(...) { ... }   // 90 dòng
    public void processStripe(...) { ... }  // 130 dòng
    // ... và một đống helper method dùng chung lẫn lộn
}

Mỗi lần có bug trong MoMo flow, developer phải load toàn bộ 847 dòng vào đầu để hiểu context. Mỗi lần thêm payment method mới, merge conflict với người đang fix bug cũ.

Sau refactor sang Strategy:

payment/
  strategy/
    PaymentStrategy.java           (interface)
    MomoPaymentStrategy.java       (89 dòng)
    PaypalPaymentStrategy.java     (112 dòng)
    VnpayPaymentStrategy.java      (75 dòng)
    StripePaymentStrategy.java     (98 dòng)
  PaymentService.java              (34 dòng — chỉ là orchestrator)

PaymentService.java 34 dòng. Đọc 5 giây hiểu toàn bộ. Developer làm MoMo chỉ cần đọc file MoMo. Không ai giẫm chân nhau.

Một điều thực tế: Không phải lúc nào cũng refactor được ngay. Codebase legacy có constraint riêng. Cách tiếp cận pragmatic: mỗi khi thêm payment method mới, tạo strategy class mới thay vì thêm else-if. Dần dần extract các method cũ ra. Strangler fig pattern.

Case 2: Pricing Engine

Hệ thống giá trong e-commerce phức tạp hơn người ngoài nghĩ. Một đơn hàng có thể apply:

  • Base price
  • Flash sale discount (30% off)
  • VIP tier discount (thêm 5%)
  • Coupon discount (giảm 50k)
  • Bundle discount (mua 2 tặng 1)
  • Regional tax (10% VAT)
  • Cross-border fee (nếu ship quốc tế)

Và các rule này có thể stack, hoặc không stack, tùy campaign.

Với Strategy + Chain of Responsibility kết hợp:

public interface PricingRule {
    BigDecimal apply(BigDecimal currentPrice, OrderContext context);
    int getPriority();  // Thứ tự apply
}

@Component
public class FlashSaleRule implements PricingRule {
    @Override
    public BigDecimal apply(BigDecimal price, OrderContext ctx) {
        if (!flashSaleService.isActive(ctx.getProductId())) return price;
        BigDecimal discount = flashSaleService.getDiscount(ctx.getProductId());
        return price.multiply(BigDecimal.ONE.subtract(discount));
    }

    @Override
    public int getPriority() { return 10; }  // Apply trước
}

@Component
public class VatRule implements PricingRule {
    @Override
    public BigDecimal apply(BigDecimal price, OrderContext ctx) {
        return price.multiply(new BigDecimal("1.10"));  // +10% VAT
    }

    @Override
    public int getPriority() { return 90; }  // Apply sau cùng
}

@Service
public class PricingEngine {
    private final List<PricingRule> rules;

    public PricingEngine(List<PricingRule> rules) {
        // Sort theo priority một lần lúc init
        this.rules = rules.stream()
            .sorted(Comparator.comparingInt(PricingRule::getPriority))
            .collect(Collectors.toList());
    }

    public BigDecimal calculate(BigDecimal basePrice, OrderContext context) {
        BigDecimal price = basePrice;
        for (PricingRule rule : rules) {
            price = rule.apply(price, context);
        }
        return price;
    }
}

Business muốn thêm "Loyalty Point Discount" cho tháng tới? Tạo LoyaltyPointRule implements PricingRule. Set priority. Deploy. Không chạm gì vào code hiện tại.

Business muốn bỏ flash sale discount tạm thời? Feature flag trong FlashSaleRule.apply() — không cần deploy.

Case 3: Authentication System

Login trong app hiện đại có nhiều provider:

public interface AuthStrategy {
    AuthResult authenticate(AuthRequest request);
    String getProviderName();
}

@Component
public class GoogleAuthStrategy implements AuthStrategy {
    private final GoogleOAuthClient googleClient;

    @Override
    public AuthResult authenticate(AuthRequest request) {
        // 1. Verify Google ID token
        GoogleIdToken.Payload payload = googleClient.verify(request.getIdToken());

        // 2. Extract user info
        String email = payload.getEmail();
        String googleId = payload.getSubject();

        // 3. Find or create user
        User user = userRepo.findByGoogleId(googleId)
            .orElseGet(() -> createUserFromGoogle(payload));

        return AuthResult.success(user, generateJwt(user));
    }

    @Override
    public String getProviderName() { return "GOOGLE"; }
}

@Component
public class PasswordAuthStrategy implements AuthStrategy {
    private final PasswordEncoder passwordEncoder;

    @Override
    public AuthResult authenticate(AuthRequest request) {
        User user = userRepo.findByEmail(request.getEmail())
            .orElseThrow(() -> new UserNotFoundException());

        if (!passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) {
            throw new InvalidCredentialsException();
        }

        if (user.isMfaEnabled()) {
            return AuthResult.requireMfa(user);  // Không trả JWT, yêu cầu MFA tiếp
        }

        return AuthResult.success(user, generateJwt(user));
    }

    @Override
    public String getProviderName() { return "PASSWORD"; }
}

Thêm Apple Sign In? Tạo AppleAuthStrategy. Business logic của Google auth không bị ảnh hưởng. Test coverage không cần thay đổi.

Điểm thực tế: Mỗi auth provider có flow phức tạp riêng — Google có ID token, Apple có private key JWT, Facebook có access token. Nếu nhét tất cả vào một class, không ai dám sửa vì không hiểu interaction giữa các flow.


Phần 5 — Góc tối của Strategy Pattern

Over-engineering — Cái bẫy thường gặp nhất

Strategy Pattern đẹp. Và vì nó đẹp, developer đôi khi muốn apply nó cho mọi thứ.

Mình từng review một PR có GreetingStrategy với implementation MorningGreetingStrategy, AfternoonGreetingStrategy, EveningGreetingStrategy. Ba class riêng biệt để làm cái việc mà một method 5 dòng là đủ:

// Cái này không cần Strategy
public String greet(LocalTime time) {
    if (time.isBefore(LocalTime.NOON)) return "Good morning!";
    if (time.isBefore(LocalTime.of(18, 0))) return "Good afternoon!";
    return "Good evening!";
}

Heuristic để quyết định có nên dùng Strategy không:

  1. Logic có thực sự khác nhau đáng kể giữa các case? Nếu mỗi case chỉ là một vài dòng khác nhau, if-else đơn giản hơn và dễ đọc hơn.

  2. Số lượng case có khả năng tăng theo thời gian? Nếu hiện tại có 2 case và business không có kế hoạch thêm, Strategy là premature optimization.

  3. Các case có cần được phát triển độc lập? Nếu cùng một developer làm tất cả, Strategy ít mang lại lợi ích về team workflow.

  4. Có cần test từng case riêng lẻ? Với case đơn giản, test chung cũng được.

Rule of thumb: Khi bạn thêm case thứ ba và bắt đầu cảm thấy if-else khó extend — đó là lúc refactor sang Strategy. Không phải trước đó.

Composition không phải miễn phí

Có những tradeoff thực tế khi dùng Strategy:

Nhiều class hơn: Thay vì một file 200 dòng, bạn có 10 file 20 dòng. Mỗi cách có ưu điểm riêng. Một số developer thấy nhiều file khó navigate hơn.

Dependency injection phức tạp hơn: Với Spring, inject List<PaymentStrategy> thường ok. Nhưng khi có nhiều strategy với dependency phức tạp, application context có thể trở nên khó debug.

Stack trace dài hơn: Khi có exception, stack trace đi qua nhiều layer (Strategy interface, Context, Dispatcher...) hơn so với gọi method trực tiếp. Debug đôi khi mất thêm thời gian.

Khó trace flow ban đầu: Với junior mới vào team, đọc code Strategy cần biết tìm implementation ở đâu. IDE với "Find implementations" giải quyết được, nhưng cần một chút làm quen.

Đây không phải lý do để không dùng Strategy. Nhưng là lý do để không dùng nó ở những chỗ không cần.

Khi nào Inheritance vẫn là lựa chọn đúng

Inheritance không phải kẻ thù. Nó phù hợp khi:

Quan hệ is-a thực sự ổn định trong domain:

// Exception hierarchy — đây là inheritance đúng chỗ
class AppException extends RuntimeException { ... }
class ValidationException extends AppException { ... }
class PaymentException extends AppException { ... }
class InsufficientBalanceException extends PaymentException { ... }

Hierarchy này phản ánh taxonomy thật của domain. InsufficientBalanceException thực sự là một loại PaymentException. Shared behavior (getErrorCode(), getHttpStatus()) được reuse hợp lý. Hierarchy ổn định — không ai sắp yêu cầu InsufficientBalanceException cũng là ValidationException.

UI component base class:

abstract class BaseDialog extends JDialog {
    protected void initComponents() { ... }  // Template method
    protected abstract JPanel buildContent();
    protected abstract String getTitle();
}

Tất cả dialog trong app chia sẻ behavior (size, position, close button, escape key handler). Khác biệt chỉ ở content. Template Method Pattern kết hợp với inheritance là phù hợp ở đây.

Framework base class:

class AbstractController {
    // Spring, hoặc framework tự viết
    protected HttpServletRequest request;
    protected HttpServletResponse response;

    protected <T> ResponseEntity<T> ok(T body) { ... }
    protected ResponseEntity<?> badRequest(String message) { ... }
}

Đây là infrastructure code, không phải business logic. Hierarchy nông (1-2 tầng), ổn định, behavior thực sự share.

Test base class:

abstract class BaseIntegrationTest {
    @Autowired protected MockMvc mockMvc;
    @Autowired protected ObjectMapper objectMapper;

    protected ResultActions post(String url, Object body) throws Exception { ... }
    protected ResultActions get(String url) throws Exception { ... }
}

Test class thừa kế helper method. Không có business logic. Không bao giờ cần thay đổi behavior lúc runtime.

Pattern nhận ra Inheritance đúng chỗ: Hierarchy nông (không quá 2-3 tầng), stable, shared behavior thực sự chung, không cần thay đổi runtime, không phát triển độc lập theo nhóm.


Phần 6 — Tư duy senior thực sự

Senior không anti-Inheritance

Mình thấy một lỗi phổ biến ở developer đang trong giai đoạn học design pattern: đọc xong "Favor Composition Over Inheritance", họ bắt đầu refactor mọi inheritance sang composition, dù không cần thiết.

Senior engineer không anti-inheritance. Họ hiểu tại sao inheritance có vấn đề trong những trường hợp cụ thể, và đủ kinh nghiệm để nhận ra trường hợp nào là trường hợp đó.

Câu hỏi họ hỏi không phải "nên dùng inheritance hay composition?" mà là:

  • Quan hệ này có thực sự là is-a, hay chỉ là "tao muốn reuse code của mày"?
  • Behavior này có cần thay đổi lúc runtime không?
  • Hierarchy này có ổn định không, hay business sẽ thay đổi nó?
  • Coupling ở đây có phù hợp với lifecycle của các class này không?
  • Team có cần phát triển các phần này độc lập không?

Coupling là từ khóa. Inheritance tạo ra coupling rất chặt — class con biết rất nhiều về implementation của class cha. Đó không phải điều xấu nếu coupling là có chủ ý và phù hợp với domain. Nó trở thành vấn đề khi coupling đó làm bạn không thể thay đổi một phần mà không ảnh hưởng phần khác.

Design Pattern không phải mục tiêu

Đây là điều mình muốn nói thẳng: Design pattern là phương tiện, không phải mục đích.

Code "dùng Strategy Pattern đúng sách" nhưng khó đọc, khó debug, over-engineered — là code tệ.

Code không có pattern nào cả nhưng dễ đọc, dễ test, dễ thay đổi — là code tốt.

Mục tiêu thực sự:

Dễ thay đổi: Business thay đổi yêu cầu mỗi sprint. Code của bạn có thể accommodate không? Mỗi lần thay đổi nhỏ có phải sửa 10 file không?

Dễ đọc: Developer mới join có thể hiểu flow trong bao lâu? Có phải load toàn bộ codebase vào đầu để thay đổi một feature không?

Dễ test: Có thể test từng đơn vị logic một cách độc lập không? Test có chạy nhanh không, hay phải spin up database và external service?

Dễ scale team: 5 developer có thể làm việc song song trên codebase này mà không giẫm chân nhau không?

Pattern giúp đạt những mục tiêu này trong nhiều trường hợp. Nhưng pattern không phải mục tiêu.


Kết bài — Code sống lâu hơn người viết nó

Có một câu nói trong ngành mà mình nghĩ đến mỗi khi viết code: "Code được đọc nhiều hơn được viết."

Bạn viết một class trong 2 giờ. Nhưng class đó có thể tồn tại 2 năm, được đọc bởi 20 người, được sửa 50 lần. Trong 50 lần sửa đó, mỗi lần ai đó phải hiểu context trước khi sửa — và mỗi lần sửa có nguy cơ introduce bug.

Inheritance thường khiến việc viết code lần đầu dễ hơn. Bạn extend, override, xong — nhanh.

Composition — và cụ thể hơn là Strategy Pattern — khiến việc thay đổi hệ thống theo thời gian dễ hơn. Mỗi piece nhỏ hơn, độc lập hơn, dễ hiểu hơn trong isolation.

Và trong thế giới software thực tế, nơi requirement thay đổi mỗi sprint, nơi team thay đổi, nơi business pivot, nơi bug cần fix lúc 11 giờ đêm — khả năng thay đổi gần như luôn quan trọng hơn sự gọn gàng ban đầu.

Lần sau khi bạn chuẩn bị gõ extends, hãy dừng lại một giây và hỏi: đây là is-a thật sự, hay chỉ là "tao muốn dùng code của nó"? Nếu là cái sau — có lẽ đã đến lúc composition.


Tóm tắt để bookmark:

Inheritance Composition / Strategy
Coupling Chặt (subclass biết nhiều về parent) Lỏng (chỉ biết interface)
Thay đổi runtime Không
Thêm behavior mới Tạo subclass mới (có thể gây tree phình) Tạo implementation mới (không ảnh hưởng code cũ)
Dễ test Khó (phải mock toàn bộ parent) Dễ (inject mock strategy)
Dễ đọc Tốt khi tree nông Tốt khi có nhiều variant
Dùng khi is-a thật, hierarchy ổn định, behavior shared immutable Behavior thay đổi theo context, nhiều variant, cần extend thường xuyên
Ví dụ đúng chỗ Exception hierarchy, UI component base, test helper Payment method, auth provider, discount rule, notification channel

Strategy Pattern là một trong số ít pattern mà hiểu đúng một lần, bạn sẽ thấy ứng dụng của nó ở khắp nơi — không chỉ trong payment hay auth, mà trong bất kỳ chỗ nào behavior cần linh hoạt. Điểm khó không phải implement, mà là biết khi nào nên dùng và khi nào thì thôi.


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.