🐈

テーブル設計とドメインオブジェクト設計の本質的な違い

に公開

『現場で役立つシステム設計の原則』を読んで、テーブル設計とドメインオブジェクト設計について深く考えさせられました。特に印象的だったのは、両者が1対1に対応することがあっても、本質的に異なるものであるということです。

なぜ混同してしまうのか

業務システムを設計していると、確かにテーブルとドメインオブジェクトが似たような構造になることがよくあります。例えば、注文管理システムを考えてみましょう。

-- テーブル構造
CREATE TABLE orders (
    id INT AUTO_INCREMENT PRIMARY KEY,
    customer_id INT NOT NULL,
    order_date DATE NOT NULL,
    status VARCHAR(20) NOT NULL,
    total_amount INT NOT NULL
);
// ドメインオブジェクト
type Order struct {
    ID           int
    CustomerID   int
    OrderDate    time.Time
    Status       OrderStatus
    TotalAmount  decimal.Decimal
}

一見すると、ほとんど同じように見えます。しかし、この類似性が落とし穴になることがあります。

本質的な違いを理解する

データベースレイヤー:データの永続化に特化

// データベースレコード - テーブル構造を忠実に反映
type OrderRecord struct {
    ID          int       `db:"id"`
    CustomerID  int       `db:"customer_id"`
    OrderDate   time.Time `db:"order_date"`
    Status      string    `db:"status"`        
    TotalAmount int64     `db:"total_amount"` 
}

// リポジトリインターフェース - データアクセスの抽象化
type OrderRepository interface {
    FindByID(id int) (*OrderRecord, error)
    FindByCustomerID(customerID int) ([]*OrderRecord, error)
    Save(record *OrderRecord) error
    Delete(id int) error
}

// 具体的な実装
type MySQLOrderRepository struct {
    db *sql.DB
}

func (r *MySQLOrderRepository) FindByID(id int) (*OrderRecord, error) {
    query := `SELECT id, customer_id, order_date, status, total_amount 
              FROM orders WHERE id = ?`
    
    var record OrderRecord
    err := r.db.QueryRow(query, id).Scan(
        &record.ID,
        &record.CustomerID,
        &record.OrderDate,
        &record.Status,
        &record.TotalAmount,
    )
    
    if err != nil {
        return nil, fmt.Errorf("failed to find order: %w", err)
    }
    
    return &record, nil
}

func (r *MySQLOrderRepository) Save(record *OrderRecord) error {
    if record.ID == 0 {
        // INSERT
        query := `INSERT INTO orders (customer_id, order_date, status, total_amount) 
                  VALUES (?, ?, ?, ?)`
        result, err := r.db.Exec(query, 
            record.CustomerID,
            record.OrderDate,
            record.Status,
            record.TotalAmount,
        )
        if err != nil {
            return fmt.Errorf("failed to insert order: %w", err)
        }
        
        id, _ := result.LastInsertId()
        record.ID = int(id)
    } else {
        // UPDATE
        query := `UPDATE orders SET customer_id=?, order_date=?, status=?, total_amount=? 
                  WHERE id=?`
        _, err := r.db.Exec(query,
            record.CustomerID,
            record.OrderDate,
            record.Status,
            record.TotalAmount,
            record.ID,
        )
        if err != nil {
            return fmt.Errorf("failed to update order: %w", err)
        }
    }
    
    return nil
}

ドメインレイヤー:ビジネスロジックの表現

type OrderStatus int

const (
    OrderStatusPending OrderStatus = iota
    OrderStatusConfirmed
    OrderStatusShipped
    OrderStatusDelivered
    OrderStatusCancelled
)

type OrderID int
type CustomerID int

// ドメインオブジェクト - ビジネスルールとデータが一体
type Order struct {
    id          OrderID
    customerID  CustomerID
    orderDate   time.Time
    status      OrderStatus
    items       []OrderItem
    totalAmount Money
}

// ビジネスロジック - ドメイン知識を表現
func (o *Order) CanBeCancelled() bool {
    return o.status == OrderStatusPending || o.status == OrderStatusConfirmed
}

func (o *Order) Cancel() error {
    if !o.CanBeCancelled() {
        return fmt.Errorf("cannot cancel order with status %v", o.status)
    }
    
    o.status = OrderStatusCancelled
    return nil
}

func (o *Order) AddItem(item OrderItem) error {
    if o.status != OrderStatusPending {
        return fmt.Errorf("cannot add item to confirmed order")
    }
    
    o.items = append(o.items, item)
    o.recalculateTotal()
    return nil
}

func (o *Order) Confirm() error {
    if o.status != OrderStatusPending {
        return fmt.Errorf("can only confirm pending orders")
    }
    
    if len(o.items) == 0 {
        return fmt.Errorf("cannot confirm order without items")
    }
    
    o.status = OrderStatusConfirmed
    return nil
}

func (o *Order) recalculateTotal() {
    total := Money{Amount: 0}
    for _, item := range o.items {
        total = total.Add(item.SubTotal())
    }
    o.totalAmount = total
}

// ドメインサービス - 複雑なビジネスロジック
type OrderService struct {
    repository OrderRepository
    mapper     *OrderMapper
}

func (s *OrderService) CreateOrder(customerID CustomerID, items []OrderItem) (*Order, error) {
    if len(items) == 0 {
        return nil, fmt.Errorf("order must have at least one item")
    }
    
    order := &Order{
        customerID: customerID,
        orderDate:  time.Now(),
        status:     OrderStatusPending,
        items:      items,
    }
    order.recalculateTotal()
    
    // ドメインオブジェクトをレコードに変換して保存
    record := s.mapper.ToRecord(order)
    if err := s.repository.Save(record); err != nil {
        return nil, fmt.Errorf("failed to save order: %w", err)
    }
    
    order.id = OrderID(record.ID)
    return order, nil
}

func (s *OrderService) GetOrder(id OrderID) (*Order, error) {
    record, err := s.repository.FindByID(int(id))
    if err != nil {
        return nil, fmt.Errorf("order not found: %w", err)
    }
    
    return s.mapper.ToDomain(record)
}

明示的なマッピング関数

両者を明確に分離し、変換を担う専用の関数を設けます。

func OrderRecordToDomain(record *OrderRecord) (*Order, error) {
    status, err := parseOrderStatus(record.Status)
    if err != nil {
        return nil, err
    }
    
    return &Order{
        id:          OrderID(record.ID),
        customerID:  CustomerID(record.CustomerID),
        orderDate:   record.OrderDate,
        status:      status,
        totalAmount: Money{Amount: record.TotalAmount},
        items:       []OrderItem{}, // 別途取得が必要
    }, nil
}

func OrderToRecord(order *Order) *OrderRecord {
    return &OrderRecord{
        ID:          int(order.id),
        CustomerID:  int(order.customerID),
        OrderDate:   order.orderDate,
        Status:      orderStatusToString(order.status),
        TotalAmount: order.totalAmount.Amount,
    }
}

func parseOrderStatus(status string) (OrderStatus, error) {
    switch status {
    case "pending":
        return OrderStatusPending, nil
    case "confirmed":
        return OrderStatusConfirmed, nil
    case "shipped":
        return OrderStatusShipped, nil
    case "delivered":
        return OrderStatusDelivered, nil
    case "cancelled":
        return OrderStatusCancelled, nil
    default:
        return 0, fmt.Errorf("invalid status: %s", status)
    }
}

func orderStatusToString(status OrderStatus) string {
    switch status {
    case OrderStatusPending:
        return "pending"
    case OrderStatusConfirmed:
        return "confirmed"
    case OrderStatusShipped:
        return "shipped"
    case OrderStatusDelivered:
        return "delivered"
    case OrderStatusCancelled:
        return "cancelled"
    default:
        return "unknown"
    }
}

独立した進化の実現

この設計により、それぞれが独立して変更できるようになります:

// ビジネスルール変更の例:出荷後24時間以内ならキャンセル可能
func (o *Order) CanBeCancelled() bool {
    // 新しいビジネスルール
    if o.status == OrderStatusShipped {
        return time.Since(o.shippedAt) <= 24*time.Hour
    }
    
    return o.status == OrderStatusPending || o.status == OrderStatusConfirmed
}

// テーブル構造は変更不要 - インデックス追加など性能改善のみ
// CREATE INDEX idx_orders_shipped_date ON orders(status, shipped_at) 
// WHERE status = 'shipped';

アーキテクチャ全体図

// 使用例 - 依存関係の注入
func NewOrderService(db *sql.DB) *OrderService {
    repository := &MySQLOrderRepository{db: db}
    return &OrderService{
        repository: repository,
    }
}

// アプリケーションレイヤーでの使用
func HandleCreateOrder(service *OrderService, customerID int, itemData []ItemRequest) {
    items := convertToOrderItems(itemData)
    
    order, err := service.CreateOrder(CustomerID(customerID), items)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    
    // レスポンス返却...
}

まとめ

テーブル設計とドメインオブジェクト設計は、同じ業務領域を扱いながらも本質的に異なる責務を持ちます。

データベースレイヤー(永続化)

  • データの整合性保証
  • クエリパフォーマンスの最適化
  • ストレージ効率

ドメインレイヤー(ビジネスロジック)

  • ビジネスルールの表現
  • 不正な状態変更の防止
  • コードの可読性と保守性

マッピング関数(変換)

  • 両者の独立性確保
  • 型安全な変換
  • 変更の影響局所化

この明確な分離により、ビジネスルール変更とデータベース最適化が独立して行え、長期的に保守しやすいシステムを構築できます。

Discussion