OOP và SOLID: refactor PaymentService/OrderService từ rối đến dễ mở rộng
SOLID trong lập trình là gì?
SOLID là 5 nguyên tắc thiết kế trong OOP. Mục tiêu: code dễ bảo trì, dễ mở rộng, ít phụ thuộc và dễ test. Thay vì học thuộc định nghĩa, mình sẽ lấy một codebase e-commerce (đặt hàng + thanh toán) và refactor dần qua cả bài này lẫn bài 1.2. Phần này đi qua nền tảng OOP và chữ S.
1. Bài toán: một project e-commerce đang "chạy được"
Project shop-backend có một class lo hết việc đặt hàng:
public class OrderService {
public void placeOrder(OrderRequest req) {
// 1. validate + tính tiền
if (req.items().isEmpty()) throw new IllegalArgumentException("Giỏ hàng trống");
BigDecimal total = req.items().stream()
.map(i -> i.price().multiply(BigDecimal.valueOf(i.qty())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
// 2. thanh toán theo loại
if (req.paymentType().equals("VNPAY")) { /* gọi API VNPay */ }
else if (req.paymentType().equals("MOMO")) { /* gọi API Momo */ }
// 3. lưu DB
MySqlOrderRepository repo = new MySqlOrderRepository();
repo.save(new Order(req.customerId(), total));
// 4. gửi email xác nhận
SmtpMailer mailer = new SmtpMailer();
mailer.send(req.email(), "Đặt hàng thành công");
}
}
Chạy đúng. Demo ổn. Nhưng sau 6 tháng, file này dài 1500 dòng.
2. Vấn đề
- Đổi rule tính tiền, đổi cổng thanh toán, đổi DB, đổi nhà cung cấp email: 4 lý do khác nhau cùng sửa một file.
- Thêm ví ZaloPay là phải mở hàm đang chạy trên production ra sửa.
- Muốn unit test logic tính tiền thì phải dựng cả MySQL lẫn SMTP vì class tự
newchúng. - Dữ liệu như
total,statusnếu đểpublicthì chỗ nào cũng gán bừa được.
3. Giải pháp: 4 tính chất OOP + 5 nguyên tắc SOLID
Nền tảng là 4 tính chất OOP, lấy ví dụ ngay trên project:
| Tính chất | Hiểu đơn giản | Ví dụ trong shop-backend |
|---|---|---|
| Encapsulation | Giấu dữ liệu, chỉ cho đổi qua method có kiểm tra | Order.status private, chỉ đổi qua markPaid() |
| Inheritance | Class con dùng lại code class cha | Order extends BaseEntity (có id, createdAt) |
| Polymorphism | Cùng lời gọi, mỗi object chạy cách riêng | paymentMethod.pay() chạy VNPay hay Momo tùy object thật |
| Abstraction | Chỉ lộ "làm gì", giấu "làm thế nào" | interface OrderRepository, service không biết SQL bên dưới |
Còn SOLID là 5 nguyên tắc dựa trên 4 tính chất đó:
| Chữ | Nguyên tắc | Hiểu đơn giản |
|---|---|---|
| S | Single Responsibility Principle | Một class chỉ nên có một trách nhiệm chính |
| O | Open/Closed Principle | Có thể mở rộng mà hạn chế sửa code cũ |
| L | Liskov Substitution Principle | Class con thay được class cha mà chương trình vẫn đúng |
| I | Interface Segregation Principle | Nhiều interface nhỏ, chuyên biệt thay vì một interface quá lớn |
| D | Dependency Inversion Principle | Phụ thuộc vào abstraction, không phụ thuộc trực tiếp implementation |
4. Code từng bước: Encapsulation và SRP
Bước 1: Đóng gói Order. Đóng gói là để bảo vệ invariant, không chỉ là "viết getter/setter":
public class Order {
private final Long customerId;
private final BigDecimal total;
private OrderStatus status = OrderStatus.CREATED;
public Order(Long customerId, BigDecimal total) {
if (total.signum() <= 0) throw new IllegalArgumentException("total phải > 0");
this.customerId = customerId;
this.total = total;
}
public void markPaid() {
if (status != OrderStatus.CREATED) throw new IllegalStateException("Đơn đã xử lý");
status = OrderStatus.PAID;
}
public BigDecimal getTotal() { return total; }
public OrderStatus getStatus() { return status; }
// không có setStatus(): không ai đưa đơn từ CANCELLED về PAID được
}
Bước 2: Tách trách nhiệm (SRP). Định nghĩa chuẩn: một class chỉ nên có một lý do để thay đổi.
class PriceCalculator { // lý do đổi: rule tính tiền
BigDecimal total(List<OrderItem> items) { /* ... */ }
}
class OrderRepository { // lý do đổi: cách lưu
void save(Order order) { /* ... */ }
}
class NotificationService { // lý do đổi: kênh gửi
void sendOrderConfirmed(String email) { /* ... */ }
}
class OrderService { // lý do đổi: luồng đặt hàng
OrderService(PriceCalculator calc, OrderRepository repo, NotificationService noti) { ... }
Order placeOrder(OrderRequest req) {
Order order = new Order(req.customerId(), calc.total(req.items()));
repo.save(order);
noti.sendOrderConfirmed(req.email());
return order;
}
}
Bước 3: Test. Giờ test PriceCalculator chỉ cần new PriceCalculator().total(items), không DB, không SMTP.
5. Cách hoạt động
TRƯỚC SAU
┌──────────────────────────┐ ┌──────────────┐
│ OrderService (1500 dòng) │ │ OrderService │ (điều phối)
│ - tính tiền │ └──────┬───────┘
│ - if/else thanh toán │ ==> ┌─────┼──────────────┬───────────────┐
│ - new MySqlRepo │ ▼ ▼ ▼ ▼
│ - new SmtpMailer │ PriceCalc PaymentService OrderRepo NotificationService
└──────────────────────────┘ (S) (O, L: phần sau) (D) (S)
4 lý do thay đổi / 1 file mỗi class 1 lý do thay đổi
6. So sánh
| God class | Tách theo SRP | |
|---|---|---|
| Sửa email | Có thể làm hỏng logic đặt hàng | Chỉ chạm NotificationService |
| Unit test | Cần DB + SMTP thật | Test từng class độc lập |
| Làm việc nhóm | Conflict liên tục trên 1 file | Mỗi người một class |
| Số class | Ít | Nhiều hơn, mỗi class nhỏ |
Đây chính là lý do Spring chia @Controller / @Service / @Repository.
7. Khi nào nên dùng / không nên
- Nên: khi một class có nhiều lý do thay đổi, khi test khó vì phải dựng quá nhiều thứ.
- Không nên tách quá đà: mỗi method một class là sai theo hướng ngược lại. Script nhỏ, prototype thì không cần.
Cách nhớ nhanh: S = một nhiệm vụ, O = thêm mới hơn sửa cũ, L = con thay được cha, I = interface nhỏ gọn, D = phụ thuộc abstraction.
PaymentService vẫn là chuỗi if/else theo loại ví. Thêm ZaloPay lại phải sửa.Tiếp đi: Open/Closed và Liskov.
Giờ đi chữ O và L. Hai chữ này đi cùng nhau: O bảo "mở rộng bằng class mới", L bảo "class mới phải cư xử đúng".
1. Bài toán: PaymentService if/else theo loại ví
public class PaymentService {
public void pay(String type, BigDecimal amount) {
if (type.equals("VNPAY")) {
// gọi API VNPay
} else if (type.equals("MOMO")) {
// gọi API Momo
} else if (type.equals("CARD")) {
// gọi cổng thẻ
}
// thêm ZaloPay? lại sửa ở đây
}
}
2. Vấn đề
- Mỗi lần thêm loại phải mở class đã chạy ổn trên production, test lại toàn bộ, dễ làm hỏng nhánh cũ.
- Logic của mọi cổng nằm chung một hàm, càng ngày càng dài.
- Vi phạm Open/Closed: mở để mở rộng, đóng để sửa đổi.
3. Giải pháp: Strategy pattern + polymorphism
Đưa phần hay thay đổi ra sau một interface. Mỗi cổng một class. Service chỉ gọi interface, polymorphism chọn code đúng lúc runtime. Ví dụ cùng ý tưởng: tính giảm giá VIP/NORMAL bằng interface Discount, muốn thêm StudentDiscount thì tạo class mới.
4. Code từng bước
Bước 1: Interface
public interface PaymentMethod {
String type();
PaymentResult pay(Order order);
}
Bước 2: Mỗi cổng một bean
@Component
class VnPayPayment implements PaymentMethod {
public String type() { return "VNPAY"; }
public PaymentResult pay(Order order) { /* gọi VNPay */ return PaymentResult.ok(); }
}
@Component
class MomoPayment implements PaymentMethod {
public String type() { return "MOMO"; }
public PaymentResult pay(Order order) { /* gọi Momo */ return PaymentResult.ok(); }
}
Bước 3: Service không còn if/else
@Service
public class PaymentService {
private final Map<String, PaymentMethod> methods;
// Spring inject tất cả bean implement PaymentMethod
public PaymentService(List<PaymentMethod> list) {
this.methods = list.stream()
.collect(Collectors.toMap(PaymentMethod::type, m -> m));
}
public PaymentResult pay(String type, Order order) {
PaymentMethod m = methods.get(type);
if (m == null) throw new IllegalArgumentException("Không hỗ trợ: " + type);
return m.pay(order);
}
}
Thêm ZaloPay giờ chỉ là tạo ZaloPayPayment mới, PaymentService không sửa một dòng.
Bước 4: Liskov, khi class con "implement cho có". Giả sử có thêm thanh toán khi nhận hàng (COD) và interface có refund():
public interface PaymentMethod {
String type();
PaymentResult pay(Order order);
void refund(Order order);
}
class CodPayment implements PaymentMethod {
public String type() { return "COD"; }
public PaymentResult pay(Order order) { return PaymentResult.pending(); }
public void refund(Order order) {
throw new UnsupportedOperationException("COD không hoàn tiền online");
}
}
// code chỗ khác tin vào hợp đồng của PaymentMethod
void cancelOrder(Order order, PaymentMethod method) {
method.refund(order); // COD → nổ lúc runtime
}
LSP: chỗ nào dùng kiểu cha, đưa object con vào thì hành vi vẫn đúng như kỳ vọng (đúng "hợp đồng" của cha). Dấu hiệu hay gặp nhất: class con override rồi throw new UnsupportedOperationException(). Ví dụ kinh điển Rectangle/Square cũng vậy:
class Square extends Rectangle {
@Override void setWidth(int w) { width = w; height = w; }
@Override void setHeight(int h) { width = h; height = h; }
}
void resize(Rectangle r) {
r.setWidth(5);
r.setHeight(4);
assert r.area() == 20; // Square cho ra 16 → sai
}
Chính JDK cũng dính: List.of(...) ném UnsupportedOperationException khi add. Cách sửa: tách khả năng hoàn tiền thành interface riêng Refundable (xem lượt sau, chữ I), không ép mọi cổng phải có.
5. Cách hoạt động
PaymentService.pay("MOMO", order)
│
▼
methods (Map do Spring build lúc khởi động)
┌─────────┬───────────────┐
│ "VNPAY" │ VnPayPayment │
│ "MOMO" │ MomoPayment │ ◄── chọn
│ "ZALO" │ ZaloPayPayment│ ◄── thêm class mới, không sửa service
└─────────┴───────────────┘
│ polymorphism
▼
MomoPayment.pay(order)
6. So sánh
| if/else theo type | Strategy (OCP) | |
|---|---|---|
| Thêm cổng mới | Sửa hàm cũ | Thêm class mới |
| Rủi ro hỏng nhánh cũ | Cao | Thấp |
| Test | Test cả hàm lớn | Test từng class |
| Khi chỉ có 2 nhánh cố định | Đơn giản, đủ dùng | Hơi thừa |
7. Khi nào nên dùng / không nên
- Nên áp dụng OCP khi phần đó thật sự hay thêm loại mới (cổng thanh toán, loại giảm giá, kênh thông báo).
- Không nên tạo interface cho mọi thứ "phòng khi sau này cần" (YAGNI).
- Kế thừa phải là quan hệ "hành xử như", không chỉ "là một" về từ ngữ. Chỉ muốn tái dùng code thì ưu tiên composition over inheritance.
CodPayment ném lỗi vì interface bắt implement thứ nó không làm được. Và service vẫn còn tự new MySqlOrderRepository().Tiếp đi: Interface Segregation và Dependency Inversion.
Hai chữ cuối: I (interface nhỏ gọn) và D (phụ thuộc abstraction). Đây là hai chữ dính trực tiếp nhất tới Spring.
1. Bài toán
Sau khi gom chức năng, team viết một interface "cho tiện":
interface PaymentMethod {
PaymentResult pay(Order order);
void refund(Order order);
void cancel(Order order);
PaymentStatus checkStatus(String txId);
}
Còn OrderService thì:
class OrderService {
private final MySqlOrderRepository repo = new MySqlOrderRepository(); // gắn chặt MySQL
}
2. Vấn đề
- Fat interface: COD không refund, ví không cancel được, nhưng vẫn phải implement và ném lỗi (lại vi phạm LSP). Interface thêm method là mọi class phải sửa.
- Module cấp cao (logic đặt hàng) phụ thuộc chi tiết cấp thấp (MySQL). Chuyển PostgreSQL phải sửa service. Unit test không thay được repo giả.
3. Giải pháp
- ISP: không bắt client phụ thuộc vào method nó không dùng. Chia interface theo khả năng.
- DIP: cả module cấp cao và cấp thấp đều phụ thuộc vào abstraction. Service chỉ biết interface, implementation truyền từ ngoài vào.
4. Code từng bước
Bước 1: Chia nhỏ interface (ISP)
public interface PaymentMethod { String type(); PaymentResult pay(Order order); }
public interface Refundable { void refund(Order order); }
public interface Cancellable { void cancel(Order order); }
class CardPayment implements PaymentMethod, Refundable, Cancellable { /* đủ cả 3 */ }
class CodPayment implements PaymentMethod { /* chỉ pay, không phải giả vờ refund */ }
void cancelOrder(Order order, PaymentMethod method) {
if (method instanceof Refundable r) r.refund(order); // chỉ gọi khi thật sự hỗ trợ
}
Spring Data cũng thiết kế kiểu này: Repository → CrudRepository → PagingAndSortingRepository, bạn chọn đúng mức mình cần.
Bước 2: Đảo phụ thuộc (DIP)
public interface OrderRepository {
void save(Order order);
}
class MySqlOrderRepository implements OrderRepository { /* ... */ }
class PostgresOrderRepository implements OrderRepository { /* ... */ }
class OrderService {
private final OrderRepository repo;
OrderService(OrderRepository repo) { // nhận từ ngoài
this.repo = repo;
}
}
Bước 3: Ai truyền vào? Tự tay hoặc để Spring làm:
new OrderService(new MySqlOrderRepository());
new OrderService(new PostgresOrderRepository());
new OrderService(order -> {}); // test: repo giả bằng lambda, không cần DB
@Repository
class JpaOrderRepository implements OrderRepository { /* ... */ }
@Service
class OrderService {
private final OrderRepository repo;
OrderService(OrderRepository repo) { this.repo = repo; } // constructor injection
}
Bước 4: Test
@Test
void placeOrder_savesOrder() {
List<Order> saved = new ArrayList<>();
OrderService service = new OrderService(saved::add); // không Spring, không DB
service.placeOrder(sampleRequest());
assertEquals(1, saved.size());
}
5. Cách hoạt động
TRƯỚC (phụ thuộc đi xuống chi tiết) SAU (cả hai cùng trỏ vào abstraction)
OrderService OrderService ──────► «interface»
│ OrderRepository
▼ ▲
MySqlOrderRepository MySqlOrderRepository ──────┤
JpaOrderRepository ──────┘
"Inversion": trước service tự chọn class nào; giờ service chỉ khai báo cần gì,
bên ngoài (Spring IoC container) quyết định và inject vào.
6. So sánh
| Khái niệm | Là gì | Ví dụ |
|---|---|---|
| ISP | Nguyên tắc: interface nhỏ theo nhu cầu client | Refundable, Cancellable tách riêng |
| DIP | Nguyên tắc: phụ thuộc abstraction | OrderService biết OrderRepository |
| DI | Kỹ thuật truyền dependency từ ngoài vào | Constructor injection |
| IoC container | Framework tự tạo và nối object | Spring ApplicationContext |
7. Khi nào nên dùng / không nên
- Nên: interface cho ranh giới hay đổi hoặc cần mock (repository, gateway thanh toán, gửi mail).
- Không nên: interface cho class chỉ có đúng một implementation và không bao giờ cần thay (DTO, util). Interface quá nhỏ, mỗi method một interface cũng khó đọc.
private thì rõ, nhưng protected với default khác nhau chỗ nào?Theo quy định của Java, protected có phạm vi rộng hơn default (package-private) à?
Đúng. Trong Java, protected có phạm vi rộng hơn default (package-private).
| Modifier | Cùng class | Cùng package | Class con khác package | Bất kỳ đâu |
|---|---|---|---|---|
private | ✅ | ❌ | ❌ | ❌ |
| (default) | ✅ | ✅ | ❌ | ❌ |
protected | ✅ | ✅ | ✅ | ❌ |
public | ✅ | ✅ | ✅ | ✅ |
package com.shop.payment;
public class BasePayment {
void audit() {} // default
protected void doPay() {} // protected
}
package com.shop.payment.momo;
public class MomoPayment extends BasePayment {
void test() {
doPay(); // ✅ protected
audit(); // ❌ default: khác package
}
}
private < default < protected < public
Tên protected dễ gây nhầm. Nó nghĩa là "được bảo vệ khỏi truy cập công khai, nhưng vẫn cho class con dùng", chứ không phải "hạn chế hơn default". Còn default không phải keyword default; nó chỉ là: không viết modifier nào thì Java áp dụng package-private.
Cách nhớ: private → class, default → package, protected → package + inheritance, public → everywhere.
extends thì có hết đồ của cha rồi, cần protected làm gì?Vậy protected có ý nghĩa gì? Class con đã extends class cha thì đương nhiên được dùng method của cha rồi, cần protected làm gì?
Đây chính là chỗ dễ nhầm: extends không có nghĩa là class con được phép truy cập tất cả mọi thứ của class cha. extends tạo quan hệ kế thừa; truy cập được member hay không vẫn do access modifier quyết định.
package com.shop.payment;
public class PaymentService {
public void pay(Order order) { // API cho bên ngoài
validate(order);
processPayment(order);
}
protected void processPayment(Order order) {
// logic mặc định, class con được override
}
private void validate(Order order) {
// logic nội bộ, class con không được đụng
}
}
package com.shop.payment.paypal;
public class PaypalPaymentService extends PaymentService {
@Override
protected void processPayment(Order order) {
// custom cách thanh toán PayPal
}
}
Nếu để processPayment là public thì class con dùng được, nhưng mọi class khác cũng gọi được:
payment.pay(order); // ✅ muốn bên ngoài gọi
payment.processPayment(order); // ❌ không muốn: bỏ qua validate
public → ai cũng được gọi
protected → class này + class con mở rộng (+ cùng package)
default → các class cùng package
private → chỉ nội bộ class này
Nhớ: inheritance ≠ access permission. extends xác định quan hệ; access modifier xác định "được đụng vào cái gì". Khung "pay() cố định, processPayment() cho con override" này chính là Template Method, sẽ dùng lại ở bài 1.2.
Vậy DIP liên quan gì tới Dependency Injection của Spring? Hai cái có phải là một không?
Bạn hiểu gần đúng là hai cái liên quan, chỉ cần chỉnh một điểm: không phải một. DIP là nguyên tắc (nên thiết kế thế nào), DI là kỹ thuật (làm thế nào để truyền dependency), còn Spring là công cụ làm DI tự động.
Nguyên tắc Kỹ thuật Công cụ
DIP ──► Dependency Injection ──► Spring IoC container
"phụ thuộc "truyền từ ngoài vào" "tự new và inject"
abstraction"
@Repository
class JpaOrderRepository implements OrderRepository { /* ... */ }
@Service
class OrderService {
private final OrderRepository repo;
OrderService(OrderRepository repo) { // 1 constructor: không cần @Autowired
this.repo = repo;
}
}
Spring thấy JpaOrderRepository là bean duy nhất kiểu OrderRepository nên inject vào. Nhiều bean cùng kiểu thì dùng @Primary hoặc @Qualifier; cần tất cả thì inject List<PaymentMethod> như ví dụ OCP.
Ưu tiên constructor injection thay vì @Autowired trên field: field được final, không thể thiếu dependency, test chỉ cần new OrderService(mockRepo). Lưu ý: dùng DI mà service vẫn phụ thuộc class cụ thể (MySqlOrderRepository) thì vẫn có DI nhưng không có DIP.
Phỏng vấn hỏi SOLID, áp dụng ra sao trong dự án thì trả lời sao?
Đừng đọc định nghĩa 5 chữ rồi dừng. Mỗi chữ gắn một ví dụ mình từng làm.
Câu 1: Bạn hiểu SOLID thế nào, áp dụng ra sao?
🇻🇳 Tiếng Việt:
SOLID là 5 nguyên tắc giúp code OOP dễ bảo trì, mở rộng và test. SRP: em tách OrderService thành tính tiền, lưu trữ, gửi thông báo riêng. OCP: thay if/else theo loại thanh toán bằng interface
PaymentMethod, mỗi cổng một class, Spring inject cả list vào map, thêm cổng mới không sửa code cũ. LSP: class con phải giữ hợp đồng của cha; nếu phải némUnsupportedOperationExceptionlà thiết kế sai. ISP: em táchRefundable,Cancellablekhỏi interface thanh toán. DIP: service phụ thuộc interface repository, Spring inject qua constructor, nhờ vậy unit test bằng mock.
🇬🇧 English:
SOLID is five design principles that keep OO code maintainable, extensible and testable. For SRP I split a large OrderService into pricing, persistence and notification. For OCP I replaced an if/else on payment type with a
PaymentMethodinterface and one class per provider, injected as a list by Spring, so a new provider means a new class only. LSP means a subtype must honor the parent's contract; throwingUnsupportedOperationExceptionis a red flag. For ISP I moved refund and cancel into separate interfaces. For DIP my services depend on repository interfaces, injected through constructors, so I can unit test them with mocks.
Câu 2: DIP và DI khác nhau thế nào?
🇻🇳 Tiếng Việt:
DIP là nguyên tắc thiết kế: module cấp cao và cấp thấp đều phụ thuộc abstraction. DI là kỹ thuật truyền dependency từ ngoài vào, qua constructor hoặc setter. Spring IoC container thực hiện DI tự động. Có thể dùng DI mà vẫn vi phạm DIP nếu inject class cụ thể.
🇬🇧 English:
DIP is a design principle: both high-level and low-level modules depend on abstractions. DI is the technique of passing dependencies in from outside, usually via the constructor. Spring's IoC container does DI for you. You can use DI and still break DIP if you inject a concrete class.
Câu 3: protected khác default thế nào, khi nào dùng protected?
🇻🇳 Tiếng Việt:
Java có 4 mức: private chỉ trong class, default trong package, protected thêm quyền cho subclass ở package khác, public ở mọi nơi. Vậy protected rộng hơn default. Em dùng protected khi muốn một method không public ra ngoài nhưng vẫn cho subclass override để mở rộng, ví dụ
processPayment()trong template method.
🇬🇧 English:
Java has four levels: private is class-only, default is package-only, protected adds access for subclasses in other packages, and public is everywhere. So protected is wider than default. I use protected when a member should not be public but subclasses need to override it, like a
processPayment()hook in a template method.
Cách nhớ ngắn gọn
- S = một lý do thay đổi; O = thêm class mới, không sửa if/else; L = con không được ném
UnsupportedOperationException. - I = interface theo khả năng (
Refundable); D = phụ thuộc interface, Spring inject qua constructor. - Encapsulation = bảo vệ invariant, không phải chỉ getter/setter.
private < default < protected < public; inheritance ≠ access permission.
Tự kiểm tra
Encapsulation có phải chỉ là để field private rồi viết getter/setter?
Không. Mục đích là bảo vệ invariant: chỉ cho đổi trạng thái qua method có kiểm tra (ví dụ Order.markPaid() không cho trả lại đơn đã xử lý). Setter public cho mọi field thì gần như không đóng gói gì.
Chuỗi if/else theo type vi phạm nguyên tắc nào, sửa bằng pattern gì?
Open/Closed. Sửa bằng Strategy: một interface, mỗi loại một class, chọn implementation qua map hoặc Spring inject List.
Dấu hiệu dễ nhận nhất của vi phạm LSP?
Class con override method rồi ném UnsupportedOperationException hoặc làm trái kỳ vọng của cha (CodPayment.refund(), Square đổi cả hai cạnh khi setWidth).
Class con ở package khác gọi được method default của cha không?
Không. Default chỉ trong package. Muốn subclass khác package dùng được mà không public ra ngoài thì để protected.
Vì sao nên dùng constructor injection thay vì field injection?
Field final, dependency bắt buộc có khi tạo object, dễ test bằng new Service(mock) mà không cần Spring context.