前回の記事では、暗黙的な副作用を明示的にしました。削除したいものは明示的に指定することで、コードの意図が明確になりました。
今回は「データ整形ロジックの混在」問題に取り組みます。
問題の再確認
現在の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;
}
}
変換処理が消えました!
改善のポイント
- フロントエンドの「見た目」と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 // 統合された形式
});
- バックエンドの実装に合わせたAPI設計
DBの構造に近い形式でAPIを設計すれば、変換は不要です。
steps テーブル:
- id
- lp_id
- type (step/cta)
- content
- order
- related_cta_id
↓ほぼそのまま
API:
{
"items": [
{"id": ..., "type": "step", "content": ..., "order": ..., "related_to": ...}
]
}
- フロントエンドの負担は増えない
「バックエンドに合わせる = フロントエンドが複雑になる」わけではありません。
// 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設計を見直すことで、変換処理自体を削除できます。