Nhận diện abstraction sai — over-engineering trap và cách sửa
4 trap over-engineering kinh điển: interface 1 implementation, abstraction không use case, premature abstraction, abstraction giả tạo. Rule of 3, YAGNI, case study payment system minh hoạ abstraction đúng và Open/Closed Principle.
TL;DR: Abstraction mạnh, nhưng abstraction sai tệ hơn không có abstraction — nó khoá design vào dự đoán sai và biến code thành ceremony. Bài này dạy cách nhận diện 4 trap over-engineering: interface chỉ có 1 implementation, abstraction không ai dùng polymorphic, premature abstraction (abstract trước khi đủ thông tin), và abstraction giả tạo (caller vẫn hardcode concrete). Công cụ phòng thủ: YAGNI và rule of 3 — chờ 3 case thực tế mới abstract. Phần cuối là case study payment system: abstraction đúng trông như thế nào, và vì sao nó tự nhiên đạt Open/Closed Principle. Kèm 5 pitfall tổng hợp với code sai/đúng đối chiếu.
Bài 01 dạy bạn quy trình 5 bước để tạo abstraction. Bài này dạy kỹ năng ngược lại — và quan trọng không kém: nhận ra khi nào abstraction là thừa, sai, hoặc quá sớm.
Codebase legacy nào cũng có vài chục BaseFooService, AbstractBarHandler, IBazManager mà không một dòng code nào dùng chúng polymorphic. Mỗi abstraction thừa là một file thêm để đọc, một tầng indirection để nhảy qua khi debug — chi phí trả hàng ngày, lợi ích không bao giờ đến. Học cách nhận diện chúng sớm rẻ hơn nhiều so với refactor sau 2 năm.
1. Khi nào KHÔNG trừu tượng — 4 trap over-engineering
Abstraction mạnh, nhưng lạm dụng tệ hơn không dùng. Dấu hiệu over-engineering:
Trap 1 — Interface cho 1 implementation
interface UserRepository {
User findById(long id);
}
class UserRepositoryImpl implements UserRepository {
User findById(long id) { ... }
}
Interface UserRepository có chỉ 1 implementation UserRepositoryImpl. Abstraction thừa — chỉ tăng 1 file, không thêm giá trị.
Trừ khi: có test double (mock) — nhưng modern testing dùng Mockito mock class trực tiếp, không cần interface.
Rule: YAGNI (You Ain't Gonna Need It). Chỉ abstract khi có ≥2 impl thực tế hoặc chắc chắn cần thay trong tương lai gần.
Trap 2 — Abstraction không có use case
Code legacy đầy BaseFooService, AbstractBarHandler, IBazManager mà không có code thực sự polymorphic. Abstract được thêm "cho đúng pattern" — giờ là dead weight.
Rule: mỗi abstraction phải trả lời được "code nào dùng abstraction này thay vì concrete?". Không trả lời được thì xoá abstraction.
Trap 3 — Abstraction quá sớm (premature abstraction)
// Nghi "mot ngay nao do se co cac loai notification"
interface Notifier {
void send(Notification n);
}
class EmailNotifier implements Notifier {
void send(Notification n) { ... }
}
Hiện tại chỉ có email. SMS, push notification — tương lai có thể. Viết abstraction ngay — chi phí thêm class, nhưng chưa dùng polymorphism.
Tương lai khi thêm SMS, requirements có thể khác — abstraction hiện tại không phù hợp. Phải sửa abstraction + email impl + thêm SMS impl. Tốn hơn bắt đầu concrete + refactor khi thực sự cần.
Rule: rule of 3. Khi có 3 implementation (không phải 2) thường mới đủ rõ ràng để abstract.
Trap 4 — Abstraction không thay đổi được
Code dùng new EmailNotifier() trực tiếp trong 50 chỗ. "Abstraction" chỉ có trong class, nhưng caller hardcode nên không thể thay. Abstraction giả tạo — tên interface nhưng coupling cụ thể.
Fix: dependency injection (Spring @Autowired, constructor inject). Caller nhận Notifier, không new trực tiếp.
flowchart TD
A{Co bao nhieu implementation thuc te?} -->|1| B[Concrete class - YAGNI]
A -->|2| C{Chac chan se co impl thu 3?}
A -->|3 tro len| D[Abstract - pattern da ro]
C -->|Khong chac| B
C -->|Co| E{Co code nao dung no polymorphic khong?}
E -->|Khong| B
E -->|Co| D2. Case study — design "payment system"
Để thấy abstraction đúng trông như thế nào (đối chiếu với 4 trap trên), xét bài tập thực tế: design thanh toán với nhiều provider.
Phân tích
Thực thể cụ thể:
- Credit card Stripe
- Credit card PayPal
- E-wallet MoMo
- Bank transfer Vietcombank
- Crypto payment Bitcoin
Use case: user chọn 1 phương thức, charge số tiền, nhận kết quả success/fail.
Chú ý: ở đây có 5 implementation thực tế ngay từ đầu — vượt rule of 3, abstraction không hề premature.
Abstraction
Điểm chung: charge(amount) trả về result. Đó là core abstraction.
interface PaymentProvider {
PaymentResult charge(BigDecimal amount, PaymentContext context);
RefundResult refund(String transactionId);
String providerName();
}
record PaymentResult(boolean success, String transactionId, String errorCode) {}
Mỗi provider implement riêng:
class StripeProvider implements PaymentProvider {
public PaymentResult charge(BigDecimal amount, PaymentContext ctx) {
// HTTP call Stripe API
}
}
class MoMoProvider implements PaymentProvider { ... }
class CryptoProvider implements PaymentProvider { ... }
Code caller — không biết provider cụ thể
class CheckoutService {
private final Map<String, PaymentProvider> providers; // injected
public void pay(String providerId, BigDecimal amount) {
PaymentProvider p = providers.get(providerId);
PaymentResult r = p.charge(amount, buildContext());
if (r.success()) markOrderPaid(r.transactionId());
else handleFailure(r.errorCode());
}
}
Thêm provider Stripe, MoMo, Bitcoin — CheckoutService không đổi 1 dòng. Đây là power of abstraction.
Đối chiếu với 4 trap:
- Có 5 implementation thật (không phạm trap 1, trap 3).
CheckoutServicechính là code dùng abstraction polymorphic (không phạm trap 2).- Provider được inject qua
Map, khôngnewhardcode (không phạm trap 4).
Open/Closed Principle
Pattern trên là ví dụ của OCP (Open for extension, Closed for modification — SOLID):
- Open for extension: thêm
PaymentProvidermới bằng class mới. - Closed for modification:
CheckoutServicekhông sửa khi thêm provider.
Abstraction đúng thì OCP đến tự nhiên. Abstraction sai khiến mỗi lần thêm feature phải sửa code cũ — hệ thống fragile.
3. Pitfall tổng hợp
❌ Nhầm 1: Abstraction vô nghĩa cho 1 implementation.
interface UserRepository { User findById(long id); }
class UserRepositoryImpl implements UserRepository { ... } // Chi co 1 impl
✅ Concrete class trước. Abstract khi có nhu cầu thực sự.
❌ Nhầm 2: Abstraction quá sớm.
// Nghi "tuong lai se co nhieu loai"
interface Foo { ... }
class FooV1 implements Foo { ... }
✅ Rule of 3 — chờ đến khi có ≥2-3 impl thực tế.
❌ Nhầm 3: God abstraction — 1 interface chứa mọi method.
interface Document {
void print();
void scan();
void fax();
void email();
}
// Impl "BasicPrinter" phai throw UnsupportedOperationException cho scan/fax
✅ Interface Segregation: tách Printable, Scannable, Faxable, Emailable. Class implement đúng interface.
❌ Nhầm 4: Abstraction leak — method lộ chi tiết impl.
interface Cache {
Map<String, Object> getInternalMap(); // Lo Map - caller depend implementation
}
✅ Method chỉ expose behavior: get(key), put(key, value). Đổi từ Map sang Redis không break API.
❌ Nhầm 5: Abstraction không test được.
class Service {
EmailSender sender = new EmailSender(); // hardcoded
}
✅ Dependency injection: Service(EmailSender sender). Test inject mock.
4. 📚 Deep Dive
Sách:
- "Effective Java" - Joshua Bloch (Item 18-22) — interface vs abstract class, favor composition, design for inheritance.
- "Design Patterns" - GoF — 23 pattern đều là case study về abstraction.
Essay:
- "Yagni" - Martin Fowler — vì sao "presumptive feature" (làm trước cho tương lai) hầu như luôn lỗ.
- "Rule of Three" - Wikipedia — nguồn gốc rule từ Don Roberts / Martin Fowler (Refactoring).
Ghi chú: Khi viết class mới, tự hỏi: "abstraction tôi đang tạo là gì? Ngữ cảnh nào? Có ≥2 implementation không? Code nào sẽ dùng nó polymorphic?". Nếu lúng túng — xoá abstraction, viết concrete trước, abstract sau khi pattern rõ. IDE có "Extract Interface" — refactor sau chỉ mất vài phút, rẻ hơn nhiều so với sống chung abstraction sai.
5. Tóm tắt
- Abstraction sai tệ hơn không có abstraction — nó khoá design vào dự đoán sai, thêm indirection vô ích.
- 4 trap: (1) interface 1 implementation, (2) abstraction không ai dùng polymorphic, (3) premature abstraction, (4) abstraction giả tạo — caller vẫn
newconcrete. - YAGNI: không xây cho nhu cầu tưởng tượng. Rule of 3: 2 case có thể là trùng hợp, 3 case mới là pattern.
- Test một abstraction: "code nào dùng nó thay vì concrete?" — không trả lời được thì xoá.
- Abstraction giả tạo fix bằng dependency injection — caller nhận interface qua constructor, không tự
new. - Case study payment: 5 provider thực tế + caller polymorphic + injection = abstraction đúng, OCP tự nhiên.
- Refactor concrete thành abstraction khi cần (Extract Interface) rẻ hơn nhiều so với gỡ abstraction sai đã ăn sâu.
6. Tự kiểm tra
Q1Vì sao 'premature abstraction' tệ hơn 'không có abstraction'?▸
Premature abstraction = abstract trước khi có đủ thông tin.
Hệ quả:
- Abstraction dựa trên đoán: chỉ có 1 impl hiện tại. Abstract dựa trên "có thể trong tương lai cần X". Khi thực sự thêm impl 2, requirements thường khác với dự đoán — abstraction không còn phù hợp, bạn phải refactor.
- Chi phí upfront: thêm class/file, indirection, boilerplate. Không có benefit ngay.
- Coupling ngược: abstraction sai lock design, khiến hệ thống khó thay đổi. Concrete code dễ refactor hơn abstraction sai.
- Fake abstraction: 1 interface + 1 impl là "code ceremony" không có giá trị. Reader đọc 2 file thay 1, không thêm semantic.
Không abstract: khi cần thêm impl thứ 2, refactor concrete class thành abstraction. Nhiều IDE có tool "Extract Interface" — 2 phút xong. Lần này abstraction dựa trên 2 case thực tế, chính xác hơn.
Rule of 3 (Martin Fowler): không abstract cho đến khi có 3 case tương tự. 2 case có thể coincidence, 3 case là pattern.
Q2Interface chỉ có 1 implementation có luôn luôn là trap không? Khi nào nó hợp lý?▸
Đa số trường hợp là trap (trap 1): thêm 1 file, 1 tầng indirection, không thêm semantic. Lý do biện hộ phổ biến nhất — "để mock khi test" — đã yếu đi nhiều vì Mockito mock được class concrete trực tiếp.
Một số ngoại lệ hợp lý:
- Ranh giới module/layer: interface định nghĩa hợp đồng giữa 2 module biên dịch tách biệt (vd API module và impl module) — caller chỉ depend vào API jar.
- Implementation thứ 2 chắc chắn và gần: đã có trên roadmap sprint tới, không phải "một ngày nào đó".
- Hợp đồng public của library: bạn publish interface cho user ngoài implement (callback, SPI) — số impl nằm ngoài tầm kiểm soát của bạn.
Điểm chung của các ngoại lệ: interface tồn tại vì ranh giới (boundary) hoặc hợp đồng mở, không phải vì "cho đúng pattern". Nếu không rơi vào các case đó, dùng concrete class.
Q3God abstraction (1 interface chứa mọi method) vi phạm nguyên tắc nào, gây hại gì, và sửa thế nào?▸
Vi phạm Interface Segregation Principle (ISP) — chữ I trong SOLID: client không nên bị buộc phụ thuộc vào method nó không dùng.
Tác hại cụ thể:
- Implementation không hỗ trợ đủ phải ném
UnsupportedOperationException— hợp đồng nói dối, caller chỉ phát hiện ở runtime. - Caller chỉ cần in tài liệu vẫn phải "thấy"
scan(),fax()— API surface phình to, đổi method fax buộc recompile cả client chỉ dùng print.
Cách sửa: tách theo capability — Printable, Scannable, Faxable. Class nào có đủ năng lực thì implement nhiều interface (class MultiFunctionPrinter implements Printable, Scannable); class cơ bản chỉ implement một. Caller khai báo đúng capability nó cần — compiler thay runtime exception bắt lỗi hộ bạn.
Q4Ví dụ abstraction leak là gì, và cách tránh?▸
Abstraction leak = abstraction (interface/class) expose detail implementation bên dưới — caller coupled vào impl cụ thể.
Ví dụ tệ:
interface Cache {
HashMap<String, Object> getInternalMap(); // Lo HashMap
}Caller dùng getInternalMap() nên phụ thuộc thẳng vào HashMap. Đổi sang Redis/Caffeine là phải sửa tất cả caller.
Ví dụ tốt:
interface Cache {
Optional<Object> get(String key);
void put(String key, Object value);
void evict(String key);
}Chỉ expose behavior. Đổi HashMap sang ConcurrentHashMap hay Redis, caller không biết và không cần sửa.
Cách tránh:
- Method trả interface hoặc simple type, không trả implementation cụ thể.
- Không expose internal collection — dùng
Collections.unmodifiableListhoặc copy defensive. - Không method có tên kiểu
getInternalX,getRawY— signal leak intent. - Test abstraction: code 2 impl khác hẳn nhau (vd in-memory + Redis). Nếu test pass cả 2, abstraction clean. Nếu fail — đâu đó leak.
Joshua Bloch Item 15: "Minimize the accessibility of classes and members" — áp dụng cho abstraction: expose ít nhất có thể để tương tác.
Q5Trong case study payment, vì sao thêm provider mới không phải sửa CheckoutService? Điều kiện nào của abstraction làm được việc đó?▸
CheckoutService chỉ phụ thuộc vào hợp đồng PaymentProvider — nó gọi charge(...) mà không biết (và không cần biết) đứng sau là Stripe hay MoMo. Thêm BitcoinProvider là viết một class mới implement hợp đồng + đăng ký vào Map được inject — không dòng nào trong CheckoutService đổi. Đây chính là Open/Closed Principle: mở cho extension (class mới), đóng cho modification (code cũ).
3 điều kiện làm được:
- Hợp đồng đúng ngữ cảnh:
charge/refund/providerNamelà điểm chung thật của mọi provider (bài 01, bước 2-3) — không provider nào phải némUnsupportedOperationException. - Caller thực sự polymorphic:
CheckoutServicechỉ thao tác qua interface, khônginstanceoftừng loại. - Injection thay vì hardcode: provider được truyền vào qua constructor/Map — tránh trap 4 (abstraction giả tạo).
Thiếu bất kỳ điều kiện nào, "thêm provider" sẽ kéo theo sửa code cũ — và abstraction chỉ còn là hình thức.
Bài tiếp theo: extends và super — kế thừa từ class cha
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên