😄

Goでバッチ処理をClean Architectureで書いたときの設計判断6つ

に公開

前提

図書館の蔵書状況をカーリルAPIで確認し、入荷があればメール通知するバッチ。
レイヤー構成はdomain / usecase / infraの3層。HTTPハンドラは持たない。

1. CalilのレスポンスをInfra内で変換し、domainに持ち込まなかった

カーリルAPIのレスポンスは以下のような形をしている。

{
  "books": {
    "4087461823": {
      "Tokyo_Pref": {
        "reserveurl": "https://...",
        "libkey": { "渋谷図書館": "蔵書あり", "新宿図書館": "館内のみ" }
      }
    }
  }
}

これをそのままdomainに持ち込むと、APIの都合をdomain層が知ることになる。

やったこと: Infra内のcalilResponse型(プライベート)でパースし、usecaseのインターフェースが返す型はdomain.CheckResultにした。

// usecase/check_library.go
type CalilRepository interface {
    FetchHoldings(ctx context.Context, isbn10List []string, systemID string) (map[string][]domain.CheckResult, error)
}

// domain/subscription.go
type CheckResult struct {
    Status     string
    ReserveURL string
}
// infra/calil_repository.go (プライベート型、外に出ない)
type calilResponse struct {
    Continue int                                      `json:"continue"`
    Books    map[string]map[string]calilSystemInfo    `json:"books"`
}

type calilSystemInfo struct {
    ReserveURL string            `json:"reserveurl"`
    LibKey     map[string]string `json:"libkey"`
}

func parseToCheckResult(resp calilResponse, systemID string) map[string][]domain.CheckResult {
    result := make(map[string][]domain.CheckResult)
    for isbn, systemMap := range resp.Books {
        info, ok := systemMap[systemID]
        if !ok {
            continue
        }
        var holdings []domain.CheckResult
        for _, status := range info.LibKey {
            holdings = append(holdings, domain.CheckResult{
                Status:     status,
                ReserveURL: info.ReserveURL,
            })
        }
        result[isbn] = holdings
    }
    return result
}

判断の理由: usecaseはAPIのJSONキー名を知る必要がない。calilResponseはInfraのプライベート型にし、domainに公開する型はCheckResultだけにした。カーリルAPIのスキーマが変わっても、infra内だけ直せばいい。

domain.CheckResultという型自体が境界線になっている。usecaseはCalilの仕様を知らず「在庫があるかどうか」しか知らない。CalilのAPIが変わっても、変換する場所(infra)だけ直せばusecaseは一切触らなくていい。


2. CalilのエラーをInfra層で吸収した

カーリルAPIはポーリングが必要で、ネットワークエラーやパースエラーが起きる可能性がある。

やったこと: エラーをusecaseに返さず、空のマップを返してログだけ出す。

// infra/calil_repository.go
func (r *CalilRepository) FetchHoldings(ctx context.Context, isbn10List []string, systemID string) (map[string][]domain.CheckResult, error) {
    params := map[string]string{
        "isbn":     strings.Join(isbn10List, ","),
        "systemid": systemID,
    }
    retryCount := 0
    for {
        data, err := r.client.Get(params)
        if err != nil {
            log.Printf("calil API error: %v", err)
            return map[string][]domain.CheckResult{}, nil  // エラーを吸収
        }
        var resp calilResponse
        if err := json.Unmarshal(data, &resp); err != nil {
            log.Printf("calil response parse error: %v", err)
            return map[string][]domain.CheckResult{}, nil  // エラーを吸収
        }
        if resp.Continue == calilIsComplete || retryCount >= maxRetryCount {
            return parseToCheckResult(resp, systemID), nil
        }
        time.Sleep(pollingWaitTime)
        retryCount++
    }
}

usecase側はシンプルになる。

// usecase/check_library.go
holdings, err := s.calilRepo.FetchHoldings(ctx, []string{book.ISBN10}, library.SystemID)
if err != nil {
    return fmt.Errorf("fetch holdings: %w", err)
}
// holdingsが空ならループを抜けるだけ
for _, result := range holdings[book.ISBN10] {
    if result.IsAvailable() {
        // 通知処理
    }
}

判断の理由: 判断基準は「このエラーが起きたときユーザー体験をどうしたいか」。
このバッチは在庫があればメールで通知するのが目的なので、カーリルが取れなかった場合は今回の実行をスキップするだけで十分。
外部/内部サービス起因かは補助的な判断材料になる。

ただし、エラーを吸収するとusecaseがCalilエラーを識別できないので、「エラー時だけ挙動を変える」判断を後から追加できない点はトレードオフ。


3. メール送信をEmailRepositoryではなくEmailServiceと命名した

Repositoryサフィックスを見ると「データの永続化・取得」を連想する。メール送信はそれではない。

やったこと: インターフェース名をEmailServiceにした。

// usecase/check_library.go
type EmailService interface {
    Send(ctx context.Context, to string, subject string, body string) error
}
// infra/mail_repository.go
const (
    gmailSMTPHost = "smtp.gmail.com"
    gmailSMTPPort = "465"
)

type GmailService struct {
    user     string
    password string
}

var _ usecase.EmailService = (*GmailService)(nil)  // コンパイル時インターフェース検証

func (g *GmailService) Send(ctx context.Context, to string, subject string, body string) error {
    // ...
}

判断の理由: Clean ArchitectureでRepositoryは「外部データソースの抽象化」。DBやAPIから取得・保存するものが対象。メール送信はデータを永続化しているわけではない。Serviceの方が意図が伝わる。

  • Repository: 外部からデータを取得・保存する
  • Service: 外部に処理を実行させる(送信、通知など)

4. UserLibrarylibrary.goに置いた

UserLibrary(ユーザーと図書館の登録情報)は独立したファイルに置くべきか迷った。

// domain/library.go
type Library struct {
    ID       int    `json:"id"`
    SystemID string `json:"system_id"`
    Name     string `json:"name"`
}

type UserLibrary struct {
    ID        int `json:"id"`
    UserID    int `json:"user_id"`
    LibraryID int `json:"library_id"`
}

判断の理由: 登録のフローを考えると、Libraryが先に存在し、ユーザーが後から紐付ける。UserLibraryLibraryの集約の一部として存在する。DDDの集約の観点で「Libraryが根」と判断し、同じファイルに置いた。

UserLibraryの中間テーブルとしてuser.goに置く考え方もある。今回はどちらが「所有している」かで判断した。


5. バッチなのでhandler層がない

Webサービスならhandler → usecase → infraの3層になる。このプロジェクトはバッチなのでmain.goが直接usecaseを呼ぶ。

// main.go
func main() {
    // 環境変数から設定取得
    supabaseURL := os.Getenv("SUPABASE_URL")
    supabaseKey := os.Getenv("SUPABASE_KEY")
    calilAPIKey := os.Getenv("CALIL_API_KEY")
    gmailUser := os.Getenv("GMAIL_USER")
    gmailPassword := os.Getenv("GMAIL_PASSWORD")

    // 依存性の組み立て(Composition Root)
    supabaseClient, err := infra.NewSupabaseClient(supabaseURL, supabaseKey)
    if err != nil {
        panic(err)
    }
    calilClient := infra.NewCalilClient(calilAPIKey)

    subscriptionRepo := infra.NewBookSubscriptionRepository(supabaseClient)
    bookRepo := infra.NewBookRepository(supabaseClient)
    libraryRepo := infra.NewLibraryRepository(supabaseClient)
    userRepo := infra.NewUserRepository(supabaseClient)
    calilRepo := infra.NewCalilRepository(calilClient)
    emailService := infra.NewGmailService(gmailUser, gmailPassword)

    s := usecase.NewCheckLibraryHoldingService(
        subscriptionRepo,
        bookRepo,
        libraryRepo,
        userRepo,
        calilRepo,
        emailService,
    )

    ctx := context.Background()
    if err := s.Run(ctx); err != nil {
        log.Fatalf("run: %v", err)
    }
}

判断の理由: バッチに入力を変換する層は不要。YAGNIの原則で、将来API化するときに追加すればいい。


6. UpdateLastNotifiedAtはメール送信成功後に呼ぶ

// usecase/check_library.go
if err := s.emailService.Send(ctx, user.Email, subject, body); err != nil {
    return fmt.Errorf("send email: %w", err)
}
if err := s.subscriptionRepo.UpdateLastNotifiedAt(ctx, subscription.ID); err != nil {
    return fmt.Errorf("update last notified: %w", err)
}

やったこと: メール送信が成功してから通知済みフラグを更新する。

判断の理由: 順番が逆になると、メール送信が失敗してもDBは「通知済み」になる。次回のバッチ実行でスキップされるので、そのユーザーには永遠に通知が届かなくなる。「成功した事実」を記録するのだから、成功後に更新するのが正しい。


まとめ

判断 選んだこと 理由
CalilのAPI型 Infra内でプライベート型、外はdomain.CheckResult APIの都合をdomainに持ち込まない
CalilエラーHandling Infra層で吸収、空マップを返す usecaseの動作に差がない
メール送信の命名 EmailService Repositoryは永続化の抽象化、メール送信は違う
UserLibraryの置き場 library.go(集約) Libraryが先に存在し、UserLibraryはその集約
handler層 作らない バッチにHTTPリクエストはない、YAGNI
UpdateLastNotifiedAtの順番 メール送信成功後 失敗してもDBが更新されると永遠に通知されなくなる

設計判断の多くは「どこまでをこのレイヤーの責任にするか」という境界の問題だった。
迷ったときは「変更が起きたとき、どのファイルだけ直せばいいか」を考えると境界が見えてくる。

Discussion