Nhóm smell này chung một bệnh sinh: một đơn vị code (hàm, struct, kiểu dữ liệu) ôm nhiều hơn mức nó nên ôm — cohesion giảm dần mà không ai để ý, vì mỗi lần chỉ thêm “một tí”.

Mỗi smell trình bày theo khung: ví dụ → nguyên nhân → hậu quả → refactoring phù hợp → khi nào KHÔNG phải smell.


A. Long Method

Ví dụ

// ❌ handler 150 dòng — rút gọn còn khung xương, bạn đã thấy nó ở mọi codebase
func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {
    // ...15 dòng parse + validate JSON...
    // ...10 dòng check tồn kho, gọi inventory service...
    // ...20 dòng tính giá: giảm giá theo hạng thành viên, mã coupon, phí ship...
    // ...15 dòng ghi DB trong transaction, xử lý rollback...
    // ...10 dòng gửi event Kafka, log, metric...
    // ...10 dòng build response...
}

Nguyên nhân

Không ai viết hàm 150 dòng — hàm 20 dòng lớn lên thành 150 qua hai năm, mỗi PR thêm 5-10 dòng “tiện thể nhét vào đây vì context sẵn rồi”. Lực hấp dẫn tự nhiên: thêm code vào hàm có sẵn luôn dễ hơn tạo cấu trúc mới. Long Method là default của entropy, không phải lỗi cá nhân.

Hậu quả

Đọc phải giữ toàn bộ 150 dòng trong đầu (biến khai báo dòng 12 được gán lại dòng 90, dùng dòng 140); test chỉ vào được từ cổng HTTP — muốn test riêng logic tính giá phải dựng cả request giả + DB + Kafka; hai dev sửa hai đoạn khác nhau của cùng hàm → conflict; và các đoạn logic không tái sử dụng được (muốn tính giá ở batch job → copy).

Refactoring phù hợp

Extract Method (3.3) theo nguyên tắc mỗi hàm một mức trừu tượng:

// ✅ hàm gốc trở thành MỤC LỤC — đọc 5 giây hiểu luồng
func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {
    req, err := parseCreateOrderRequest(r)
    if err != nil { respondError(w, http.StatusBadRequest, err); return }

    if err := h.inventory.Reserve(r.Context(), req.Items); err != nil {
        respondError(w, http.StatusConflict, err); return
    }
    quote := h.pricer.Quote(req.Items, req.CustomerID, req.CouponCode)
    o, err := h.orders.Create(r.Context(), req, quote)
    if err != nil { respondError(w, http.StatusInternalServerError, err); return }

    h.events.OrderCreated(r.Context(), o)
    respondJSON(w, http.StatusCreated, toOrderResponse(o))
}

Tín hiệu chỉ điểm chỗ cắt: comment “// bước 1: …” (mỗi comment phân đoạn là một tên hàm đang chờ được đặt), dòng trắng chia cụm, và biến chỉ dùng trong một cụm.

Khi nào KHÔNG phải smell

Đếm dòng là thước đo hạng hai; thước đo hạng nhất là số mức trừu tượng trộn lẫn. Hàm 60 dòng làm một việc ở một mức (bảng switch ánh xạ dài, hàm dựng SQL query phức tạp nhưng tuyến tính, table-driven test) — hoàn toàn ổn. Ngược lại hàm 15 dòng trộn parse + business + I/O vẫn cần mổ. Và đừng chẻ quá tay: 30 hàm 3 dòng gọi dây chuyền còn khó đọc hơn một hàm 40 dòng mạch lạc — chẻ đến khi mỗi tên hàm nói được một điều có nghĩa, rồi dừng.


B. Large Class & God Object

Ví dụ

// ❌ "class lớn" phiên bản Go: struct + 47 method trải trên 6 file
type OrderManager struct {
    db, redis, kafka, s3, mailer, sms, pdf, logger /* 8 dependency */
}
// order_manager.go:        CreateOrder, UpdateOrder, CancelOrder, ...
// order_manager_pricing.go: CalcDiscount, ApplyCoupon, EstimateShipping, ...
// order_manager_export.go:  ExportPDF, ExportExcel, GenerateInvoice, ...
// order_manager_notify.go:  SendConfirmation, SendReminder, ...
// order_manager_report.go:  DailyStats, RevenueByRegion, ...

God Object là thể nặng của Large Class: object mà mọi luồng nghiệp vụ đều phải đi qua nó, nó biết mọi bảng DB, giữ mọi dependency, và tên của nó thường kết thúc bằng Manager, Service, Processor, Handler, Util — những cái tên trống nghĩa vì nó làm quá nhiều thứ để đặt tên cụ thể.

Nguyên nhân

Ba lực cộng hưởng: (1) chi phí kích hoạt — tạo package/struct mới đòi nghĩ tên, nghĩ ranh giới, còn thêm method vào chỗ cũ thì miễn phí; (2) tổ chức code theo danh từ to (“mọi thứ liên quan order”) thay vì theo lý do thay đổi — cohesion chủ đề giả (chương 1.3); (3) DI thuận tay: struct đã có sẵn 8 dependency, thêm method mới dùng luôn, khỏi wiring.

Hậu quả

Là điểm hội tụ của mọi thay đổi → conflict git hằng tuần, không ai nắm hết, mọi PR đều đụng nó nên mọi PR đều rủi ro; test dựng cả 8 dependency cho method dùng 1; không tách được team (“phần của ai?” — của tất cả, tức là của không ai); và nó hút thêm code mới theo quán tính — God Object chỉ to lên, không bao giờ tự nhỏ đi.

Refactoring phù hợp

Extract Class (3.3), cắt theo cụm dependency × cụm lý do thay đổi — hai tín hiệu này thường trùng nhau và chỉ thẳng đường ranh:

OrderManager (47 methods, 8 deps)
    → order.Service      (Create/Update/Cancel — cần db, kafka)
    → pricing.Engine     (CalcDiscount/Coupon — cần db, redis)     ← đã gặp ở 2.5
    → invoice.Exporter   (PDF/Excel — cần s3, pdf)
    → notify.Service     (confirmation/reminder — cần mailer, sms)
    → orderreport.Query  (stats — cần db replica)

Chiến thuật an toàn: tách dần từng cụm — cụm export ít coupling nhất tách trước; OrderManager tạm giữ vai facade ủy quyền sang class mới (giữ caller cũ không vỡ), teo dần rồi xóa. Đúng nhịp “chuỗi bước nhỏ” của chương 3.0.

Khi nào KHÔNG phải smell

Package order gom nhiều file quanh một domain là functional cohesion tốt — đừng nhầm “package nhiều code” với God Object; tiêu chí là một struct/một điểm nghẽn chứ không phải tổng số dòng. Và ở app nhỏ 2.000 dòng, một Service struct 15 method là hoàn toàn lành mạnh — God Object là bệnh của quy mô.


C. Primitive Obsession

Ví dụ

// ❌ mọi thứ là string và int64
func Transfer(fromAccount string, toAccount string, amount int64, currency string) error
// Và hóa đơn của nó, trả dần ở mọi call site:
Transfer(toAcc, fromAcc, amt, "VND")        // đảo ngược from/to — compile ngon, tiền đi nhầm chiều
Transfer(from, to, amtUSD, "VND")           // số tiền USD dán nhãn VND
if email != "" && strings.Contains(email, "@") { }   // validation rải rác, mỗi nơi một kiểu

Nguyên nhân

Primitive luôn sẵn — string không cần khai báo type mới; và cảm giác “chỉ là cái email thôi, cần gì type riêng”. Nhưng mỗi primitive trần là một khái niệm nghiệp vụ chưa được đặt tên: “email đã validate”, “số tiền gắn liền currency”, “ID của account (khác ID của order!)”.

Hậu quả

Compiler — công cụ kiểm lỗi mạnh nhất, miễn phí nhất — bị vô hiệu hóa: mọi string hoán đổi được cho nhau. Validation lặp và lệch nhau khắp nơi (DRY vi phạm ở mức tri thức). Nghiệp vụ ngầm không có chỗ sống: tiền VND không có phần thập phân, so sánh hai Money khác currency phải là lỗi — logic này rải trong if lẻ tẻ hoặc tệ hơn, trong đầu dev.

Refactoring phù hợp

Replace Primitive with Value Object — đã gặp Money ở chương 1.5, giờ thành hệ thống:

// ✅ mỗi khái niệm một type — compiler thành người gác nghiệp vụ
type AccountID string          // named type: AccountID và OrderID không gán lẫn được
type Email struct{ addr string }

func ParseEmail(s string) (Email, error) {  // validate MỘT LẦN ở biên
    if !emailRx.MatchString(s) {
        return Email{}, fmt.Errorf("invalid email %q", s)
    }
    return Email{addr: strings.ToLower(s)}, nil
}
// Email tồn tại ⟹ đã hợp lệ. Mọi hàm nhận Email khỏi validate lại.

func Transfer(from, to AccountID, amount Money) error   // currency sống trong Money

Nguyên tắc đi kèm: Parse, don’t validate — biên hệ thống (HTTP handler, consumer) parse primitive thô thành value object; từ đó vào trong, type system bảo đảm tính hợp lệ, code lõi không còn phòng thủ lặt vặt. TypeScript không có named type thật (type alias là structural) — dùng branded type: type AccountID = string & { readonly __brand: "AccountID" } — cùng hiệu quả compile-time.

Khi nào KHÔNG phải smell

Giá trị cục bộ, đi qua 1-2 hàm, không mang invariant: limit int của một query, retries int của một vòng lặp — bọc type là nghi lễ. Value object đáng giá khi khái niệm xuyên nhiều tầng hoặc mang luật riêng. Và trong Go, named type (type AccountID string) gần như miễn phí — với ID, ngưỡng dùng nên rất thấp.


D. Data Clumps

Ví dụ

// ❌ bộ ba (street, district, city) đi tour khắp codebase
func ValidateAddress(street, district, city string) error
func ShippingFee(street, district, city string, weight float64) int64
func SaveCustomer(name, phone, street, district, city string) error
func FormatLabel(name, phone, street, district, city string) string

Nguyên nhân & nhận diện

Vài mảnh dữ liệu luôn xuất hiện cùng nhau nhưng chưa ai đặt tên cho cụm — một khái niệm domain đang tồn tại ẩn danh. Phép thử của Fowler: xóa một mảnh, các mảnh còn lại còn nghĩa không? district đứng một mình vô nghĩa → chúng là một cụm. Họ hàng gần của Primitive Obsession (cụm primitive thay vì một primitive) và là nguyên nhân trực tiếp của Long Parameter List.

Hậu quả

Chữ ký hàm dài và giòn — thêm ward (phường) vào địa chỉ = sửa 15 chữ ký + mọi call site (Shotgun Surgery); các tham số cùng type đứng cạnh nhau chờ bị truyền nhầm thứ tự; và logic của cụm (format địa chỉ, so sánh 2 địa chỉ) không có nhà, rải khắp nơi.

Refactoring phù hợp

Introduce Parameter Object (3.3):

// ✅ khái niệm ẩn danh được đặt tên — và lập tức thành nam châm hút hành vi về đúng nhà
type Address struct {
    Street, Ward, District, City string
}

func (a Address) Validate() error        { /* ... */ }
func (a Address) OneLine() string        { /* ... */ }
func (a Address) SameDistrict(b Address) bool { /* ... */ }

func ShippingFee(addr Address, weight float64) int64
func SaveCustomer(name, phone string, addr Address) error

Hiệu ứng dây chuyền đáng giá nhất: sau khi cụm có tên, các hàm thao tác trên cụm tự tìm về làm method — Feature Envy (3.2) quanh cụm đó tự tan. Thêm ward giờ là thay đổi một struct.

Khi nào KHÔNG phải smell

Hai tham số đi cùng nhau đúng một lần — chưa đủ án; Rule of Three áp dụng ở đây. Và đừng gom những thứ tình cờ đi cùng trong một chữ ký nhưng không phải một khái niệm (ctx + logger không phải data clump — chúng là hai mối quan tâm độc lập).


Tổng kết chương — bảng tra nhanh

SmellCâu hỏi chẩn đoánThuốc chínhNgưỡng báo động
Long MethodHàm này trộn mấy mức trừu tượng?Extract MethodComment “bước 1/2/3”, biến sống cục bộ theo cụm
Large Class / God ObjectStruct này có mấy lý do thay đổi, mấy cụm dependency?Extract ClassTên kết thúc bằng Manager/Util; mọi PR đều đụng nó
Primitive ObsessionString/int này mang khái niệm nghiệp vụ nào?Replace Primitive with Value ObjectValidation lặp; tham số cùng type kề nhau
Data ClumpsCụm tham số này có phải một khái niệm ẩn danh?Introduce Parameter Object3+ tham số luôn đi cùng nhau ở 3+ chữ ký

Tiếp theo: 3.2 — Code Smells nhóm coupling