第34章:DBモデルとDomainモデルは分けてOK(変換で吸収)🔁🗄️💎
この章は「DomainをDB都合に合わせない」ための超重要テクだよ〜!✨ いまの最新前提だと .NET 10(LTS)+ C# 14 + EF Core 10 が現行ど真ん中🧡(2026-01-22時点)(Microsoft)
1) 今日のゴール🎯✨
- ✅ Domainモデル(業務ルール・不変条件・ふるまい)をキレイに保つ💎
- ✅ DBモデル(テーブル形状・外部キー・NULL・INDEXなどの都合)を外側に閉じ込める🗄️
- ✅ その間を マッピング(変換) で吸収できるようになる🔁
2) なぜ「分ける」って話が出るの?😵💫➡️😍
DBは現実的にこういう都合が出がち👇
NULLが混ざる(でもDomainは「NULL禁止」で守りたい)😇💥- 列名・型・長さ・正規化・外部キー…「保存の事情」が多い🗃️
- EFの都合(追跡、遅延ロード、ナビゲーションなど)をDomainに持ち込みたくない🙅♀️
だから、DomainはDomainの言葉で作って、 DBの事情は永続化側(外側)で吸収しようね、って流れ✨
Microsoftのガイドでも「EF Coreは永続化層でマッピングして、Domainを“汚染”しない」方向が推されてるよ🫶(Microsoft Learn)
3) 2つのモデルの役割をハッキリさせよ〜🧠✨

💎 Domainモデル(Core側)
-
目的:業務ルールを守る
-
特徴:
- 不変条件(例:タイトル空禁止)🚧
- ふるまい(例:Archiveする)🎬
- VO(Value Object)で「string地獄」回避💍
🗄️ DBモデル(Persistence/EF側)
-
目的:DBに正しく保存・復元する
-
特徴:
Guid/string/intなどDB向きの型- 外部キー・ナビゲーション・列制約
NULL許可、既定値、ConcurrencyToken…など保存都合
4) いつ「分ける」べき?分けないでもOK?🤔✨
✅ 分けると強いケース💪
- Domainに VOや不変条件が多い(ちゃんと設計したい)💎
- DB側が複雑(JOIN多い、履歴、監査列、Nullable多い)🗄️
- EFの都合をDomainに一切入れたくない(純度命)🧼✨
✅ 分けなくてもいいケース🙂
- ほぼCRUDで、Domainのルールが薄い
- 早く作って検証したいプロトタイプ
- EF CoreのマッピングでDomainを汚さずいける設計にできる
※ EF Coreは Fluent APIでDomainモデルを汚さずにマップできる考え方も用意されてるよ🫶(Microsoft Learn) この章は「分ける」パターンをがっつり練習するね!🔁✨
5) 置き場所(クリーンアーキ的にどこに置く?)🧭🧡
- Core(Entities):Domain(Entity/VO/ルール)💎
- Core(UseCases):Repositoryの
interface(出口)🚪 - Adapters(Persistence):EF CoreのDbContext、DBモデル、Repository実装、Mapper 🔁
- Frameworks:接続文字列、Migration、EF設定など🧰
6) 実装例:Memoで「分離+マッピング」やってみよ〜✍️🗒️✨
6-1) Domain側(Core/Entities)💎
namespace Core.Entities;
public readonly record struct MemoId(Guid Value);
public sealed class MemoTitle
{
public const int MaxLength = 100;
public string Value { get; }
public MemoTitle(string value)
{
value = (value ?? "").Trim();
if (value.Length == 0) throw new ArgumentException("タイトルは必須だよ🥺");
if (value.Length > MaxLength) throw new ArgumentException($"タイトル長すぎ!最大{MaxLength}文字だよ🥺");
Value = value;
}
public override string ToString() => Value;
}
public sealed class Memo
{
public MemoId Id { get; }
public MemoTitle Title { get; private set; }
public bool IsArchived { get; private set; }
public DateTimeOffset CreatedAtUtc { get; }
private Memo(MemoId id, MemoTitle title, bool isArchived, DateTimeOffset createdAtUtc)
{
Id = id;
Title = title;
IsArchived = isArchived;
CreatedAtUtc = createdAtUtc;
}
public static Memo CreateNew(MemoTitle title, DateTimeOffset nowUtc)
=> new(new MemoId(Guid.NewGuid()), title, isArchived: false, createdAtUtc: nowUtc);
// DBから復元するとき用(Rehydrateパターン)🧟♀️✨
public static Memo Rehydrate(MemoId id, MemoTitle title, bool isArchived, DateTimeOffset createdAtUtc)
=> new(id, title, isArchived, createdAtUtc);
public void Rename(MemoTitle newTitle) => Title = newTitle;
public void Archive() => IsArchived = true;
}
ポイント🎀
- Domainは DB列名もEF属性も知らない🧼
- DB復元用に
Rehydrateを用意して、生成と復元を分けてるよ✨
6-2) Repository interface(Core/UseCases側に置く)🚪
using Core.Entities;
namespace Core.UseCases.Ports;
public interface IMemoRepository
{
Task<Memo?> FindByIdAsync(MemoId id, CancellationToken ct);
Task SaveAsync(Memo memo, CancellationToken ct);
}
6-3) DBモデル(Adapters/Persistence側)🗄️
namespace Adapters.Persistence.Ef.Models;
// 「DBに保存する形」だけを表すクラス🗄️
internal sealed class MemoRow
{
public Guid Id { get; set; }
public string Title { get; set; } = "";
public bool IsArchived { get; set; }
public DateTimeOffset CreatedAtUtc { get; set; }
}
6-4) DbContext(Adapters/Persistence側)🧰✨
using Adapters.Persistence.Ef.Models;
using Microsoft.EntityFrameworkCore;
namespace Adapters.Persistence.Ef;
internal sealed class AppDbContext : DbContext
{
public DbSet<MemoRow> Memos => Set<MemoRow>();
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var b = modelBuilder.Entity<MemoRow>();
b.ToTable("Memos");
b.HasKey(x => x.Id);
b.Property(x => x.Title)
.HasMaxLength(100)
.IsRequired();
b.Property(x => x.CreatedAtUtc)
.IsRequired();
}
}
6-5) Mapper(ここが本章の主役)🔁🌟
using Adapters.Persistence.Ef.Models;
using Core.Entities;
namespace Adapters.Persistence.Ef.Mapping;
internal static class MemoMapper
{
public static MemoRow ToRow(Memo domain)
=> new()
{
Id = domain.Id.Value,
Title = domain.Title.Value,
IsArchived = domain.IsArchived,
CreatedAtUtc = domain.CreatedAtUtc
};
public static Memo ToDomain(MemoRow row)
=> Memo.Rehydrate(
new MemoId(row.Id),
new MemoTitle(row.Title),
row.IsArchived,
row.CreatedAtUtc
);
}
ここ大事〜〜❣️
- “変換” を 1か所に集める と、あとで仕様が変わっても壊れにくいよ🔁✨
6-6) Repository実装(EF Coreを使うのは外側だけ)🧩🗄️
using Adapters.Persistence.Ef.Mapping;
using Microsoft.EntityFrameworkCore;
using Core.Entities;
using Core.UseCases.Ports;
namespace Adapters.Persistence.Ef;
internal sealed class EfMemoRepository : IMemoRepository
{
private readonly AppDbContext _db;
public EfMemoRepository(AppDbContext db) => _db = db;
public async Task<Memo?> FindByIdAsync(MemoId id, CancellationToken ct)
{
var row = await _db.Memos.AsNoTracking()
.SingleOrDefaultAsync(x => x.Id == id.Value, ct);
return row is null ? null : MemoMapper.ToDomain(row);
}
public async Task SaveAsync(Memo memo, CancellationToken ct)
{
// Upsertっぽく保存(学習用にシンプル)
var existing = await _db.Memos.SingleOrDefaultAsync(x => x.Id == memo.Id.Value, ct);
if (existing is null)
{
_db.Memos.Add(MemoMapper.ToRow(memo));
}
else
{
existing.Title = memo.Title.Value;
existing.IsArchived = memo.IsArchived;
// CreatedAtUtcは不変、更新しない方針✨
}
await _db.SaveChangesAsync(ct);
}
}
7) マッピングの地雷💣あるある集(超大事)😱➡️😌
💣 地雷1:IDの生成場所がブレる
- Domainで作る?DBで作る?が混ざると地獄👹 おすすめ:Domainで生成(ルールが明確)✨
💣 地雷2:UTC/ローカル時刻混在
- 保存は基本 UTC に寄せるのが安全🙆♀️ (表示はPresenter/外側でローカルへ)🕒
💣 地雷3:NULLとDomain不変条件の衝突
- DBにNULLが入ったら
new MemoTitle(row.Title)が例外で落ちる💥 対策: - DB制約でNULLを防ぐ
- それでも怖いなら「不正データ検知→隔離ログ」みたいな方針を決める🧯
💣 地雷4:既定値がDomainとズレる
- DBの既定値(DEFAULT)とDomainの既定値がズレると事故る💥 対策:既定値はなるべくDomainで確定して保存する✨
8) さらに上級:VOは「値変換」でも扱えるよ🪄(分けないルート)
「DomainをそのままEFでマップ」したい場合は、Value Converter が便利✨ EFの公式でも「DBとの読み書き時に値を変換できる」って説明されてるよ📚(Microsoft Learn)
イメージ👇(※この章のメインは“分ける”だから、参考としてね)
// 例:MemoTitle を string に変換して保存する
builder.Property(x => x.Title)
.HasConversion(
v => v.Value,
v => new MemoTitle(v)
);
あと、複数カラムをまとめたいときは Owned Entity Types って仕組みもあるよ〜🧩(Microsoft Learn)
9) テストで守ると安心🧪💖(マッピングテスト)
「変換が壊れてない?」を自動で見れると最強✨
using Core.Entities;
using Adapters.Persistence.Ef.Mapping;
using Xunit;
public class MemoMappingTests
{
[Fact]
public void RoundTrip_Domain_to_Row_to_Domain_keeps_data()
{
var now = DateTimeOffset.UtcNow;
var memo = Memo.CreateNew(new MemoTitle("テストだよ〜💖"), now);
var row = MemoMapper.ToRow(memo);
var restored = MemoMapper.ToDomain(row);
Assert.Equal(memo.Id, restored.Id);
Assert.Equal(memo.Title.Value, restored.Title.Value);
Assert.Equal(memo.IsArchived, restored.IsArchived);
Assert.Equal(memo.CreatedAtUtc, restored.CreatedAtUtc);
}
}
10) ミニ課題✍️✨(手を動かすやつ)
課題A:列追加に強くなる💪
- Domainに
LastUpdatedAtUtcを追加🕒 - DBモデル(MemoRow)に列追加
- MapperとRepositoryを更新
- マッピングテストも更新✅
課題B:バグ仕込み→検出ゲーム🎮
- わざと
TitleのマッピングをTrim()し忘れる - テストで落ちるのを確認する😆
11) Copilot / Codex に頼るコツ🤖💬✨
✅ 使える指示例(そのまま貼ってOK)
- 「
MemoとMemoRowの相互変換メソッドを作って。NULLや既定値の注意点もコメントで入れて」 - 「マッピングのround-tripテスト(xUnit)を書いて。比較すべき項目を列挙して」
- 「このRepositoryのUpsertをもう少し安全にして。CreatedAtは不変で」
⚠️ ただし注意!
AIが作ったMapperって、項目の追加時に漏れやすいの🥺 だから
- ✅ “Mapperだけのテスト” を置く
- ✅ 追加したプロパティは Domain→DB→Domain で必ず検査 これでかなり事故減るよ〜🧪💖
まとめ🎀✨
- Domainは💎、DBは🗄️。役割が違う!
- 分けたら Mapper(変換) が生命線🔁
- 地雷(NULL/既定値/UTC/ID)を最初から潰す💣➡️✅
- テストで“変換の破綻”を自動検出🧪✨
次(第35章)は「検索/一覧(Query)をどこに置く?」で、読み取り最適化がテーマになるよ〜🔎💖