0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【どう実装する?シリーズ #9】データ変換処理、そもそも必要?

Posted at

前回の記事では、暗黙的な副作用を明示的にしました。削除したいものは明示的に指定することで、コードの意図が明確になりました。

今回は「データ整形ロジックの混在」問題に取り組みます。

問題の再確認

現在のLP保存処理には、データ整形処理が含まれています。

class LandingPageService
{
    public function update(int $lpId, array $data, int $userId): int
    {
        // バリデーション
        // ...

        DB::beginTransaction();
        try {
            // LP本体を保存
            $lp = Lp::updateOrCreate(['id' => $lpId], [
                'name' => $data['name'],
                'status' => $data['status'],
                'template' => $data['template'],
            ]);

            // リクエストデータを内部形式に整形 ← これ
            $formattedData = $this->formatData($data);

            // ステップ・CTAを保存
            $this->saveStepsAndCtas($formattedData, $lp);

            // ...
        }
    }

    private function formatData(array $data): array
    {
        // 何らかの変換処理
        // ...
    }
}

「リクエストデータを内部形式に整形」と書かれていますが、そもそもなぜ整形が必要なのでしょうか?

なぜ変換が必要になるのか

データ変換が必要になる典型的な理由を見てみましょう。

ケース1: フロントエンドの都合とバックエンドの都合

フロントエンドが送りたい形式

// ステップとCTAを分けて送信
{
  "steps": [
    {"id": 100, "content": "ステップ1"},
    {"id": 200, "content": "ステップ2"}
  ],
  "ctas": [
    {"id": 300, "content": "申し込む"}
  ]
}

バックエンドで扱いたい形式

// ステップとCTAを統合して扱う
[
  ["id" => 100, "content" => "ステップ1", "is_cta" => false, "order" => 1],
  ["id" => 200, "content" => "ステップ2", "is_cta" => false, "order" => 2],
  ["id" => 300, "content" => "申し込む", "is_cta" => true, "order" => -1],
]

このギャップを埋めるためにformatData()が必要になります。

ケース2: ネストの深さの違い

リクエスト

{
  "steps": [
    {
      "id": 100,
      "content": "...",
      "individual_cta": {
        "id": 101,
        "content": "個別CTA"
      }
    }
  ]
}

保存したい形式

// 個別CTAを別のレコードとして保存
Step::create(['id' => 100, 'content' => '...', 'related_cta_id' => 101]);
Step::create(['id' => 101, 'content' => '個別CTA', 'is_cta' => true]);

ネストを解いて、フラットな構造にする必要があります。

ケース3: 歴史的経緯

// 古いAPIの形式(互換性維持)
{
  "step_list": [...],  // 昔は "step_list" だった
  "cta_list": [...]
}

// 新しいコード内では
{
  "steps": [...],  // 今は "steps"
  "ctas": [...]
}

APIの互換性を維持するため、変換が必要になります。

変換処理の何が問題か

1. Service層にHTTPの知識が漏れる

class LandingPageService
{
    private function formatData(array $data): array
    {
        // リクエストの構造を知っている前提
        $steps = $data['steps'] ?? [];
        $ctas = $data['ctas'] ?? [];
        
        // HTTPリクエストの構造に依存している
        // ...
    }
}

Serviceが「HTTPリクエストがこういう構造で来る」という知識を持っています。

問題点:

  • Jobやコマンドから呼ぶとき、わざわざHTTPリクエスト形式に合わせる必要がある
  • GraphQL APIを追加したら、また別の変換が必要?

2. 変換ロジックが見えにくい

private function formatData(array $data): array
{
    // 何をしているのか分かりにくい
    $result = [];
    
    foreach ($data['steps'] as $index => $step) {
        $result[] = [
            'id' => $step['id'],
            'content' => $step['content'],
            'is_cta' => false,
            'order' => $index,
        ];
        
        if (isset($step['individual_cta'])) {
            $result[] = [
                'id' => $step['individual_cta']['id'],
                'content' => $step['individual_cta']['content'],
                'is_cta' => true,
                'order' => -1,
                'related_cta_id' => $step['id'],
            ];
        }
    }
    
    foreach ($data['ctas'] as $cta) {
        $result[] = [
            'id' => $cta['id'],
            'content' => $cta['content'],
            'is_cta' => true,
            'order' => -1,
        ];
    }
    
    return $result;
}

100行以上の変換ロジックになることもあります。

3. テストで毎回リクエスト形式を作る必要

public function test_LP更新()
{
    $service = new LandingPageService();
    
    // テストのたびにリクエスト形式を組み立てる
    $data = [
        'name' => 'テストLP',
        'steps' => [
            ['id' => 100, 'content' => '...'],
        ],
        'ctas' => [
            ['id' => 200, 'content' => '...'],
        ],
    ];
    
    $service->update(1, $data, 1);
}

Service単体をテストしたいのに、HTTPリクエストの構造を知っている必要があります。

4. 再利用時に困る

// Jobから呼ぶ場合
class LpImportJob implements ShouldQueue
{
    public function handle(LandingPageService $service)
    {
        foreach ($this->importData as $data) {
            // インポートデータをHTTPリクエスト形式に変換する必要がある
            $requestFormat = $this->convertToRequestFormat($data);
            
            $service->update(null, $requestFormat, $this->userId);
        }
    }
    
    private function convertToRequestFormat(array $data): array
    {
        // わざわざHTTPリクエスト形式に合わせる...
    }
}

そもそも変換を不要にできないか

「変換処理をどこに置くか」を考える前に、「そもそも変換が必要なのか」を問うべきです。

根本的な原因: API設計の問題

多くの場合、データ変換が必要になるのはAPI設計がバックエンドの実装に合っていないからです。

悪い例: フロントエンドの都合だけで設計

// フロントエンドが送りやすい形式
POST /api/lp/update
{
  "steps": [...],
  "ctas": [...]
}

// バックエンドで変換が必要
// steps と ctas を統合して保存

良い例: バックエンドの実装に合わせる

// バックエンドがそのまま保存できる形式
POST /api/lp/update
{
  "name": "LPタイトル",
  "status": "active",
  "items": [  // steps と ctas を統合
    {"id": 100, "content": "...", "type": "step", "order": 1},
    {"id": 200, "content": "...", "type": "step", "order": 2},
    {"id": 300, "content": "...", "type": "cta", "order": 3}
  ]
}

改善: API設計を見直す

Before(変換が必要)

リクエスト:

{
  "steps": [
    {"id": 100, "content": "ステップ1"},
    {"id": 200, "content": "ステップ2", "individual_cta": {"id": 201, "content": "個別CTA"}}
  ],
  "ctas": [
    {"id": 300, "content": "共通CTA"}
  ]
}

Service:

private function formatData(array $data): array
{
    $result = [];
    
    // steps を処理
    foreach ($data['steps'] as $index => $step) {
        $result[] = ['id' => $step['id'], 'is_cta' => false, 'order' => $index];
        
        // individual_cta を処理
        if (isset($step['individual_cta'])) {
            $result[] = ['id' => $step['individual_cta']['id'], 'is_cta' => true, ...];
        }
    }
    
    // ctas を処理
    foreach ($data['ctas'] as $cta) {
        $result[] = ['id' => $cta['id'], 'is_cta' => true, 'order' => -1];
    }
    
    return $result;
}

複雑な変換ロジックが必要です。

After(変換不要)

リクエスト:

{
  "name": "LPタイトル",
  "status": "active",
  "items": [
    {"id": 100, "type": "step", "content": "ステップ1", "order": 1},
    {"id": 200, "type": "step", "content": "ステップ2", "order": 2},
    {"id": 201, "type": "cta", "content": "個別CTA", "order": 2, "related_to": 200},
    {"id": 300, "type": "cta", "content": "共通CTA", "order": 3}
  ]
}

Service:

public function update(int $lpId, array $data, int $userId): int
{
    DB::beginTransaction();
    try {
        $lp = Lp::updateOrCreate(['id' => $lpId], [
            'name' => $data['name'],
            'status' => $data['status'],
        ]);

        // 変換不要!そのまま保存
        foreach ($data['items'] as $item) {
            Step::updateOrCreate(
                ['id' => $item['id']],
                [
                    'lp_id' => $lp->id,
                    'type' => $item['type'],
                    'content' => $item['content'],
                    'order' => $item['order'],
                    'related_cta_id' => $item['related_to'] ?? null,
                ]
            );
        }

        DB::commit();
        return $lp->id;
    }
}

変換処理が消えました!

改善のポイント

  1. フロントエンドの「見た目」とAPIの「構造」を分離

フロントエンドでは、UIの都合で「ステップ」と「CTA」を分けて表示しても構いません。

// フロントエンド内部でステップとCTAを分けて管理
const steps = items.filter(item => item.type === 'step');
const ctas = items.filter(item => item.type === 'cta');

// でも、APIには統合した形式で送る
await api.updateLP({
  items: items  // 統合された形式
});
  1. バックエンドの実装に合わせたAPI設計

DBの構造に近い形式でAPIを設計すれば、変換は不要です。

steps テーブル:
- id
- lp_id
- type (step/cta)
- content
- order
- related_cta_id

↓ほぼそのまま

API:
{
  "items": [
    {"id": ..., "type": "step", "content": ..., "order": ..., "related_to": ...}
  ]
}
  1. フロントエンドの負担は増えない

「バックエンドに合わせる = フロントエンドが複雑になる」わけではありません。

// Before: バックエンドに送る前に変換
const requestData = {
  steps: [...],
  ctas: [...]
};

// After: 変換不要
const requestData = {
  items: items  // 内部で持っているデータをそのまま送る
};

むしろシンプルになることも多いです。

どうしても変換が必要な場合

以下のような場合、変換が避けられないこともあります:

ケース1: 既存APIの互換性維持

// 既存のアプリが古い形式で呼んでいる
POST /api/lp/update
{
  "step_list": [...],  // 変更できない
  "cta_list": [...]
}

APIを変更すると既存のクライアントが壊れます。

ケース2: 外部APIとの統合

// 外部サービスから来るデータ形式
{
  "title": "...",
  "sections": [...]  // 外部の形式
}

外部の形式は変更できません。

ケース3: 複数のクライアントが異なる形式を期待

- Webアプリ: 形式A
- モバイルアプリ: 形式B
- 社内ツール: 形式C

統一するのが難しい場合もあります。

そんな時は: DTOパターン

変換が避けられない場合、変換ロジックを明示的に分離します。

// DTO (Data Transfer Object)
class LpUpdateRequest
{
    public function __construct(
        public readonly string $name,
        public readonly string $status,
        public readonly array $items
    ) {}

    // リクエストからDTOを作成
    public static function fromHttpRequest(array $data): self
    {
        // 変換ロジックをここに集約
        $items = [];
        
        foreach ($data['steps'] ?? [] as $index => $step) {
            $items[] = [
                'id' => $step['id'],
                'type' => 'step',
                'content' => $step['content'],
                'order' => $index,
            ];
            
            if (isset($step['individual_cta'])) {
                $items[] = [
                    'id' => $step['individual_cta']['id'],
                    'type' => 'cta',
                    'content' => $step['individual_cta']['content'],
                    'order' => $index,
                    'related_to' => $step['id'],
                ];
            }
        }
        
        foreach ($data['ctas'] ?? [] as $cta) {
            $items[] = [
                'id' => $cta['id'],
                'type' => 'cta',
                'content' => $cta['content'],
                'order' => -1,
            ];
        }
        
        return new self(
            name: $data['name'],
            status: $data['status'],
            items: $items
        );
    }

    // 外部APIからDTOを作成
    public static function fromExternalApi(array $data): self
    {
        // 外部API用の変換ロジック
        // ...
    }
}

Controller:

class LandingPageController extends Controller
{
    public function update(Request $request)
    {
        // リクエストをDTOに変換
        $dto = LpUpdateRequest::fromHttpRequest($request->all());
        
        // ServiceはDTOを受け取る
        $lpId = $this->service->update($dto, Auth::id());
        
        return $this->success(['lp_id' => $lpId]);
    }
}

Service:

class LandingPageService
{
    public function update(LpUpdateRequest $dto, int $userId): int
    {
        // DTOから直接データを取得
        // 変換は不要
        DB::beginTransaction();
        try {
            $lp = Lp::updateOrCreate(
                ['id' => $dto->lpId],
                [
                    'name' => $dto->name,
                    'status' => $dto->status,
                ]
            );

            foreach ($dto->items as $item) {
                Step::updateOrCreate(['id' => $item['id']], [
                    'lp_id' => $lp->id,
                    'type' => $item['type'],
                    'content' => $item['content'],
                    'order' => $item['order'],
                    'related_cta_id' => $item['related_to'] ?? null,
                ]);
            }

            DB::commit();
            return $lp->id;
        }
    }
}

DTOパターンのメリット:

  • 変換ロジックが明示的な場所にある(DTO)
  • Serviceは変換を知らない(HTTPリクエストに依存しない)
  • 複数の入力源に対応できる(HTTP、外部API、Jobなど)
  • テストがしやすい(DTOを直接作ればいい)
public function test_LP更新()
{
    $service = new LandingPageService();
    
    // DTOを直接作成(HTTPリクエスト形式を知らなくていい)
    $dto = new LpUpdateRequest(
        name: 'テストLP',
        status: 'active',
        items: [
            ['id' => 100, 'type' => 'step', 'content' => '...', 'order' => 1],
        ]
    );
    
    $lpId = $service->update($dto, 1);
    
    $this->assertEquals(1, $lpId);
}

変換が必要な理由を記録する

どうしても変換が必要な場合、なぜ変換が必要なのかをコメントで残しましょう。

class LpUpdateRequest
{
    /**
     * 旧APIとの互換性維持のため、古い形式からの変換が必要
     * 
     * 旧形式:
     *   - steps: ステップの配列
     *   - ctas: CTAの配列
     * 
     * 新形式:
     *   - items: ステップとCTAを統合した配列
     * 
     * TODO: 2025年Q3にモバイルアプリが新APIに移行したら、この変換を削除
     */
    public static function fromLegacyHttpRequest(array $data): self
    {
        // 変換ロジック
    }
}

なぜ記録するか:

  • 将来、変換を削除できるタイミングが分かる
  • 新しいメンバーが「なんでこんな変換してるの?」と悩まない
  • 技術的負債として認識できる

まとめ

データ変換処理が必要になる理由:

根本的な原因

  • API設計がバックエンドの実装に合っていない
  • フロントエンドの都合だけで設計している

避けられない理由

  • 既存APIの互換性維持
  • 外部APIとの統合
  • 複数クライアントの異なる形式

まず考えるべきこと:

  • 変換を不要にできないか?
  • API設計を見直す
  • フロントエンドとバックエンドで構造を統一

どうしても変換が必要な場合:

  • DTOパターンで変換を明示的に分離
  • Serviceは変換を知らない
  • なぜ変換が必要かをコメントで記録

「変換処理をどこに置くか」より「そもそも変換が必要なのか」を問うことが重要です。多くの場合、API設計を見直すことで、変換処理自体を削除できます。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?