🐥

DDDのServiceパターンをTypeScript関数型アプローチで実装する実践ガイド

に公開

はじめに

ドメイン駆動設計(DDD)を学ぶ際、多くの開発者がつまずくポイントの一つが「Service」の理解と適切な実装です。特に、EntityやValue Objectには属さないビジネスロジックをどう扱うべきか、どのようにServiceを分類し実装すべきかについては、実践的な指針が不足しがちです。

本記事では、TypeScriptの関数型アプローチを用いて、DDDにおけるServiceパターンを実装する方法を詳しく解説します。クラスベースの実装ではなく、関数と型を活用したモダンなアプローチで、保守性と可読性を両立させる手法をお伝えします。

対象読者

  • DDDの基本概念は理解しているが、Serviceの実装で悩んでいる方
  • TypeScriptで関数型プログラミングのアプローチを学びたい方
  • 実践的なDDD実装パターンを知りたい方

目次

  1. DDDにおけるServiceとは
  2. TypeScript関数型アプローチの基本
  3. Domain Serviceの実装
  4. Application Serviceの実装
  5. Infrastructure Serviceの実装
  6. 実践的な設計パターン
  7. よくある問題とその対策
  8. まとめ

DDDにおけるServiceとは

なぜServiceが必要なのか

DDDでは、ビジネスロジックを主にEntityとValue Objectで表現しますが、以下のような場合には適切な表現が困難になります:

  • 複数のEntityが関わる操作
  • 複雑な計算や変換処理
  • 外部システムとの連携

このような処理をEntityに無理やり押し込むと、責務が曖昧になり、保守性が損なわれます。

// 悪い例:どちらのEntityに属するべきか不明確
type User = {
  readonly id: UserId
  readonly email: Email
  readonly balance: Money
  // このオブジェクトに送金処理を含めるべき?
}

// この処理はどこに属するべきか?
const transferMoney = (from: User, to: User, amount: Money) => {
  // 2つのUserが関わる処理...
}

Serviceの3つの分類

DDDでは、Serviceを以下の3つに分類します:

種類 責務 実装場所
Domain Service ビジネスロジック Domain層
Application Service ユースケースの調整 Application層
Infrastructure Service 技術的な処理 Infrastructure層

TypeScript関数型アプローチの基本

型定義による設計

まず、ドメインオブジェクトを型として定義します:

// ブランド型を用いた型安全性の向上
type UserId = string & { readonly _brand: 'UserId' }
type AccountId = string & { readonly _brand: 'AccountId' }
type Email = string & { readonly _brand: 'Email' }

// Value Object
type Money = {
  readonly amount: number
  readonly currency: Currency
}

type Currency = 'JPY' | 'USD' | 'EUR'

// Entity
type User = {
  readonly id: UserId
  readonly email: Email
  readonly name: string
  readonly createdAt: Date
}

type Account = {
  readonly id: AccountId
  readonly userId: UserId
  readonly balance: Money
  readonly status: AccountStatus
}

type AccountStatus = 'active' | 'suspended' | 'closed'

エラーハンドリングの戦略

関数型アプローチでは、エラーを戻り値の型として表現します:

type Result<T, E> = T | E

type DomainError = {
  readonly type: string
  readonly message: string
  readonly details?: Record<string, unknown>
}

Domain Serviceの実装

複数Entityを扱うビジネスロジック

最も典型的なDomain Serviceの例として、銀行の送金処理を実装してみましょう:

// 送金結果の型定義
type TransferResult = {
  readonly fromAccount: Account
  readonly toAccount: Account
  readonly transferredAmount: Money
  readonly executedAt: Date
}

// エラーの詳細な型定義
type TransferError = 
  | { 
      type: 'InsufficientFunds'
      required: Money
      available: Money 
    }
  | { 
      type: 'CurrencyMismatch'
      fromCurrency: Currency
      toCurrency: Currency 
    }
  | { 
      type: 'AccountSuspended'
      accountId: AccountId 
    }
  | { 
      type: 'SameAccountTransfer'
      accountId: AccountId 
    }

// 純粋関数としてのDomain Service
const transferMoney = (
  fromAccount: Account,
  toAccount: Account,
  amount: Money
): Result<TransferResult, TransferError> => {
  // ビジネスルールの検証
  
  // 同一アカウント間の送金禁止
  if (fromAccount.id === toAccount.id) {
    return {
      type: 'SameAccountTransfer',
      accountId: fromAccount.id
    }
  }
  
  // アカウント状態の確認
  if (fromAccount.status !== 'active') {
    return {
      type: 'AccountSuspended',
      accountId: fromAccount.id
    }
  }
  
  if (toAccount.status !== 'active') {
    return {
      type: 'AccountSuspended',
      accountId: toAccount.id
    }
  }
  
  // 通貨の確認
  if (fromAccount.balance.currency !== amount.currency) {
    return {
      type: 'CurrencyMismatch',
      fromCurrency: fromAccount.balance.currency,
      toCurrency: amount.currency
    }
  }
  
  // 残高の確認
  if (fromAccount.balance.amount < amount.amount) {
    return {
      type: 'InsufficientFunds',
      required: amount,
      available: fromAccount.balance
    }
  }
  
  // 送金処理の実行(不変性を保持)
  const updatedFromAccount: Account = {
    ...fromAccount,
    balance: {
      ...fromAccount.balance,
      amount: fromAccount.balance.amount - amount.amount
    }
  }
  
  const updatedToAccount: Account = {
    ...toAccount,
    balance: {
      ...toAccount.balance,
      amount: toAccount.balance.amount + amount.amount
    }
  }
  
  return {
    fromAccount: updatedFromAccount,
    toAccount: updatedToAccount,
    transferredAmount: amount,
    executedAt: new Date()
  }
}

複雑な計算処理

次に、複雑な料金計算を行うDomain Serviceを実装します:

// 商品とお客様の型定義
type Product = {
  readonly id: ProductId
  readonly name: string
  readonly basePrice: Money
  readonly category: ProductCategory
  readonly weight: number
}

type Customer = {
  readonly id: CustomerId
  readonly tier: CustomerTier
  readonly registeredAt: Date
  readonly totalPurchases: Money
}

type ProductId = string & { readonly _brand: 'ProductId' }
type CustomerId = string & { readonly _brand: 'CustomerId' }
type ProductCategory = 'electronics' | 'clothing' | 'books' | 'food'
type CustomerTier = 'bronze' | 'silver' | 'gold' | 'platinum'

// 割引条件の定義
type Discount = {
  readonly id: DiscountId
  readonly type: DiscountType
  readonly value: number
  readonly conditions: readonly DiscountCondition[]
  readonly validUntil: Date
}

type DiscountId = string & { readonly _brand: 'DiscountId' }
type DiscountType = 'percentage' | 'fixed_amount'
type DiscountCondition = 
  | { type: 'minimum_quantity'; quantity: number }
  | { type: 'customer_tier'; tier: CustomerTier }
  | { type: 'product_category'; category: ProductCategory }
  | { type: 'new_customer'; daysFromRegistration: number }

// 価格計算Domain Service
const calculatePrice = (
  product: Product,
  customer: Customer,
  quantity: number,
  discounts: readonly Discount[] = []
): Money => {
  let totalPrice = product.basePrice.amount * quantity
  
  // 顧客ティア基本割引の適用
  const tierDiscount = calculateTierDiscount(customer.tier)
  totalPrice = totalPrice * (1 - tierDiscount)
  
  // カテゴリ別ボリューム割引
  const volumeDiscount = calculateVolumeDiscount(product.category, quantity)
  totalPrice = totalPrice * (1 - volumeDiscount)
  
  // 追加割引の適用
  const applicableDiscounts = discounts.filter(discount => 
    isDiscountApplicable(discount, customer, product, quantity)
  )
  
  for (const discount of applicableDiscounts) {
    totalPrice = applyDiscount(totalPrice, discount)
  }
  
  return {
    amount: Math.max(0, Math.round(totalPrice * 100) / 100), // 小数点2桁で丸め
    currency: product.basePrice.currency
  }
}

// ヘルパー関数群
const calculateTierDiscount = (tier: CustomerTier): number => {
  const tierDiscounts: Record<CustomerTier, number> = {
    bronze: 0,
    silver: 0.05,
    gold: 0.10,
    platinum: 0.15
  }
  return tierDiscounts[tier]
}

const calculateVolumeDiscount = (
  category: ProductCategory, 
  quantity: number
): number => {
  const volumeThresholds: Record<ProductCategory, { threshold: number; discount: number }[]> = {
    electronics: [
      { threshold: 5, discount: 0.05 },
      { threshold: 10, discount: 0.10 }
    ],
    clothing: [
      { threshold: 3, discount: 0.10 },
      { threshold: 7, discount: 0.15 }
    ],
    books: [
      { threshold: 5, discount: 0.15 },
      { threshold: 15, discount: 0.25 }
    ],
    food: [
      { threshold: 10, discount: 0.05 }
    ]
  }
  
  const thresholds = volumeThresholds[category]
  const applicableDiscount = thresholds
    .filter(t => quantity >= t.threshold)
    .sort((a, b) => b.discount - a.discount)[0]
    
  return applicableDiscount?.discount ?? 0
}

const isDiscountApplicable = (
  discount: Discount,
  customer: Customer,
  product: Product,
  quantity: number
): boolean => {
  // 有効期限チェック
  if (new Date() > discount.validUntil) {
    return false
  }
  
  // すべての条件を満たす必要がある
  return discount.conditions.every(condition => {
    switch (condition.type) {
      case 'minimum_quantity':
        return quantity >= condition.quantity
        
      case 'customer_tier':
        return customer.tier === condition.tier
        
      case 'product_category':
        return product.category === condition.category
        
      case 'new_customer':
        const daysSinceRegistration = Math.floor(
          (Date.now() - customer.registeredAt.getTime()) / (1000 * 60 * 60 * 24)
        )
        return daysSinceRegistration <= condition.daysFromRegistration
        
      default:
        return false
    }
  })
}

const applyDiscount = (price: number, discount: Discount): number => {
  switch (discount.type) {
    case 'percentage':
      return price * (1 - discount.value / 100)
    case 'fixed_amount':
      return Math.max(0, price - discount.value)
    default:
      return price
  }
}

Application Serviceの実装

Application Serviceは、ユースケースを実現するための調整役として機能します。Repository、Domain Service、Infrastructure Serviceを組み合わせて、エンドツーエンドの処理を実現します。

ユーザー登録のApplication Service

// 外部依存の抽象化
type UserRepository = {
  readonly findById: (id: UserId) => Promise<User | null>
  readonly findByEmail: (email: Email) => Promise<User | null>
  readonly save: (user: User) => Promise<void>
  readonly existsByEmail: (email: Email) => Promise<boolean>
}

type EmailService = {
  readonly sendWelcomeEmail: (email: Email, name: string) => Promise<void>
  readonly sendVerificationEmail: (email: Email, token: string) => Promise<void>
}

// コマンドとイベントの定義
type RegisterUserCommand = {
  readonly email: string
  readonly name: string
  readonly password: string
}

type UserRegisteredEvent = {
  readonly type: 'UserRegistered'
  readonly userId: UserId
  readonly email: Email
  readonly registeredAt: Date
}

// バリデーションエラーの詳細な定義
type UserRegistrationError = 
  | { type: 'InvalidEmail'; email: string; reason: string }
  | { type: 'InvalidName'; name: string; reason: string }
  | { type: 'WeakPassword'; reason: string }
  | { type: 'DuplicateEmail'; email: string }
  | { type: 'ExternalServiceError'; service: string; message: string }

// バリデーション関数群
const validateEmail = (email: string): Result<Email, UserRegistrationError> => {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
  
  if (!email.trim()) {
    return { type: 'InvalidEmail', email, reason: 'Email is required' }
  }
  
  if (email.length > 254) {
    return { type: 'InvalidEmail', email, reason: 'Email is too long' }
  }
  
  if (!emailRegex.test(email)) {
    return { type: 'InvalidEmail', email, reason: 'Invalid email format' }
  }
  
  return email as Email
}

const validateName = (name: string): Result<string, UserRegistrationError> => {
  const trimmedName = name.trim()
  
  if (!trimmedName) {
    return { type: 'InvalidName', name, reason: 'Name is required' }
  }
  
  if (trimmedName.length < 2) {
    return { type: 'InvalidName', name, reason: 'Name must be at least 2 characters' }
  }
  
  if (trimmedName.length > 50) {
    return { type: 'InvalidName', name, reason: 'Name must be 50 characters or less' }
  }
  
  const nameRegex = /^[a-zA-Z--\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF\s]+$/
  if (!nameRegex.test(trimmedName)) {
    return { type: 'InvalidName', name, reason: 'Name contains invalid characters' }
  }
  
  return trimmedName
}

const validatePassword = (password: string): Result<string, UserRegistrationError> => {
  if (password.length < 8) {
    return { type: 'WeakPassword', reason: 'Password must be at least 8 characters' }
  }
  
  if (password.length > 100) {
    return { type: 'WeakPassword', reason: 'Password must be 100 characters or less' }
  }
  
  const hasUpperCase = /[A-Z]/.test(password)
  const hasLowerCase = /[a-z]/.test(password)
  const hasNumbers = /\d/.test(password)
  const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(password)
  
  const strengthChecks = [hasUpperCase, hasLowerCase, hasNumbers, hasSpecialChar]
  const passedChecks = strengthChecks.filter(Boolean).length
  
  if (passedChecks < 3) {
    return { 
      type: 'WeakPassword', 
      reason: 'Password must contain at least 3 of: uppercase, lowercase, numbers, special characters' 
    }
  }
  
  return password
}

// Application Service本体
const createUserRegistrationService = (
  userRepository: UserRepository,
  emailService: EmailService
) => {
  const registerUser = async (
    command: RegisterUserCommand
  ): Promise<Result<UserRegisteredEvent, UserRegistrationError>> => {
    
    // 1. 入力値のバリデーション
    const emailResult = validateEmail(command.email)
    if ('type' in emailResult) return emailResult
    
    const nameResult = validateName(command.name)
    if ('type' in nameResult) return nameResult
    
    const passwordResult = validatePassword(command.password)
    if ('type' in passwordResult) return passwordResult
    
    const validatedEmail = emailResult
    const validatedName = nameResult
    const validatedPassword = passwordResult
    
    try {
      // 2. ビジネスルールの検証(重複チェック)
      const emailExists = await userRepository.existsByEmail(validatedEmail)
      if (emailExists) {
        return { type: 'DuplicateEmail', email: command.email }
      }
      
      // 3. ドメインオブジェクトの生成
      const userId = crypto.randomUUID() as UserId
      const hashedPassword = await hashPassword(validatedPassword) // 実装は省略
      const verificationToken = generateVerificationToken() // 実装は省略
      
      const user: User = {
        id: userId,
        email: validatedEmail,
        name: validatedName,
        createdAt: new Date()
      }
      
      // 4. 永続化
      await userRepository.save(user)
      
      // 5. 外部サービスとの連携
      await Promise.all([
        emailService.sendWelcomeEmail(validatedEmail, validatedName),
        emailService.sendVerificationEmail(validatedEmail, verificationToken)
      ])
      
      // 6. イベントの生成
      const event: UserRegisteredEvent = {
        type: 'UserRegistered',
        userId,
        email: validatedEmail,
        registeredAt: user.createdAt
      }
      
      return event
      
    } catch (error) {
      // エラーハンドリング
      if (error instanceof Error) {
        return {
          type: 'ExternalServiceError',
          service: 'UserRegistration',
          message: error.message
        }
      }
      
      return {
        type: 'ExternalServiceError',
        service: 'UserRegistration',
        message: 'Unknown error occurred'
      }
    }
  }
  
  return { registerUser }
}

// ヘルパー関数(実装は省略)
const hashPassword = async (password: string): Promise<string> => {
  // bcrypt等を使用したハッシュ化
  return `hashed_${password}`
}

const generateVerificationToken = (): string => {
  return crypto.randomUUID()
}

注文処理のApplication Service

type OrderRepository = {
  readonly save: (order: Order) => Promise<void>
  readonly findById: (id: OrderId) => Promise<Order | null>
}

type InventoryService = {
  readonly checkAvailability: (items: readonly OrderItem[]) => Promise<InventoryCheckResult>
  readonly reserveItems: (items: readonly OrderItem[]) => Promise<void>
}

type PaymentService = {
  readonly processPayment: (amount: Money, paymentMethod: PaymentMethod) => Promise<PaymentResult>
}

// 注文関連の型定義
type Order = {
  readonly id: OrderId
  readonly customerId: CustomerId
  readonly items: readonly OrderItem[]
  readonly totalAmount: Money
  readonly status: OrderStatus
  readonly createdAt: Date
}

type OrderItem = {
  readonly productId: ProductId
  readonly quantity: number
  readonly unitPrice: Money
  readonly totalPrice: Money
}

type OrderId = string & { readonly _brand: 'OrderId' }
type OrderStatus = 'pending' | 'confirmed' | 'paid' | 'shipped' | 'delivered' | 'cancelled'

type CreateOrderCommand = {
  readonly customerId: CustomerId
  readonly items: readonly {
    readonly productId: ProductId
    readonly quantity: number
  }[]
  readonly paymentMethod: PaymentMethod
}

type PaymentMethod = {
  readonly type: 'credit_card' | 'bank_transfer' | 'digital_wallet'
  readonly details: Record<string, string>
}

type InventoryCheckResult = 
  | { type: 'Available' }
  | { type: 'InsufficientStock'; unavailableItems: readonly ProductId[] }

type PaymentResult = 
  | { type: 'Success'; transactionId: string }
  | { type: 'Failed'; reason: string }

type OrderCreationError = 
  | { type: 'CustomerNotFound'; customerId: CustomerId }
  | { type: 'EmptyOrder' }
  | { type: 'InsufficientStock'; products: readonly ProductId[] }
  | { type: 'PaymentFailed'; reason: string }
  | { type: 'PricingError'; message: string }

// 注文処理Application Service
const createOrderProcessingService = (
  userRepository: UserRepository,
  productRepository: ProductRepository,
  orderRepository: OrderRepository,
  inventoryService: InventoryService,
  paymentService: PaymentService
) => {
  const createOrder = async (
    command: CreateOrderCommand
  ): Promise<Result<Order, OrderCreationError>> => {
    try {
      // 1. 顧客の存在確認
      const customer = await userRepository.findById(command.customerId)
      if (!customer) {
        return { type: 'CustomerNotFound', customerId: command.customerId }
      }
      
      // 2. 注文内容の基本検証
      if (command.items.length === 0) {
        return { type: 'EmptyOrder' }
      }
      
      // 3. 商品情報の取得と注文項目の構築
      const orderItems: OrderItem[] = []
      let totalAmount = 0
      let currency: Currency | undefined
      
      for (const item of command.items) {
        const product = await productRepository.findById(item.productId)
        if (!product) {
          continue // 商品が見つからない場合はスキップ
        }
        
        // 価格計算(Domain Serviceを使用)
        const calculatedPrice = calculatePrice(product, customer, item.quantity)
        
        // 通貨の統一性確認
        if (!currency) {
          currency = calculatedPrice.currency
        } else if (currency !== calculatedPrice.currency) {
          return { type: 'PricingError', message: 'Mixed currencies not supported' }
        }
        
        const orderItem: OrderItem = {
          productId: item.productId,
          quantity: item.quantity,
          unitPrice: {
            amount: calculatedPrice.amount / item.quantity,
            currency: calculatedPrice.currency
          },
          totalPrice: calculatedPrice
        }
        
        orderItems.push(orderItem)
        totalAmount += calculatedPrice.amount
      }
      
      if (orderItems.length === 0) {
        return { type: 'EmptyOrder' }
      }
      
      // 4. 在庫確認
      const inventoryCheck = await inventoryService.checkAvailability(orderItems)
      if (inventoryCheck.type === 'InsufficientStock') {
        return { 
          type: 'InsufficientStock', 
          products: inventoryCheck.unavailableItems 
        }
      }
      
      // 5. 在庫の予約
      await inventoryService.reserveItems(orderItems)
      
      // 6. 注文オブジェクトの生成
      const order: Order = {
        id: crypto.randomUUID() as OrderId,
        customerId: command.customerId,
        items: orderItems,
        totalAmount: { amount: totalAmount, currency: currency! },
        status: 'pending',
        createdAt: new Date()
      }
      
      // 7. 注文の永続化
      await orderRepository.save(order)
      
      // 8. 決済処理
      const paymentResult = await paymentService.processPayment(
        order.totalAmount,
        command.paymentMethod
      )
      
      if (paymentResult.type === 'Failed') {
        // 決済失敗時は在庫予約を解除(実装は省略)
        return { type: 'PaymentFailed', reason: paymentResult.reason }
      }
      
      // 9. 注文ステータスの更新
      const paidOrder: Order = { ...order, status: 'paid' }
      await orderRepository.save(paidOrder)
      
      return paidOrder
      
    } catch (error) {
      return { 
        type: 'PricingError', 
        message: error instanceof Error ? error.message : 'Unknown error'
      }
    }
  }
  
  return { createOrder }
}

Infrastructure Serviceの実装

Infrastructure Serviceは、技術的な処理を担当し、Domain ServiceやApplication Serviceで定義されたインターフェースを実装します。

メール送信サービス

// 設定の型定義
type EmailConfig = {
  readonly smtpHost: string
  readonly smtpPort: number
  readonly username: string
  readonly password: string
  readonly fromAddress: string
}

// メールテンプレートの型定義
type EmailTemplate = {
  readonly subject: string
  readonly htmlBody: string
  readonly textBody: string
}

// メール送信Infrastructure Service
const createEmailInfrastructureService = (
  config: EmailConfig
): EmailService => {
  
  const sendWelcomeEmail = async (email: Email, name: string): Promise<void> => {
    const template = getWelcomeEmailTemplate(name)
    await sendEmail(email, template)
  }
  
  const sendVerificationEmail = async (email: Email, token: string): Promise<void> => {
    const template = getVerificationEmailTemplate(token)
    await sendEmail(email, template)
  }
  
  const sendEmail = async (email: Email, template: EmailTemplate): Promise<void> => {
    try {
      // 実際のSMTP送信処理(例:nodemailer使用)
      console.log(`Sending email to ${email}`)
      console.log(`Subject: ${template.subject}`)
      console.log(`Body: ${template.textBody}`)
      
      // 実装例(実際にはnodemailer等を使用)
      // const transporter = nodemailer.createTransporter({ ... })
      // await transporter.sendMail({
      //   from: config.fromAddress,
      //   to: email,
      //   subject: template.subject,
      //   html: template.htmlBody,
      //   text: template.textBody
      // })
      
    } catch (error) {
      console.error(`Failed to send email to ${email}:`, error)
      throw new Error(`Email sending failed: ${error}`)
    }
  }
  
  return {
    sendWelcomeEmail,
    sendVerificationEmail
  }
}

// テンプレート生成関数
const getWelcomeEmailTemplate = (name: string): EmailTemplate => ({
  subject: 'Welcome to Our Service!',
  htmlBody: `
    <h1>Welcome, ${name}!</h1>
    <p>Thank you for registering with our service.</p>
    <p>We're excited to have you on board!</p>
  `,
  textBody: `
    Welcome, ${name}!
    
    Thank you for registering with our service.
    We're excited to have you on board!
  `
})

const getVerificationEmailTemplate = (token: string): EmailTemplate => ({
  subject: 'Please verify your email address',
  htmlBody: `
    <h1>Email Verification</h1>
    <p>Please click the link below to verify your email address:</p>
    <a href="https://example.com/verify?token=${token}">Verify Email</a>
  `,
  textBody: `
    Email Verification
    
    Please visit the following link to verify your email address:
    https://example.com/verify?token=${token}
  `
})

データベースRepository

// データベース接続の抽象化
type DatabaseConnection = {
  readonly query: <T>(sql: string, params: readonly unknown[]) => Promise<T[]>
  readonly execute: (sql: string, params: readonly unknown[]) => Promise<void>
}

// UserRepository の実装
const createUserRepository = (db: DatabaseConnection): UserRepository => {
  
  const findById = async (id: UserId): Promise<User | null> => {
    const rows = await db.query<UserRow>(
      'SELECT id, email, name, created_at FROM users WHERE id = ?',
      [id]
    )
    
    return rows.length > 0 ? mapRowToUser(rows[0]) : null
  }
  
  const findByEmail = async (email: Email): Promise<User | null> => {
    const rows = await db.query<UserRow>(
      'SELECT id, email, name, created_at FROM users WHERE email = ?',
      [email]
    )
    
    return rows.length > 0 ? mapRowToUser(rows[0]) : null
  }
  
  const save = async (user: User): Promise<void> => {
    await db.execute(
      `INSERT INTO users (id, email, name, created_at) 
       VALUES (?, ?, ?, ?)
       ON DUPLICATE KEY UPDATE 
       email = VALUES(email), 
       name = VALUES(name)`,
      [user.id, user.email, user.name, user.createdAt.toISOString()]
    )
  }
  
  const existsByEmail = async (email: Email): Promise<boolean> => {
    const rows = await db.query<{ count: number }>(
      'SELECT COUNT(*) as count FROM users WHERE email = ?',
      [email]
    )
    
    return rows[0].count > 0
  }
  
  return {
    findById,
    findByEmail,
    save,
    existsByEmail
  }
}

// データベース行の型定義
type UserRow = {
  id: string
  email: string
  name: string
  created_at: string
}

// マッピング関数
const mapRowToUser = (row: UserRow): User => ({
  id: row.id as UserId,
  email: row.email as Email,
  name: row.name,
  createdAt: new Date(row.created_at)
})

外部API連携サービス

// 決済サービスの実装
type PaymentConfig = {
  readonly apiKey: string
  readonly apiUrl: string
  readonly timeout: number
}

const createPaymentInfrastructureService = (
  config: PaymentConfig
): PaymentService => {
  
  const processPayment = async (
    amount: Money, 
    paymentMethod: PaymentMethod
  ): Promise<PaymentResult> => {
    try {
      // 外部決済APIとの通信
      const requestBody = {
        amount: amount.amount,
        currency: amount.currency,
        payment_method: {
          type: paymentMethod.type,
          ...paymentMethod.details
        }
      }
      
      const response = await fetch(`${config.apiUrl}/payments`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': `Bearer ${config.apiKey}`
        },
        body: JSON.stringify(requestBody),
        signal: AbortSignal.timeout(config.timeout)
      })
      
      if (!response.ok) {
        const errorData = await response.json()
        return {
          type: 'Failed',
          reason: errorData.message || 'Payment processing failed'
        }
      }
      
      const data = await response.json()
      return {
        type: 'Success',
        transactionId: data.transaction_id
      }
      
    } catch (error) {
      if (error instanceof Error) {
        return {
          type: 'Failed',
          reason: `Payment service error: ${error.message}`
        }
      }
      
      return {
        type: 'Failed',
        reason: 'Unknown payment error'
      }
    }
  }
  
  return { processPayment }
}

実践的な設計パターン

Dependency Injectionパターン

関数型アプローチでは、高階関数を使ってDependency Injectionを実現します:

// サービス全体をまとめるコンテナ
type ServiceContainer = {
  readonly userRepository: UserRepository
  readonly emailService: EmailService
  readonly paymentService: PaymentService
  readonly userRegistrationService: ReturnType<typeof createUserRegistrationService>
  readonly orderProcessingService: ReturnType<typeof createOrderProcessingService>
}

// コンテナの作成
const createServiceContainer = (
  dbConnection: DatabaseConnection,
  emailConfig: EmailConfig,
  paymentConfig: PaymentConfig
): ServiceContainer => {
  
  // Infrastructure Serviceの作成
  const userRepository = createUserRepository(dbConnection)
  const emailService = createEmailInfrastructureService(emailConfig)
  const paymentService = createPaymentInfrastructureService(paymentConfig)
  
  // Application Serviceの作成(依存性を注入)
  const userRegistrationService = createUserRegistrationService(
    userRepository,
    emailService
  )
  
  const orderProcessingService = createOrderProcessingService(
    userRepository,
    createProductRepository(dbConnection), // 省略した実装
    createOrderRepository(dbConnection),    // 省略した実装
    createInventoryService(),               // 省略した実装
    paymentService
  )
  
  return {
    userRepository,
    emailService,
    paymentService,
    userRegistrationService,
    orderProcessingService
  }
}

Compositionパターン

複数のServiceを組み合わせて、より複雑な処理を実現:

// 複数のサービスを組み合わせた複合処理
const createOrderWorkflowService = (
  orderProcessingService: ReturnType<typeof createOrderProcessingService>,
  emailService: EmailService,
  inventoryService: InventoryService
) => {
  
  const processCompleteOrder = async (
    command: CreateOrderCommand
  ): Promise<Result<OrderWorkflowResult, OrderWorkflowError>> => {
    
    // 1. 注文の作成
    const orderResult = await orderProcessingService.createOrder(command)
    if ('type' in orderResult) {
      return mapOrderErrorToWorkflowError(orderResult)
    }
    
    const order = orderResult
    
    try {
      // 2. 顧客への注文確認メール
      await emailService.sendOrderConfirmationEmail(order)
      
      // 3. 在庫システムへの通知
      await inventoryService.updateStock(order.items)
      
      // 4. 発送準備の開始
      await initiateShippingProcess(order)
      
      return {
        order,
        status: 'completed',
        processedAt: new Date()
      }
      
    } catch (error) {
      // エラー時のロールバック処理
      await handleOrderProcessingError(order, error)
      
      return {
        type: 'ProcessingFailed',
        orderId: order.id,
        error: error instanceof Error ? error.message : 'Unknown error'
      }
    }
  }
  
  return { processCompleteOrder }
}

type OrderWorkflowResult = {
  readonly order: Order
  readonly status: 'completed'
  readonly processedAt: Date
}

type OrderWorkflowError = 
  | { type: 'OrderCreationFailed'; details: OrderCreationError }
  | { type: 'ProcessingFailed'; orderId: OrderId; error: string }

// エラーマッピング関数
const mapOrderErrorToWorkflowError = (
  error: OrderCreationError
): OrderWorkflowError => ({
  type: 'OrderCreationFailed',
  details: error
})

// ヘルパー関数(実装は省略)
const initiateShippingProcess = async (order: Order): Promise<void> => {
  // 発送処理の実装
}

const handleOrderProcessingError = async (order: Order, error: unknown): Promise<void> => {
  // エラーハンドリングとロールバック処理
}

よくある問題とその対策

1. 貧血症モデルの回避

// ❌ 悪い例:すべてのロジックがServiceに集中
const userService = {
  updateProfile: async (userId: UserId, name: string, email: string) => {
    const user = await userRepository.findById(userId)
    if (!user) throw new Error('User not found')
    
    // すべてのバリデーションと更新ロジックがServiceに
    user.name = name
    user.email = email as Email
    await userRepository.save(user)
  },
  
  changePassword: async (userId: UserId, oldPassword: string, newPassword: string) => {
    // パスワード変更ロジックもServiceに
  },
  
  deactivateAccount: async (userId: UserId) => {
    // アカウント無効化ロジックもServiceに
  }
}

// ✅ 良い例:Entityの操作は専用の関数群として分離
const userOperations = {
  updateProfile: (
    user: User, 
    name: string, 
    email: Email
  ): Result<User, ProfileUpdateError> => {
    // バリデーション
    const nameValidation = validateUserName(name)
    if ('type' in nameValidation) return nameValidation
    
    const emailValidation = validateUserEmail(email)
    if ('type' in emailValidation) return emailValidation
    
    // 不変更新
    return {
      ...user,
      name: nameValidation,
      email: emailValidation
    }
  },
  
  changePassword: (
    user: User, 
    newPasswordHash: string
  ): User => ({
    ...user,
    passwordHash: newPasswordHash,
    passwordChangedAt: new Date()
  }),
  
  deactivate: (user: User): User => ({
    ...user,
    status: 'inactive',
    deactivatedAt: new Date()
  })
}

// Application Serviceは調整に専念
const createUserApplicationService = (
  userRepository: UserRepository,
  emailService: EmailService
) => ({
  updateProfile: async (
    command: UpdateProfileCommand
  ): Promise<Result<User, UpdateProfileError>> => {
    const user = await userRepository.findById(command.userId)
    if (!user) {
      return { type: 'UserNotFound', userId: command.userId }
    }
    
    // Domain操作を呼び出し
    const updateResult = userOperations.updateProfile(
      user, 
      command.name, 
      command.email
    )
    
    if ('type' in updateResult) return updateResult
    
    // 永続化
    await userRepository.save(updateResult)
    
    // 通知
    await emailService.sendProfileUpdatedEmail(updateResult.email)
    
    return updateResult
  }
})

2. Serviceの肥大化防止

// ❌ 悪い例:巨大なサービス
const orderService = {
  createOrder: () => { /* ... */ },
  cancelOrder: () => { /* ... */ },
  updateOrder: () => { /* ... */ },
  calculateShipping: () => { /* ... */ },
  processPayment: () => { /* ... */ },
  generateInvoice: () => { /* ... */ },
  sendNotifications: () => { /* ... */ },
  updateInventory: () => { /* ... */ },
  // 数十のメソッド...
}

// ✅ 良い例:責務ごとに分割
namespace OrderServices {
  // 注文作成に特化
  export const creation = {
    createOrder: (/* params */) => { /* 注文作成 */ },
    validateOrderItems: (/* params */) => { /* 注文項目検証 */ }
  }
  
  // 注文管理に特化
  export const management = {
    cancelOrder: (/* params */) => { /* 注文キャンセル */ },
    updateOrder: (/* params */) => { /* 注文更新 */ }
  }
  
  // 配送関連に特化
  export const shipping = {
    calculateShipping: (/* params */) => { /* 配送料計算 */ },
    scheduleDelivery: (/* params */) => { /* 配送スケジュール */ }
  }
  
  // 決済関連に特化
  export const payment = {
    processPayment: (/* params */) => { /* 決済処理 */ },
    refundPayment: (/* params */) => { /* 返金処理 */ }
  }
}

// 使用例
const order = await OrderServices.creation.createOrder(command)
const shippingCost = OrderServices.shipping.calculateShipping(order)

3. テスタビリティの向上

// テスト用のモックサービス作成
const createMockUserRepository = (): UserRepository => {
  const users = new Map<UserId, User>()
  
  return {
    findById: async (id: UserId) => users.get(id) || null,
    findByEmail: async (email: Email) => {
      for (const user of users.values()) {
        if (user.email === email) return user
      }
      return null
    },
    save: async (user: User) => {
      users.set(user.id, user)
    },
    existsByEmail: async (email: Email) => {
      for (const user of users.values()) {
        if (user.email === email) return true
      }
      return false
    }
  }
}

const createMockEmailService = (): EmailService => ({
  sendWelcomeEmail: async () => {
    console.log('Mock: Welcome email sent')
  },
  sendVerificationEmail: async () => {
    console.log('Mock: Verification email sent')
  }
})

// テストの例
const testUserRegistration = async () => {
  // Arrange
  const mockUserRepository = createMockUserRepository()
  const mockEmailService = createMockEmailService()
  const userRegistrationService = createUserRegistrationService(
    mockUserRepository,
    mockEmailService
  )
  
  const command: RegisterUserCommand = {
    email: 'test@example.com',
    name: 'Test User',
    password: 'SecurePassword123!'
  }
  
  // Act
  const result = await userRegistrationService.registerUser(command)
  
  // Assert
  if ('type' in result) {
    throw new Error(`Registration failed: ${result.type}`)
  }
  
  console.log('User registration test passed:', result)
  
  // 副作用の確認
  const savedUser = await mockUserRepository.findByEmail('test@example.com' as Email)
  console.log('User saved successfully:', savedUser !== null)
}

まとめ

本記事では、TypeScriptの関数型アプローチを用いたDDDのServiceパターンの実装方法を詳しく解説しました。

重要なポイント

1. 適切な責務分離

  • Domain Service: ビジネスロジック
  • Application Service: ユースケースの調整
  • Infrastructure Service: 技術的な処理

2. 型安全性の活用

  • ブランド型による型の強化
  • Result型によるエラーハンドリング
  • 詳細なエラー型定義

3. 関数型アプローチの利点

  • 不変性による予測可能性
  • 純粋関数によるテスタビリティ
  • 関数合成による柔軟性

4. 実践的な設計パターン

  • Dependency Injection
  • Service Composition
  • 適切な粒度での分割

実装時の注意点

  • 貧血症モデルを避ける: EntityやValue Objectの責務を適切に分離
  • Serviceの肥大化を防ぐ: 単一責任の原則を守る
  • テスタビリティを重視: モックしやすい設計にする
  • エラーハンドリング: 型安全なエラー処理を実装

このアプローチにより、保守性が高く、テストしやすい、堅牢なDDDアプリケーションを構築することができます。実際のプロジェクトでは、チームの習熟度や要件に応じて、段階的に導入することをお勧めします。


この記事が、DDDとTypeScriptを組み合わせた開発の参考になれば幸いです。質問や改善点があれば、お気軽にコメントください。

Discussion