Design Principles: SOLID, DRY, YAGNI & Composition
Giới thiệu
Design principles là compass khi code. Chúng không phải rules cứng (nếu đó, thì code sẽ từng là "design patterns"), mà là guideline giúp code dễ dàng thay đổi.
Tâm điểm: Không phải "viết code đẹp", mà là "viết code dễ mở rộng, dễ test, dễ bảo trì".
SOLID Principles
SOLID là 5 principles giúp tăng cohesion, giảm coupling.
S: Single Responsibility Principle (SRP)
Định nghĩa: Một class/function có một lý do để thay đổi.
// ❌ WRONG: Nhiều lý do thay đổi
type Order struct {
ID string
Items []Item
}
func (o *Order) CalculateTotal() float64 {
// Reason 1: Pricing logic change
}
func (o *Order) SendConfirmationEmail() error {
// Reason 2: Email service change
}
func (o *Order) SaveToDatabase() error {
// Reason 3: Database schema change
}
// ✓ RIGHT: Mỗi class có 1 lý do
type Order struct {
ID string
Items []Item
}
func (o *Order) CalculateTotal() float64 {
// Reason: Pricing logic
}
type OrderPersistence struct {
repo OrderRepository
}
func (op *OrderPersistence) Save(order *Order) error {
// Reason: Database interaction
}
type OrderNotification struct {
emailService EmailService
}
func (on *OrderNotification) SendConfirmation(order *Order) error {
// Reason: Email service
}
Detection:
- Tên class/function quá dài:
UserAuthenticationPasswordResetEmailService→ split - Mô tả class cần "and", "or": "handle order creation AND payment" → split
- Multiple reasons to reuse? No: nếu Email service thay đổi, Order model không cần thay
O: Open/Closed Principle (OCP)
Định nghĩa: Mở để mở rộng (extend), đóng để modify.
Thay đổi behavior mà không touch existing code.
// ❌ WRONG: Phải modify function mỗi khi add discount type
func CalculateDiscount(order *Order, discountType string) float64 {
switch discountType {
case "percentage":
return order.Total() * 0.1
case "fixed":
return 10.0
case "loyalty":
return order.Total() * 0.05
// Mỗi discount type mới = modify function này
}
}
// ✓ RIGHT: Extend via interface
type DiscountStrategy interface {
Calculate(total float64) float64
}
type PercentageDiscount struct {
percent float64
}
func (pd *PercentageDiscount) Calculate(total float64) float64 {
return total * pd.percent
}
type FixedDiscount struct {
amount float64
}
func (fd *FixedDiscount) Calculate(total float64) float64 {
return fd.amount
}
type LoyaltyDiscount struct {
percent float64
}
func (ld *LoyaltyDiscount) Calculate(total float64) float64 {
return total * ld.percent
}
// Function không cần modify khi thêm discount type
func CalculateDiscount(order *Order, strategy DiscountStrategy) float64 {
return strategy.Calculate(order.Total())
}
// Usage: Add new discount type without modifying CalculateDiscount
type BirthdayDiscount struct{}
func (bd *BirthdayDiscount) Calculate(total float64) float64 {
return total * 0.2
}
// ✓ Works immediately
CalculateDiscount(order, &BirthdayDiscount{})
Mechanism: Abstraction (interface) cho phép extension mà không modify existing code.
Real-world example:
// ❌ Hard to extend: Payment processor mét hóa
type OrderService struct {
repo OrderRepository
}
func (os *OrderService) CheckoutOrder(order *Order) error {
if !os.validateOrder(order) {
return errors.New("invalid")
}
// ← Hard-coded payment processor
if err := chargeWithStripe(order); err != nil {
return err
}
// ← Hard-coded notification
if err := sendEmailNotification(order); err != nil {
return err
}
return os.repo.Save(order)
}
// ✓ Open for extension
type PaymentProcessor interface {
Charge(order *Order) error
}
type Notifier interface {
Notify(order *Order) error
}
type OrderService struct {
repo OrderRepository
paymentProcessor PaymentProcessor
notifier Notifier
}
func (os *OrderService) CheckoutOrder(order *Order) error {
if !os.validateOrder(order) {
return errors.New("invalid")
}
if err := os.paymentProcessor.Charge(order); err != nil {
return err
}
if err := os.notifier.Notify(order); err != nil {
return err
}
return os.repo.Save(order)
}
// ✓ Add new payment processor without modifying OrderService
type StripeProcessor struct{}
func (sp *StripeProcessor) Charge(order *Order) error { /* stripe */ }
type PayPalProcessor struct{}
func (pp *PayPalProcessor) Charge(order *Order) error { /* paypal */ }
// ✓ Add new notifier without modifying OrderService
type SMSNotifier struct{}
func (sn *SMSNotifier) Notify(order *Order) error { /* sms */ }
L: Liskov Substitution Principle (LSP)
Định nghĩa: Subtype phải able thay thế supertype mà không break.
// ❌ WRONG: EmailNotifier not substitutable for Notifier
type Notifier interface {
Send(message string) error
}
type EmailNotifier struct{}
func (en *EmailNotifier) Send(message string) error {
if len(message) > 100 {
return errors.New("message too long") // ← Violates interface contract!
}
// send email
}
type SMSNotifier struct{}
func (sn *SMSNotifier) Send(message string) error {
if len(message) > 160 {
return errors.New("message too long")
}
// send sms
}
// User expects any Notifier to send any message
func NotifyUser(n Notifier, message string) error {
return n.Send(message) // ← Works for SMS, fails for Email if > 100 chars
}
// ✓ RIGHT: Respect interface contract
type Notifier interface {
Send(message string) error
}
type EmailNotifier struct{}
func (en *EmailNotifier) Send(message string) error {
// Truncate if needed, but always succeed
if len(message) > 100 {
message = message[:100] + "..."
}
// send email
return nil
}
type SMSNotifier struct{}
func (sn *SMSNotifier) Send(message string) error {
if len(message) > 160 {
message = message[:160] + "..."
}
// send sms
return nil
}
// Now both can substitute for Notifier
func NotifyUser(n Notifier, message string) error {
return n.Send(message) // ✓ Works for both
}
Detection: Subclass method returns error khi parent doesn't → LSP violation.
I: Interface Segregation Principle (ISP)
Định nghĩa: Clients không nên force depend on interface methods yang mereka tidak use.
// ❌ WRONG: Fat interface
type OrderManager interface {
CreateOrder(order *Order) error
CancelOrder(orderID string) error
CompleteOrder(orderID string) error
ProcessRefund(orderID string) error
SendNotification(orderID string) error
GenerateReport(orderID string) error
}
// Client yang hanya cần create order, harus implement semua methods
type SimpleOrderHandler struct{}
func (soh *SimpleOrderHandler) CreateOrder(order *Order) error {
// real impl
}
func (soh *SimpleOrderHandler) CancelOrder(orderID string) error {
// panic: tidak relevant
panic("not implemented")
}
// ... implement 4 more methods just to use CreateOrder
// ✓ RIGHT: Segregate into smaller interfaces
type OrderCreator interface {
CreateOrder(order *Order) error
}
type OrderCanceller interface {
CancelOrder(orderID string) error
}
type OrderCompleter interface {
CompleteOrder(orderID string) error
}
type RefundProcessor interface {
ProcessRefund(orderID string) error
}
// Client can depend only on what it needs
type SimpleOrderHandler struct{}
func (soh *SimpleOrderHandler) CreateOrder(order *Order) error {
// real impl
}
// ✓ No need to implement unused methods
// If need multiple interfaces:
type FullOrderManager interface {
OrderCreator
OrderCanceller
OrderCompleter
RefundProcessor
}
Rule of thumb: Interface with 1-3 methods. More than 5 = probably too fat.
D: Dependency Inversion Principle (DIP)
Định nghĩa: High-level modules tidak depend on low-level modules. Both depend on abstraction.
// ❌ WRONG: High-level depends on low-level
type MySQLDatabase struct{}
func (db *MySQLDatabase) SaveOrder(order *Order) error {
// SQL insert
}
type OrderService struct {
db MySQLDatabase // ← Concrete, tightly coupled
}
func (os *OrderService) CheckoutOrder(order *Order) error {
// ... business logic
return os.db.SaveOrder(order) // ← Must use MySQL
}
// To test: need real MySQL or complex mock
func TestCheckout(t *testing.T) {
realDB := MySQLDatabase{}
service := &OrderService{db: realDB} // ← Slow, brittle
}
// ✓ RIGHT: Both depend on abstraction
type Repository interface {
SaveOrder(order *Order) error
}
type OrderService struct {
repo Repository // ← Abstract, not concrete
}
func (os *OrderService) CheckoutOrder(order *Order) error {
// ... business logic
return os.repo.SaveOrder(order) // ← Works with any Repository impl
}
// To test: inject mock
type MockRepository struct{}
func (mr *MockRepository) SaveOrder(order *Order) error {
return nil // ← Test version
}
func TestCheckout(t *testing.T) {
mockRepo := &MockRepository{}
service := &OrderService{repo: mockRepo} // ✓ Fast, explicit
// ... test
}
// In production: inject real implementation
realRepo := mysql.NewRepository(db)
service := &OrderService{repo: realRepo}
Pattern: Constructor injection
// Service doesn't know how to create Repository
type OrderService struct {
repo Repository
}
func NewOrderService(repo Repository) *OrderService {
return &OrderService{repo: repo}
}
// DIP: Caller decides what Repository impl to use
func main() {
repo := postgres.NewRepository(db) // Could be MySQL, MongoDB, ...
service := NewOrderService(repo)
}
DRY: Don't Repeat Yourself
Định nghĩa: Mỗi piece of knowledge nên có 1 source of truth.
// ❌ WRONG: Duplicate validation
func ValidateOrderAPI(order *Order) error {
if order.Total() < 10 {
return errors.New("order total must be >= 10")
}
if len(order.Items) == 0 {
return errors.New("order must have items")
}
}
func ValidateOrderCLI(order *Order) error {
if order.Total() < 10 {
return errors.New("order total must be >= 10") // ← Duplicated
}
if len(order.Items) == 0 {
return errors.New("order must have items") // ← Duplicated
}
}
// ✓ RIGHT: Extract to single place
func (o *Order) Validate() error {
if o.Total() < 10 {
return errors.New("order total must be >= 10")
}
if len(o.Items) == 0 {
return errors.New("order must have items")
}
}
func ValidateOrderAPI(order *Order) error {
return order.Validate()
}
func ValidateOrderCLI(order *Order) error {
return order.Validate()
}
Pitfall: Extracting too early (premature DRY)
// ❌ WRONG: Similar ≠ Duplicate
func CalculateOrderDiscount(order *Order) float64 {
return order.Total() * 0.1
}
func CalculateSubscriptionDiscount(subscription *Subscription) float64 {
return subscription.MonthlyPrice() * 0.1 // ← Looks same, but different logic
}
// Later: Order discount changes to 15%, subscription stays 10%
// → If extracted early, now they break apart
// ✓ RIGHT: Only extract when truly same logic, not just similar code
// Keep separate until there's clear evidence of same concept
YAGNI: You Aren't Gonna Need It
Định nghĩa: Don't add features / abstraction yang tidak digunakan.
// ❌ WRONG: Premature generalization
type PaymentProcessor interface {
Charge(amount float64) error
Refund(transactionID string) error
UpdatePaymentMethod(method PaymentMethod) error
ScheduleRecurringCharge(amount float64, interval string) error
ApplyPromotionCode(code string) error
GetPaymentHistory(limit int) ([]Payment, error)
// ... 10 more methods
}
// But current use case: only Charge & Refund needed
// → Over-engineer, future-proof features tapi chưa dùng
// ✓ RIGHT: Start minimal, add as needed
type PaymentProcessor interface {
Charge(amount float64) error
Refund(transactionID string) error
}
// If need recurring: add later
type RecurringPaymentProcessor interface {
PaymentProcessor
ScheduleRecurringCharge(amount float64, interval string) error
}
// If need promo codes: add later
type PromoPaymentProcessor interface {
PaymentProcessor
ApplyPromotionCode(code string) error
}
Benefit:
- Less code = less maintenance
- Easier to understand
- If feature never needed: zero cost
- When needed: easier to add (incremental) than maintain unused
Composition Over Inheritance
Heuristic: In Go, prefer composition (embedding, interfaces) over inheritance.
Go doesn't have inheritance anyway (no class hierarchy), but the principle applies.
// ❌ WRONG: Trying to fake inheritance
type Animal struct {
Name string
}
func (a *Animal) Speak() {
fmt.Println("Some sound")
}
type Dog struct {
Animal // ← Embedded, "inherits" Animal
}
func (d *Dog) Speak() {
fmt.Println("Bark")
}
// Problem: dog.Name works, but dog.Animal.Speak() too
// Ambiguous, confusing
// ✓ RIGHT: Composition via embedding for reuse
type Being struct {
Name string
}
type Dog struct {
Being // ← Embedded for field reuse
}
// Dog inherits Being.Name field
dog := Dog{Being: Being{Name: "Fido"}}
fmt.Println(dog.Name) // "Fido" ← Works via embedding
// For behavior:
type Speaker interface {
Speak() string
}
type Dog struct {
Name string
}
func (d *Dog) Speak() string {
return "Bark"
}
type Cat struct {
Name string
}
func (c *Cat) Speak() string {
return "Meow"
}
// Both implement Speaker interface
func MakeNoise(s Speaker) {
fmt.Println(s.Speak())
}
// ✓ Flexible composition
Use embedding when:
- Want to reuse fields + promote them
- Want to add methods to existing type (type wrapper pattern)
// Embedding for promotion
type Logger struct {
level string
}
func (l *Logger) Log(msg string) {
fmt.Printf("[%s] %s\n", l.level, msg)
}
type Database struct {
Logger // ← Promote Logger methods
}
db := &Database{Logger: &Logger{level: "INFO"}}
db.Log("Connected") // ← Available via promotion
Go-Specific: Interface Design
1. Interface-as-Contract Pattern
// ✓ Define interface at the point of use, not at the definition
// ← This is Go-idiomatic
package service
type Repository interface { // ← Defined in service pkg
GetOrder(id string) (*Order, error)
SaveOrder(order *Order) error
}
package postgres
type OrderRepository struct {
db *sql.DB
}
func (r *OrderRepository) GetOrder(id string) (*Order, error) { ... }
func (r *OrderRepository) SaveOrder(order *Order) error { ... }
// OrderRepository implicitly implements service.Repository
// ✓ No "implements" keyword needed (structural typing)
2. Small, Focused Interfaces
// ✓ Go style: small interfaces
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
type Closer interface {
Close() error
}
// Compose:
type ReadCloser interface {
Reader
Closer
}
type ReadWriter interface {
Reader
Writer
}
// vs
// ❌ Java style: big interface
type FileOperation interface {
Read(p []byte) (int, error)
Write(p []byte) (int, error)
Close() error
Seek(offset int64) error
Stat() (FileInfo, error)
// ... 10 more methods
}
3. Receiver Type Matters
// ✓ Use value receiver for immutable operations, pointer for mutation
type Order struct {
ID string
Total float64
}
// Value receiver: OK, doesn't mutate
func (o Order) Discount(percent float64) float64 {
return o.Total * (1 - percent/100)
}
// Pointer receiver: Must mutate
func (o *Order) ApplyDiscount(percent float64) {
o.Total = o.Total * (1 - percent/100)
}
// ✓ Rule: If in doubt, use pointer receiver (more flexible)
When to Break SOLID ⚠️
SOLID are guidelines, not absolute rules. Sometimes breaking them is pragmatic.
Trade-off 1: Performance vs Purity
// ✓ Pure: Immutable, functional
func CalculateTotal(items []Item) float64 {
total := 0.0
for _, item := range items {
total += item.Price * float64(item.Quantity)
}
return total
}
// If called 1M times / second, allocating []Item copies = overhead
// ✗ Sometimes, mutate in-place for perf
func CalculateTotalMutate(items []Item) float64 {
total := 0.0
for i := range items { // ← In-place mutation
total += items[i].Price * float64(items[i].Quantity)
}
return total
}
Trade-off 2: Simplicity vs Abstraction
// For small, one-off use case, don't over-engineer
// ✓ Simple, works
var orders []Order
for _, o := range orders {
fmt.Println(o.ID)
}
// vs
// ✗ Overkill for one-off
type OrderIterator interface {
HasNext() bool
Next() *Order
}
type ArrayOrderIterator struct { ... }
func (it *ArrayOrderIterator) HasNext() bool { ... }
func (it *ArrayOrderIterator) Next() *Order { ... }
Trade-off 3: Composition Depth
// ✓ When composition has 1-2 levels: clear
type OrderService struct {
repo Repository
}
// ✗ When composition has 5+ levels: hard to trace
type OrderService struct {
paymentService *PaymentService
}
type PaymentService struct {
processor PaymentProcessor
}
type PaymentProcessor struct {
gateway PaymentGateway
}
type PaymentGateway struct {
httpClient *http.Client
}
type http.Client struct {
... // Another 10 layers
}
// At this point, maybe just use concrete type for PaymentGateway
Real-World Checklist ✅
When designing a module:
- SRP: Does this class have 1 reason to change?
- OCP: Can I add new behavior without modifying existing code?
- LSP: Can subtype substitute parent without breaking?
- ISP: Am I forcing clients to depend on methods they don't use?
- DIP: Am I depending on abstractions, not concrete types?
- DRY: Is this logic defined in only 1 place?
- YAGNI: Am I adding something not immediately needed?
- Composition: Am I using composition over inheritance?
If you can answer "yes" to most: your design is solid. If not: consider refactor.
Tóm tắt
| Principle | Spirit | Red Flag |
|---|---|---|
| SRP | One reason to change | Class name has "And", "Or" |
| OCP | Extend, don't modify | Switch statement on type |
| LSP | Subtype = supertype | Subclass throws "not implemented" |
| ISP | Don't force unused methods | Interface with 10+ methods |
| DIP | Depend on abstraction | Service depends on concrete class |
| DRY | Single source of truth | Same logic in 2+ places |
| YAGNI | Minimal until needed | Code for hypothetical future feature |
| Composition | Favor small, composable | Deep inheritance hierarchy |
Bước tiếp theo
- Clean Architecture: Apply SOLID to layer design
- Testing: SOLID makes testing easier, and vice versa
- Refactoring: How to incrementally apply SOLID to legacy code