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. UserLibraryをlibrary.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が先に存在し、ユーザーが後から紐付ける。UserLibraryはLibraryの集約の一部として存在する。DDDの集約の観点で「Libraryが根」と判断し、同じファイルに置いた。
UserとLibraryの中間テーブルとして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