solid principles
Tung Bui Thanh (TungDaDev)23 mins read

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ái | Nguyên tắc | Một câu tóm tắt |
|---|---|---|
| S | Single Responsibility | Một class chỉ có một lý do để thay đổi |
| O | Open/Closed | Mở để mở rộng, đóng để sửa đổi |
| L | Liskov Substitution | Subclass phải thay thế được parent class |
| I | Interface Segregation | Nhiều interface nhỏ tốt hơn một interface lớn |
| D | Dependency Inversion | Phụ 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:
OrderPricingServicedù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
@Componentmới - Thêm payment method thì
PaymentServicekhô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
- Preconditions: subclass không được đòi hỏi nhiều hơn parent (không tự thêm validation mà contract không có)
- Postconditions: subclass không được trả về ít hơn parent (không nuốt mất kết quả)
- Invariants: subclass phải giữ nguyên các bất biến của parent
- 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:
- High-level module (business logic) không depend vào low-level module (database, API, file system)
- 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.jarLư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òng | SRP | "Class này có bao nhiêu lý do để thay đổi?" |
| Switch/if-else trên type | OCP | "Thêm type mới có phải sửa file này không?" |
instanceof checks | LSP | "Subclass có giữ đúng contract không?" |
UnsupportedOperationException | ISP (+ LSP) | "Interface có ép implement method thừa không?" |
new ConcreteClass() trong service | DIP | "High-level module có depend vào low-level không?" |
# vậy nên:
- Bắt đầu đơn giản, code phức tạp dần thì refactor
- SRP và DIP là hai nguyên tắc tác động lớn nhất, nên ưu tiên trước
- Code review là chỗ tốt nhất để bắt lỗi vi phạm SOLID
- Đừng cứng nhắc: SOLID sinh ra để phục vụ bạn, không phải ngược lại
- 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 😎 👍🏻 🚀 🔥.