Welcome to MikeDaDev's Blog – notes on Java, backend & system design.

Explore
MikeDaDev's Blog
in java

solid principles

Tung Bui Thanh (TungDaDev)23 mins read

Solid principles.webp

Any fool can write code that a computer can understand. Good programmers write code that humans can understand. — Martin Fowler

Nếu bạn từng review code mà thấy "cái service này ôm đồm quá", hoặc thêm một feature mới mà phải sửa 15 file, thì đó là lúc bạn đang trả giá cho việc vi phạm SOLID.

Bài này ngoài chuyện SOLID là gì, mình sẽ đi vào tại sao có từng nguyên tắc, khi nào nên áp dụng và áp dụng ra sao trong Java & Spring Boot.


# tổng quan về solid

SOLID là 5 nguyên tắc thiết kế hướng đối tượng (OOP) được Robert C. Martin (Uncle Bob) gom lại và phổ biến, chủ yếu qua bài "Design Principles and Design Patterns" năm 2000. Không phải nguyên tắc nào cũng do ông nghĩ ra: Open/Closed là của Bertrand Meyer (1988), Liskov Substitution là của Barbara Liskov (1987), còn cái tên viết tắt SOLID do Michael Feathers đặt vào khoảng năm 2004. Mục tiêu:

  • Dễ đọc: đọc code là hiểu người viết muốn làm gì
  • Dễ mở rộng: thêm feature mới không làm hỏng feature cũ
  • Dễ test: từng phần test riêng được
  • Dễ bảo trì: sửa bug chỗ này không lan sang chỗ khác

Cứ coi SOLID như quy chuẩn xây dựng cho phần mềm. Quy chuẩn xây dựng giữ cho toà nhà không sập, còn SOLID giữ cho codebase của bạn không biến thành đống "legacy" ai cũng ngại đụng sau 6 tháng.

Chữ cáiNguyên tắcMột câu tóm tắt
SSingle ResponsibilityMột class chỉ có một lý do để thay đổi
OOpen/ClosedMở để mở rộng, đóng để sửa đổi
LLiskov SubstitutionSubclass phải thay thế được parent class
IInterface SegregationNhiều interface nhỏ tốt hơn một interface lớn
DDependency InversionPhụ thuộc vào abstraction, không phụ thuộc vào implementation

# s: single responsibility principle

A class should have one, and only one, reason to change. — Robert C. Martin

Bạn gặp UserService nào vừa xử lý business logic, vừa gửi email, vừa validate input, vừa format response chưa? Đó là "God Class": cái gì cũng biết, cái gì cũng làm, sửa một chỗ là vỡ cả đám.

SRP không có nghĩa là "mỗi class chỉ có một method". SRP nói về reason to change, tức lý do khiến class phải sửa. Đổi business rule phải sửa class này, đổi email template cũng sửa đúng class đó, vậy là vi phạm SRP.

# vi phạm SRP

@Service
public class OrderService {
 
    @Autowired
    private OrderRepository orderRepository;
 
    @Autowired
    private JavaMailSender mailSender;
 
    public Order createOrder(OrderRequest request) {
        // Validate input
        if (request.getItems().isEmpty()) {
            throw new IllegalArgumentException("Order must have at least one item");
        }
        if (request.getItems().stream().anyMatch(i -> i.getQuantity() <= 0)) {
            throw new IllegalArgumentException("Quantity must be positive");
        }
 
        // Calculate total
        BigDecimal total = request.getItems().stream()
                .map(i -> i.getPrice().multiply(BigDecimal.valueOf(i.getQuantity())))
                .reduce(BigDecimal.ZERO, BigDecimal::add);
 
        // Apply discount
        if (total.compareTo(BigDecimal.valueOf(1000)) > 0) {
            total = total.multiply(BigDecimal.valueOf(0.9)); // 10% discount
        }
 
        // Save order
        Order order = new Order();
        order.setItems(request.getItems());
        order.setTotal(total);
        order.setStatus(OrderStatus.CREATED);
        Order saved = orderRepository.save(order);
 
        // Send confirmation email
        SimpleMailMessage message = new SimpleMailMessage();
        message.setTo(request.getCustomerEmail());
        message.setSubject("Order Confirmation #" + saved.getId());
        message.setText("Your order total: " + total);
        mailSender.send(message);
 
        // Log for analytics
        log.info("ORDER_CREATED: id={}, total={}, items={}",
                saved.getId(), total, request.getItems().size());
 
        return saved;
    }
}

Class này có tới 5 lý do để thay đổi: validation rules, pricing logic, persistence, email notification và analytics logging. PM bảo "đổi cách tính discount" là bạn phải mở cái file chứa luôn cả logic gửi email, sửa mà run tay.

# áp dụng srp đúng cách

// 1. Validation — thay đổi khi business rules thay đổi
@Component
public class OrderValidator {
 
    public void validate(OrderRequest request) {
        if (request.getItems().isEmpty()) {
            throw new IllegalArgumentException("Order must have at least one item");
        }
        if (request.getItems().stream().anyMatch(i -> i.getQuantity() <= 0)) {
            throw new IllegalArgumentException("Quantity must be positive");
        }
    }
}
 
// 2. Pricing — thay đổi khi pricing strategy thay đổi
@Component
public class OrderPricingService {
 
    public BigDecimal calculateTotal(List<OrderItem> items) {
        BigDecimal total = items.stream()
                .map(i -> i.getPrice().multiply(BigDecimal.valueOf(i.getQuantity())))
                .reduce(BigDecimal.ZERO, BigDecimal::add);
 
        if (total.compareTo(BigDecimal.valueOf(1000)) > 0) {
            total = total.multiply(BigDecimal.valueOf(0.9));
        }
        return total;
    }
}
 
// 3. Notification — thay đổi khi cách thông báo thay đổi
@Component
public class OrderNotificationService {
 
    @Autowired
    private JavaMailSender mailSender;
 
    public void sendConfirmation(Order order, String customerEmail) {
        SimpleMailMessage message = new SimpleMailMessage();
        message.setTo(customerEmail);
        message.setSubject("Order Confirmation #" + order.getId());
        message.setText("Your order total: " + order.getTotal());
        mailSender.send(message);
    }
}
 
// 4. Orchestrator — chỉ điều phối, không chứa business logic
@Service
@RequiredArgsConstructor
public class OrderService {
 
    private final OrderValidator validator;
    private final OrderPricingService pricingService;
    private final OrderRepository orderRepository;
    private final OrderNotificationService notificationService;
 
    @Transactional
    public Order createOrder(OrderRequest request) {
        validator.validate(request);
 
        BigDecimal total = pricingService.calculateTotal(request.getItems());
 
        Order order = new Order();
        order.setItems(request.getItems());
        order.setTotal(total);
        order.setStatus(OrderStatus.CREATED);
        Order saved = orderRepository.save(order);
 
        notificationService.sendConfirmation(saved, request.getCustomerEmail());
 
        return saved;
    }
}

# tại sao cách này tốt hơn?

  • Test dễ hơn: test pricing logic không cần mock email sender
  • Sửa an toàn hơn: đổi discount strategy thì chỉ đụng OrderPricingService
  • Reuse: OrderPricingService dùng lại được cho cả quote, invoice
  • Làm nhóm dễ hơn: 2 dev làm song song được, một người sửa pricing, một người sửa notification

Còn một chỗ nên để ý trong bản đã tách: sendConfirmation() đang chạy bên trong @Transactional. Nếu email gửi xong mà transaction commit lỗi (vd vi phạm constraint lúc flush), khách đã nhận mail xác nhận cho một đơn không tồn tại. Ngược lại, SMTP chậm thì transaction và connection DB bị giữ lâu theo. Cách thường dùng là bắn một event rồi gửi mail trong @TransactionalEventListener(phase = AFTER_COMMIT), hoặc ghi vào bảng outbox để một job gửi sau. Tách class theo SRP rồi thì đổi sang cách này cũng chỉ đụng tới chỗ gọi notification.


# o: open/closed principle

Software entities should be open for extension, but closed for modification. — Bertrand Meyer

Mỗi lần PM bảo "thêm phương thức thanh toán mới", bạn lại mở PaymentService, nhét thêm một else if vào chuỗi if-else dài 200 dòng. Thêm lần nào là lần đó có nguy cơ làm hỏng logic cũ.

OCP không có nghĩa là "không bao giờ sửa code". Ý của nó là thiết kế sao cho thêm behavior mới mà không phải sửa code đang chạy. Công cụ chính: abstraction (interface) + polymorphism.

# vi phạm ocp: chuỗi if-else dài bất tận

@Service
public class PaymentService {
 
    public PaymentResult processPayment(PaymentRequest request) {
        if (request.getMethod() == PaymentMethod.CREDIT_CARD) {
            // 30 dòng xử lý credit card
            return processCreditCard(request);
        } else if (request.getMethod() == PaymentMethod.BANK_TRANSFER) {
            // 25 dòng xử lý bank transfer
            return processBankTransfer(request);
        } else if (request.getMethod() == PaymentMethod.MOMO) {
            // 20 dòng xử lý MoMo
            return processMoMo(request);
        } else if (request.getMethod() == PaymentMethod.VNPAY) {
            // Mới thêm tuần trước, đã gây bug cho credit card...
            return processVnPay(request);
        }
        // Thêm ZaloPay? Lại mở file này ra sửa...
        throw new UnsupportedOperationException("Unknown payment method");
    }
}

# áp dụng OCP với Strategy Pattern + Spring

// Interface — contract cho mọi payment processor
public interface PaymentProcessor {
 
    PaymentMethod getSupportedMethod();
 
    PaymentResult process(PaymentRequest request);
}
 
// Mỗi implementation là một file riêng, không ai ảnh hưởng ai
@Component
public class CreditCardPaymentProcessor implements PaymentProcessor {
 
    @Override
    public PaymentMethod getSupportedMethod() {
        return PaymentMethod.CREDIT_CARD;
    }
 
    @Override
    public PaymentResult process(PaymentRequest request) {
        // Validate card number, expiry, CVV
        // Call payment gateway API
        // Handle 3D Secure if needed
        return PaymentResult.success(transactionId);
    }
}
 
@Component
public class MoMoPaymentProcessor implements PaymentProcessor {
 
    @Autowired
    private MoMoApiClient momoClient;
 
    @Override
    public PaymentMethod getSupportedMethod() {
        return PaymentMethod.MOMO;
    }
 
    @Override
    public PaymentResult process(PaymentRequest request) {
        // Call MoMo API
        // Handle MoMo-specific flow
        return PaymentResult.success(transactionId);
    }
}
// Service sử dụng Spring DI để tự động collect tất cả implementations
@Service
public class PaymentService {
 
    private final Map<PaymentMethod, PaymentProcessor> processorMap;
 
    // Spring tự inject tất cả beans implement PaymentProcessor
    public PaymentService(List<PaymentProcessor> processors) {
        this.processorMap = processors.stream()
                .collect(Collectors.toMap(
                        PaymentProcessor::getSupportedMethod,
                        Function.identity()
                ));
    }
 
    public PaymentResult processPayment(PaymentRequest request) {
        PaymentProcessor processor = processorMap.get(request.getMethod());
        if (processor == null) {
            throw new UnsupportedOperationException(
                    "No processor found for: " + request.getMethod());
        }
        return processor.process(request);
    }
}

# thêm ZaloPay? Chỉ cần tạo file mới

// Tạo file mới, KHÔNG sửa logic nào đã có (chỉ thêm hằng ZALOPAY vào enum PaymentMethod)
@Component
public class ZaloPayPaymentProcessor implements PaymentProcessor {
 
    @Override
    public PaymentMethod getSupportedMethod() {
        return PaymentMethod.ZALOPAY;
    }
 
    @Override
    public PaymentResult process(PaymentRequest request) {
        // ZaloPay-specific logic
        return PaymentResult.success(transactionId);
    }
}
// Xong. PaymentService tự động nhận ZaloPay mà không cần sửa gì.

# điểm mấu chốt

Spring Boot hợp với OCP cực kỳ. Nhờ dependency injection, bạn có thể:

  • Thêm implementation mới chỉ bằng một @Component mới
  • Thêm payment method thì PaymentService không cần sửa gì
  • Mỗi processor test riêng được, review riêng được

Nói cho đủ thì vẫn có một chỗ phải sửa: hằng ZALOPAY trong enum PaymentMethod. Muốn "không đụng file cũ" tuyệt đối thì cho getSupportedMethod() trả về String hoặc một kiểu không đóng, nhưng enum giúp bắt lỗi gõ sai lúc compile nên đa số team chấp nhận sửa enum. Thêm nữa, Collectors.toMap sẽ ném IllegalStateException nếu hai processor khai báo cùng một method. Lỗi này nổ ngay lúc khởi động app (khi Spring tạo PaymentService), tức là fail sớm, tốt hơn để lọt lên production.


# l: liskov substitution principle

Objects of a superclass should be replaceable with objects of its subclasses without breaking the application. — cách diễn đạt phổ biến của nguyên tắc do Barbara Liskov đưa ra (1987)

Đây là nguyên tắc hay bị hiểu sai nhất. Nhiều người nghĩ LSP chỉ là "subclass phải override đúng method". Thật ra LSP nói về behavioral compatibility: subclass phải giữ đúng contract mà parent class đã đặt ra.

# ví dụ kinh điển: hình chữ nhật và hình vuông

Trước khi vào Spring Boot, xem ví dụ quen thuộc này cho dễ hiểu:

// Tưởng đúng nhưng SAI
public class Rectangle {
    protected int width;
    protected int height;
 
    public void setWidth(int width) { this.width = width; }
    public void setHeight(int height) { this.height = height; }
    public int getArea() { return width * height; }
}
 
public class Square extends Rectangle {
    // "Hình vuông là hình chữ nhật đặc biệt" — đúng trong toán, SAI trong code
    @Override
    public void setWidth(int width) {
        this.width = width;
        this.height = width; // Buộc height = width
    }
 
    @Override
    public void setHeight(int height) {
        this.width = height; // Buộc width = height
        this.height = height;
    }
}
 
// Code client — hoạt động đúng với Rectangle, SAI với Square
public void resize(Rectangle rect) {
    rect.setWidth(5);
    rect.setHeight(10);
    assert rect.getArea() == 50; // FAIL nếu rect là Square! Area = 100
}

# vi phạm LSP trong Spring Boot

public interface NotificationSender {
    /**
     * Gửi notification đến user.
     * Contract: luôn gửi được, throw exception nếu có lỗi kỹ thuật.
     */
    void send(String userId, String message);
}
 
@Component
public class EmailNotificationSender implements NotificationSender {
 
    @Override
    public void send(String userId, String message) {
        // Gửi email — OK, đúng contract
        emailClient.send(userId, message);
    }
}
 
@Component
public class SmsNotificationSender implements NotificationSender {
 
    @Override
    public void send(String userId, String message) {
        // Vi phạm LSP: thêm điều kiện mà contract không đề cập
        if (message.length() > 160) {
            throw new IllegalArgumentException("SMS cannot exceed 160 chars");
            // Client code không expect exception này!
        }
        smsClient.send(userId, message);
    }
}
 
@Component
public class PushNotificationSender implements NotificationSender {
 
    @Override
    public void send(String userId, String message) {
        // Vi phạm LSP: âm thầm không làm gì
        if (!userHasApp(userId)) {
            return; // Swallow silently — caller nghĩ đã gửi thành công!
        }
        pushClient.send(userId, message);
    }
}

# áp dụng LSP đúng cách

public interface NotificationSender {
 
    /**
     * Kiểm tra xem sender này có thể gửi cho user này không.
     * Caller PHẢI gọi method này trước khi gọi send().
     */
    boolean canSend(String userId, String message);
 
    /**
     * Gửi notification. Chỉ gọi khi canSend() trả về true.
     * @throws NotificationException nếu có lỗi kỹ thuật (network, server down)
     */
    void send(String userId, String message) throws NotificationException;
}
 
@Component
public class SmsNotificationSender implements NotificationSender {
 
    @Override
    public boolean canSend(String userId, String message) {
        // Rõ ràng: SMS có giới hạn, caller biết trước
        return message.length() <= 160 && phoneNumberExists(userId);
    }
 
    @Override
    public void send(String userId, String message) throws NotificationException {
        // Nếu đã qua canSend(), đảm bảo gửi được
        try {
            smsClient.send(userId, message);
        } catch (SmsApiException e) {
            throw new NotificationException("SMS sending failed", e);
        }
    }
}
 
@Component
public class PushNotificationSender implements NotificationSender {
 
    @Override
    public boolean canSend(String userId, String message) {
        return userHasApp(userId) && deviceTokenExists(userId);
    }
 
    @Override
    public void send(String userId, String message) throws NotificationException {
        try {
            pushClient.send(userId, message);
        } catch (PushApiException e) {
            throw new NotificationException("Push sending failed", e);
        }
    }
}
 
// Service sử dụng — hoạt động đúng với BẤT KỲ implementation nào
@Slf4j
@Service
@RequiredArgsConstructor
public class NotificationService {
 
    private final List<NotificationSender> senders;
 
    public void notifyUser(String userId, String message) {
        List<NotificationSender> availableSenders = senders.stream()
                .filter(s -> s.canSend(userId, message))
                .toList();
 
        if (availableSenders.isEmpty()) {
            throw new NoAvailableSenderException(userId);
        }
 
        for (NotificationSender sender : availableSenders) {
            try {
                sender.send(userId, message);
                return; // Gửi thành công qua channel đầu tiên
            } catch (NotificationException e) {
                log.warn("Failed to send via {}: {}", sender.getClass().getSimpleName(), e.getMessage());
            }
        }
        throw new AllSendersFailedException(userId);
    }
}

# quy tắc vàng của LSP

  1. Preconditions: subclass không được đòi hỏi nhiều hơn parent (không tự thêm validation mà contract không có)
  2. Postconditions: subclass không được trả về ít hơn parent (không nuốt mất kết quả)
  3. Invariants: subclass phải giữ nguyên các bất biến của parent
  4. Exception behavior: subclass chỉ throw những loại exception mà parent đã khai báo (hoặc subtype của chúng)

Cách sửa ở trên đưa giới hạn của SMS vào contract qua canSend(), nên caller biết trước thay vì bị bất ngờ. Đổi lại, nó tạo ra kiểu "temporal coupling": caller phải nhớ gọi canSend() trước send(), và giữa hai lần gọi trạng thái vẫn có thể đổi (user vừa gỡ app chẳng hạn). Vì vậy send() vẫn phải ném NotificationException khi thất bại chứ không được im lặng. Một chi tiết nữa cho người viết app tiếng Việt: 160 ký tự là giới hạn của SMS mã hoá GSM-7. Tin có dấu tiếng Việt phải gửi bằng UCS-2, mỗi tin chỉ còn 70 ký tự, nên điều kiện message.length() <= 160 thực tế cần tính theo encoding.


# i: interface segregation principle

No client should be forced to depend on methods it does not use. — Robert C. Martin

Giả sử có interface UserService với 20 method. Controller chỉ cần findById() và findAll(), vậy mà vẫn phải depend vào cả deleteUser(), exportToExcel(), syncToLdap(). syncToLdap() đổi signature là mọi class implement interface đó phải sửa theo, mọi chỗ mock nó trong test cũng có thể phải sửa, và module của controller vẫn bị kéo vào vòng build lại, dù controller chẳng hề dùng method đó.

# vi phạm ISP: "fat interface"

// Interface "béo phì" — ép mọi implementation phải implement tất cả
public interface UserRepository {
    User findById(Long id);
    List<User> findAll();
    User save(User user);
    void delete(Long id);
    List<User> findByDepartment(String dept);
    void exportToCsv(OutputStream out);
    void importFromCsv(InputStream in);
    UserStatistics calculateStatistics();
    void syncToExternalSystem(String systemId);
    List<User> searchFullText(String query);
}
 
// Admin module cần tất cả — OK
@Repository
public class AdminUserRepository implements UserRepository {
    // Implement tất cả 10 methods — hợp lý
}
 
// Public API chỉ cần đọc — nhưng bị ép implement hết
@Repository
public class PublicUserRepository implements UserRepository {
 
    @Override
    public void delete(Long id) {
        throw new UnsupportedOperationException("Public API cannot delete users");
        // Vi phạm cả LSP!
    }
 
    @Override
    public void syncToExternalSystem(String systemId) {
        throw new UnsupportedOperationException("Not supported");
        // Vô nghĩa, nhưng bắt buộc phải có
    }
 
    // ... nhiều method throw UnsupportedOperationException
}

# áp dụng ISP: tách interface theo role

// Interface cho việc đọc data
public interface UserReader {
    User findById(Long id);
    List<User> findAll();
    List<User> findByDepartment(String dept);
}
 
// Interface cho việc ghi data
public interface UserWriter {
    User save(User user);
    void delete(Long id);
}
 
// Interface cho search
public interface UserSearchable {
    List<User> searchFullText(String query);
}
 
// Interface cho import/export
public interface UserDataTransfer {
    void exportToCsv(OutputStream out);
    void importFromCsv(InputStream in);
}
 
// Interface cho external sync
public interface UserExternalSync {
    void syncToExternalSystem(String systemId);
}
 
// Admin repository — implement những gì cần
@Repository
public class AdminUserRepository implements UserReader, UserWriter,
        UserSearchable, UserDataTransfer, UserExternalSync {
    // Implement tất cả — hợp lý vì admin cần tất cả
}
 
// Public API — chỉ implement đọc và search
@Repository
public class PublicUserRepository implements UserReader, UserSearchable {
    // Chỉ implement 4 methods cần thiết
    // Không có method thừa, không có UnsupportedOperationException
}

# áp dụng ISP trong Spring Boot Controller

// Controller cho public API — chỉ inject những gì cần
@RestController
@RequestMapping("/api/public/users")
public class PublicUserController {
 
    // Chỉ depend vào UserReader, không biết gì về delete, sync, export
    private final UserReader userReader;
    private final UserSearchable userSearch;
 
    // Có 2 bean cùng implement UserReader/UserSearchable nên phải chỉ rõ bean nào.
    // Viết constructor tay vì @Qualifier trên field không được Lombok chép sang constructor.
    public PublicUserController(
            @Qualifier("publicUserRepository") UserReader userReader,
            @Qualifier("publicUserRepository") UserSearchable userSearch) {
        this.userReader = userReader;
        this.userSearch = userSearch;
    }
 
    @GetMapping("/{id}")
    public ResponseEntity<UserDto> getUser(@PathVariable Long id) {
        return ResponseEntity.ok(toDto(userReader.findById(id)));
    }
 
    @GetMapping("/search")
    public ResponseEntity<List<UserDto>> search(@RequestParam String q) {
        return ResponseEntity.ok(
                userSearch.searchFullText(q).stream().map(this::toDto).toList()
        );
    }
}
 
// Controller cho admin — inject thêm write capabilities
@RestController
@RequestMapping("/api/admin/users")
public class AdminUserController {
 
    private final UserReader userReader;
    private final UserWriter userWriter;
    private final UserDataTransfer dataTransfer;
 
    public AdminUserController(
            @Qualifier("adminUserRepository") UserReader userReader,
            UserWriter userWriter,          // chỉ có 1 bean, không cần qualifier
            UserDataTransfer dataTransfer) {
        this.userReader = userReader;
        this.userWriter = userWriter;
        this.dataTransfer = dataTransfer;
    }
 
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
        userWriter.delete(id);
        return ResponseEntity.noContent().build();
    }
 
    @GetMapping("/export")
    public void exportCsv(HttpServletResponse response) throws IOException {
        response.setContentType("text/csv");
        dataTransfer.exportToCsv(response.getOutputStream());
    }
}

# ISP + Spring Boot

Với Spring Boot, làm ISP khá tự nhiên:

  • Một class implement được nhiều interface
  • Spring inject theo type, nên controller chỉ thấy đúng interface nó cần
  • Lúc test chỉ cần mock interface nhỏ, khỏi phải mock 20 method

Cái giá phải trả là chuyện chọn bean. Khi hai class cùng implement UserReader, inject UserReader trơn sẽ làm app không khởi động được (NoUniqueBeanDefinitionException). Vì vậy ví dụ trên phải dùng @Qualifier; cách khác là đánh @Primary cho bean mặc định, hoặc tách hẳn interface theo ngữ cảnh (PublicUserReader, AdminUserReader) nếu hai bên thật sự khác nhau.


# d: dependency inversion principle

High-level modules should not depend on low-level modules. Both should depend on abstractions. — Robert C. Martin

# vấn đề thực tế

OrderService gọi thẳng MySqlOrderRepository. Một hôm team quyết chuyển qua MongoDB. Thế là bạn phải sửa OrderService, một high-level module chứa business logic, chỉ vì tầng infrastructure thay đổi. Phụ thuộc như vậy là ngược hướng.

# hiểu đúng DIP

DIP có 2 phần:

  1. High-level module (business logic) không depend vào low-level module (database, API, file system)
  2. Cả hai cùng depend vào abstraction (interface)

Chiều phụ thuộc phải đảo lại: business logic không phụ thuộc database nữa, mà database phải theo interface do business logic định nghĩa.

# vi phạm DIP: coupling trực tiếp

// High-level module phụ thuộc trực tiếp vào low-level module
@Service
public class ReportService {
 
    // Depend trực tiếp vào MySQL implementation
    private final MySqlReportRepository mysqlRepo;
 
    // Depend trực tiếp vào cách gửi email cụ thể
    private final SmtpEmailClient smtpClient;
 
    // Depend trực tiếp vào AWS S3
    private final AmazonS3Client s3Client;
 
    public ReportService(MySqlReportRepository mysqlRepo,
                         SmtpEmailClient smtpClient,
                         AmazonS3Client s3Client) {
        this.mysqlRepo = mysqlRepo;
        this.smtpClient = smtpClient;
        this.s3Client = s3Client;
    }
 
    public void generateAndSendReport(Long reportId) {
        // Business logic bị trộn lẫn với infrastructure details
        Report report = mysqlRepo.findById(reportId); // MySQL-specific
        byte[] pdf = generatePdf(report);
 
        // Upload to S3 — nếu đổi sang GCS phải sửa class này
        s3Client.putObject("reports-bucket", report.getName() + ".pdf",
                new ByteArrayInputStream(pdf), new ObjectMetadata());
 
        // Send email — nếu đổi sang SendGrid phải sửa class này
        MimeMessage message = smtpClient.createMimeMessage();
        // ... 20 dòng SMTP-specific code
        smtpClient.send(message);
    }
}

# áp dụng DIP: depend vào abstraction

// Abstractions — định nghĩa ở tầng domain/business
public interface ReportRepository {
    Report findById(Long id);
    Report save(Report report);
}
 
public interface FileStorage {
    String upload(String path, byte[] content);
    byte[] download(String path);
}
 
public interface NotificationSender {
    void send(String recipient, String subject, String body, byte[] attachment);
}
 
// High-level module — chỉ biết abstractions
@Service
@RequiredArgsConstructor
public class ReportService {
 
    private final ReportRepository reportRepository;  // Không biết là MySQL hay MongoDB
    private final FileStorage fileStorage;             // Không biết là S3 hay GCS
    private final NotificationSender notificationSender; // Không biết là SMTP hay SendGrid
 
    public void generateAndSendReport(Long reportId) {
        Report report = reportRepository.findById(reportId);
        byte[] pdf = generatePdf(report);
 
        String fileUrl = fileStorage.upload(
                "reports/" + report.getName() + ".pdf", pdf);
 
        notificationSender.send(
                report.getOwnerEmail(),
                "Report Ready: " + report.getName(),
                "Your report is available at: " + fileUrl,
                pdf
        );
    }
}
// Low-level modules — implement abstractions, có thể swap tự do
 
@Repository
public class JpaReportRepository implements ReportRepository {
 
    @Autowired
    private SpringDataReportRepository springDataRepo;
 
    @Override
    public Report findById(Long id) {
        return springDataRepo.findById(id)
                .orElseThrow(() -> new ReportNotFoundException(id));
    }
 
    @Override
    public Report save(Report report) {
        return springDataRepo.save(report);
    }
}
 
@Component
@Profile("aws")
public class S3FileStorage implements FileStorage {
 
    @Autowired
    private AmazonS3Client s3Client;
 
    @Value("${storage.bucket}")
    private String bucket;
 
    @Override
    public String upload(String path, byte[] content) {
        s3Client.putObject(bucket, path,
                new ByteArrayInputStream(content), new ObjectMetadata());
        return s3Client.getUrl(bucket, path).toString();
    }
 
    @Override
    public byte[] download(String path) {
        try (S3Object object = s3Client.getObject(bucket, path)) {
            return object.getObjectContent().readAllBytes();
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }
}
 
@Component
@Profile("gcp")
public class GcsFileStorage implements FileStorage {
 
    @Autowired
    private Storage gcsStorage;
 
    @Value("${storage.bucket}")
    private String bucket;
 
    @Override
    public String upload(String path, byte[] content) {
        BlobInfo blobInfo = BlobInfo.newBuilder(bucket, path).build();
        gcsStorage.create(blobInfo, content);
        return String.format("https://storage.googleapis.com/%s/%s", bucket, path);
    }
 
    @Override
    public byte[] download(String path) {
        return gcsStorage.readAllBytes(bucket, path);
    }
}

# DIP + Spring Profiles

# application-aws.yml
storage:
  bucket: my-reports-bucket
 
# application-gcp.yml
storage:
  bucket: my-reports-gcs-bucket
# Chọn profile lúc chạy, không ghi trong file profile
java -jar app.jar --spring.profiles.active=aws
# hoặc: SPRING_PROFILES_ACTIVE=gcp java -jar app.jar

Lưu ý: từ Spring Boot 2.4, khai báo spring.profiles.active bên trong file theo profile (application-aws.yml) là không hợp lệ, app báo InvalidConfigDataPropertyException khi khởi động. Profile được bật từ ngoài vào: tham số dòng lệnh, biến môi trường, hoặc application.yml chung.

Chuyển từ AWS sang GCP? Đổi profile là xong, business logic không sửa dòng nào. Ví dụ S3 ở trên dùng AWS SDK for Java v1 (AmazonS3Client) cho ngắn gọn; bản v1 đã hết hỗ trợ từ 31/12/2025, code mới nên dùng SDK v2 (software.amazon.awssdk.services.s3.S3Client). Nhờ DIP, việc nâng cấp này cũng chỉ nằm trong S3FileStorage.

# DIP trong Testing

@ExtendWith(MockitoExtension.class)
class ReportServiceTest {
 
    @Mock
    private ReportRepository reportRepository;
 
    @Mock
    private FileStorage fileStorage;
 
    @Mock
    private NotificationSender notificationSender;
 
    @InjectMocks
    private ReportService reportService;
 
    @Test
    void shouldGenerateAndSendReport() {
        // Arrange
        Report report = new Report(1L, "Q4 Report", "owner@example.com");
        when(reportRepository.findById(1L)).thenReturn(report);
        when(fileStorage.upload(anyString(), any())).thenReturn("https://example.com/report.pdf");
 
        // Act
        reportService.generateAndSendReport(1L);
 
        // Assert
        verify(fileStorage).upload(contains("Q4 Report"), any());
        verify(notificationSender).send(
                eq("owner@example.com"),
                contains("Q4 Report"),
                contains("https://example.com/report.pdf"),
                any()
        );
    }
}

Test business logic mà không cần database, không cần S3, không cần email server. Chạy mất vài mili giây.


# SOLID ngoài đời: khi nào KHÔNG nên áp dụng

Phần này nhiều bài hay bỏ qua. SOLID là guideline chứ không phải luật. Áp dụng máy móc nhiều khi hại hơn lợi.

# over-engineering cho code đơn giản

// ĐỪNG làm thế này cho một util method đơn giản
public interface StringFormatter { ... }
public interface StringValidator { ... }
public interface StringTransformer { ... }
public class UpperCaseStringFormatter implements StringFormatter { ... }
public class DefaultStringValidator implements StringValidator { ... }
 
// Chỉ cần thế này
public class StringUtils {
    public static String formatName(String name) {
        return name.trim().toUpperCase();
    }
}

# premature abstraction

Chỉ có một implementation thì đừng vội tạo interface. Tạo khi thật sự cần polymorphism, hoặc khi cần mock trong test.

// Nếu chỉ có MySQL và không có kế hoạch đổi — YAGNI
// Đừng tạo interface chỉ vì "SOLID nói phải có interface"
 
// Nhưng NÊN tạo interface khi:
// - Có 2+ implementations (payment processors)
// - Cần mock trong unit test (external API calls)
// - Team đã biết sẽ có thêm implementation (notification channels)

# quy tắc "Rule of Three"

Quy tắc này Martin Fowler ghi lại trong cuốn Refactoring và ghi công cho Don Roberts:

Lần đầu: cứ viết thẳng. Lần hai: thấy trùng lặp, tạm chấp nhận. Lần ba: refactor, áp dụng SOLID.

# context matters

  • Startup MVP: ship trước, refactor sau. Đừng over-engineer.
  • Enterprise system: theo SOLID từ đầu, technical debt ở đây trả giá đắt lắm.
  • Microservice nhỏ: service 200 dòng thì không cần tới 15 interface.
  • Shared library: SOLID rất quan trọng vì nhiều team cùng depend vào.

# kết luận

SOLID không phải checklist lúc nào cũng phải tick đủ 100%. Nó là cách nghĩ để viết code tốt hơn. Có lúc phá một nguyên tắc cũng được, miễn trong tình huống đó code gọn hơn. Còn nếu thấy mình cứ thêm feature là phải sửa cùng một file, hoặc test phải mock quá trời thứ, thì đó là dấu hiệu đang vi phạm SOLID và nên refactor.

Khi bạn thấy...Có thể vi phạm...Hỏi...
Class > 300 dòngSRP"Class này có bao nhiêu lý do để thay đổi?"
Switch/if-else trên typeOCP"Thêm type mới có phải sửa file này không?"
instanceof checksLSP"Subclass có giữ đúng contract không?"
UnsupportedOperationExceptionISP (+ LSP)"Interface có ép implement method thừa không?"
new ConcreteClass() trong serviceDIP"High-level module có depend vào low-level không?"

# vậy nên:

  1. Bắt đầu đơn giản, code phức tạp dần thì refactor
  2. SRP và DIP là hai nguyên tắc tác động lớn nhất, nên ưu tiên trước
  3. Code review là chỗ tốt nhất để bắt lỗi vi phạm SOLID
  4. Đừng cứng nhắc: SOLID sinh ra để phục vụ bạn, không phải ngược lại
  5. Spring Boot có sẵn những thứ giúp làm SOLID khá nhẹ nhàng: DI, Profiles và component scanning, cứ tận dụng

The goal of software architecture is to minimize the human resources required to build and maintain the required system. — Robert C. Martin, Clean Architecture

Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!

Happy coding 😎 👍🏻 🚀 🔥.