⚙️ Software✍️ Khoa📅 20/04/2026☕ 14 phút đọc

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:

  1. SRP: Does this class have 1 reason to change?
  2. OCP: Can I add new behavior without modifying existing code?
  3. LSP: Can subtype substitute parent without breaking?
  4. ISP: Am I forcing clients to depend on methods they don't use?
  5. DIP: Am I depending on abstractions, not concrete types?
  6. DRY: Is this logic defined in only 1 place?
  7. YAGNI: Am I adding something not immediately needed?
  8. 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