第04章:設計の超基本:SoC(関心の分離)✂️✨
今日のゴール🎯
- 「UI/業務ロジック/DB/外部API」をごちゃ混ぜにしない感覚がつかめる😊✨
- “混ぜた地獄”コードを見て「うわ…😇」ってなったあと、分けてスッキリ😎できる
- モジュラーモノリスで「モジュール境界」を守るための超・基礎体力がつく💪🧩
1) SoCってなに?(超ざっくり)🧠✨

SoC(Separation of Concerns)は、ひとことで言うと…
「別の理由で変わるものは、別の場所に置こう」✂️📦
たとえば、同じ“注文する”処理でも👇
- 画面(UI)の都合で変わる(ボタン増えた、入力項目変わった)🖥️
- ルールが変わる(合計が0円なら注文不可、割引条件が変わる)🧾
- DBの都合で変わる(テーブル設計変更、DBを替える)🗄️
- 外部サービスの都合で変わる(決済APIの仕様変更)🌐
…これ、全部いっしょのメソッドに入れると、変更が来たときにドミノ倒し💥になります😵💫
2) モジュラーモノリスでSoCが超大事な理由🏠🧩
モジュラーモノリスは「1つのアプリの中を、モジュール境界で分けて強くする」やり方だよね🏠✨ このとき SoC ができてないと…
- モジュールの“外”の事情(DBやUI)が、モジュールの“中核のルール”に侵入😇
- 結果:境界が溶ける🫠 → 「分けた意味がない」になる💥
逆に、SoCできてると…
- ルールはルールとして守れる🔒
- UIやDBや外部APIが変わっても、影響範囲が小さい✨
- テストもしやすい🧪
ちなみに今どきの .NET は **.NET 10(LTS)**が中心で、C# も C# 14が最新の流れだよ(この章のサンプルもこの世代の書き味でOK)📌✨ (Microsoft for Developers) Visual Studio も Visual Studio 2026 が出ていて、AI統合もどんどん強い🌟 (Microsoft Learn)
3) まずダメ例😇→ “混ぜた地獄”を体験しよう🔥
やりがちな「全部入り」例(注文確定+決済+DB+メール+ログが1か所)👇
public async Task<IActionResult> Checkout(CheckoutRequest req)
{
// 1) 入力チェック(UI都合)
if (string.IsNullOrWhiteSpace(req.UserId)) return BadRequest("UserId required");
if (req.Items.Count == 0) return BadRequest("No items");
// 2) DB直アクセス(永続化都合)
var user = await _db.Users.FindAsync(req.UserId);
if (user is null) return NotFound("User not found");
var productIds = req.Items.Select(x => x.ProductId).ToList();
var products = await _db.Products.Where(p => productIds.Contains(p.Id)).ToListAsync();
// 3) 業務ルール(本来コア)
decimal total = 0;
foreach (var item in req.Items)
{
var p = products.Single(x => x.Id == item.ProductId);
if (p.Stock < item.Qty) return BadRequest("Out of stock");
total += p.Price * item.Qty;
p.Stock -= item.Qty; // 在庫減らす(副作用)
}
if (total <= 0) return BadRequest("Invalid total");
// 4) 外部API(決済)
var chargeId = await _paymentApi.ChargeAsync(user.CreditCardToken, total);
// 5) さらにDB(注文保存)
var order = new OrderEntity { UserId = user.Id, Total = total, PaymentId = chargeId };
_db.Orders.Add(order);
await _db.SaveChangesAsync();
// 6) 外部I/O(メール)
await _email.SendAsync(user.Email, "Thanks!", $"OrderId: {order.Id}");
return Ok(new { order.Id });
}
何がヤバいの?😵💫(痛いところあるある)
- 決済APIの仕様が変わる → このメソッドを直す💥
- DBが変わる → このメソッドを直す💥
- 画面入力が増える → このメソッドを直す💥
- ルールが増える → さらに肥大化💥
- テストしたい → DBも決済もメールも必要😇(無理ゲー)
4) 良い例😎→ “関心で分ける”とこうなる🧩✨
同じ「注文する」でも、役割で分けるよ👇
分け方の定番(1モジュール内の基本形)🧅
- UI(Controller / API):HTTPの受け取り、入力の一次整形、結果の返却🖥️
- Application(UseCase):ユースケース進行役(注文確定の手順)📋
- Domain(ルール):ビジネスルールの中心(在庫・合計・状態など)🔒
- Infrastructure(実装):DB/外部API/メールなどの“具体”🧰
大事:Domain は I/O を知らない(DBやHTTPや外部APIに触れない)🙅♀️✨
5) 手を動かす(C#)⌨️✨:同じ処理をSoCで作り直す
ここから「最小セット」でいくよ😊
(注文モジュール Ordering の中だけで完結させるイメージ🛒)
Step A:Domainを作る(ルール担当)🔒
「注文を作るときのルール」を Domain に閉じ込める✨
public sealed class Order
{
public Guid Id { get; } = Guid.NewGuid();
public string UserId { get; }
public IReadOnlyList<OrderLine> Lines { get; }
public Money Total { get; }
private Order(string userId, IReadOnlyList<OrderLine> lines, Money total)
{
UserId = userId;
Lines = lines;
Total = total;
}
public static Order Create(string userId, IEnumerable<OrderLine> lines)
{
if (string.IsNullOrWhiteSpace(userId))
throw new DomainException("UserId is required");
var list = lines?.ToList() ?? throw new DomainException("Lines required");
if (list.Count == 0) throw new DomainException("No items");
var total = list.Aggregate(Money.Zero, (acc, x) => acc + (x.UnitPrice * x.Qty));
if (total.Amount <= 0) throw new DomainException("Invalid total");
return new Order(userId, list, total);
}
}
public sealed record OrderLine(string ProductId, int Qty, Money UnitPrice);
public sealed record Money(decimal Amount, string Currency)
{
public static Money Zero => new(0, "JPY");
public static Money operator +(Money a, Money b)
=> a.Currency == b.Currency ? new(a.Amount + b.Amount, a.Currency)
: throw new DomainException("Currency mismatch");
public static Money operator *(Money a, int qty)
=> new(a.Amount * qty, a.Currency);
}
public sealed class DomainException : Exception
{
public DomainException(string message) : base(message) { }
}
👉 ここ、DBも決済もメールも出てこないよね?それがSoCの勝ちポイント😎✨
Step B:Application(UseCase)を作る(手順担当)📋
UseCaseは「注文の流れ」を組み立てるけど、具体実装には触れない(触れるのは interface だけ)🧩
public interface IProductReader
{
Task<IReadOnlyList<ProductSnapshot>> GetProductsAsync(IReadOnlyList<string> productIds);
}
public sealed record ProductSnapshot(string Id, Money Price, int Stock);
public interface IPaymentGateway
{
Task<string> ChargeAsync(string userId, Money total);
}
public interface IOrderRepository
{
Task SaveAsync(Order order, string paymentId);
}
public interface IEmailSender
{
Task SendThanksAsync(string userId, Guid orderId);
}
public sealed class PlaceOrder
{
private readonly IProductReader _products;
private readonly IPaymentGateway _payment;
private readonly IOrderRepository _orders;
private readonly IEmailSender _email;
public PlaceOrder(IProductReader products, IPaymentGateway payment, IOrderRepository orders, IEmailSender email)
{
_products = products;
_payment = payment;
_orders = orders;
_email = email;
}
public async Task<Guid> ExecuteAsync(string userId, IReadOnlyList<(string productId, int qty)> items)
{
// 1) 必要なデータ取得(読み取り)
var ids = items.Select(x => x.productId).Distinct().ToList();
var snaps = await _products.GetProductsAsync(ids);
// 2) ルール適用(Domainへ)
var lines = items.Select(i =>
{
var p = snaps.Single(x => x.Id == i.productId);
if (p.Stock < i.qty) throw new DomainException("Out of stock");
return new OrderLine(p.Id, i.qty, p.Price);
}).ToList();
var order = Order.Create(userId, lines);
// 3) 決済(外部I/Oはポート越し)
var paymentId = await _payment.ChargeAsync(userId, order.Total);
// 4) 保存(DBもポート越し)
await _orders.SaveAsync(order, paymentId);
// 5) 通知(メールもポート越し)
await _email.SendThanksAsync(userId, order.Id);
return order.Id;
}
}
Step C:UIは“薄く”する(HTTP担当)🖥️
コントローラは「HTTPの受け渡し」が主役✨
[ApiController]
[Route("api/orders")]
public sealed class OrdersController : ControllerBase
{
private readonly PlaceOrder _placeOrder;
public OrdersController(PlaceOrder placeOrder) => _placeOrder = placeOrder;
[HttpPost("checkout")]
public async Task<IActionResult> Checkout([FromBody] CheckoutRequest req)
{
try
{
var orderId = await _placeOrder.ExecuteAsync(
req.UserId,
req.Items.Select(x => (x.ProductId, x.Qty)).ToList()
);
return Ok(new { orderId });
}
catch (DomainException ex)
{
return BadRequest(new { error = ex.Message });
}
}
}
public sealed record CheckoutRequest(string UserId, List<CheckoutItem> Items);
public sealed record CheckoutItem(string ProductId, int Qty);
ちなみに最近の ASP.NET Core(.NET 10)だと Minimal API 側でバリデーションも強化されてて、入力チェックを“境界”でやりやすくなってるよ✨(境界で整える=SoCに追い風) (Microsoft Learn)
Step D:Infrastructureで“具体”を実装する🧰
ここはDB(EF Coreなど)や外部APIの具体を置く場所✨ (サンプルなので雰囲気だけ👇)
public sealed class FakePaymentGateway : IPaymentGateway
{
public Task<string> ChargeAsync(string userId, Money total)
=> Task.FromResult($"PAY-{Guid.NewGuid()}");
}
6) これで何が嬉しいの?😆✨(効果まとめ)
- ルール(Domain)が単体テストしやすい🧪(DBいらない)
- 決済をモックにして、注文フローをテストできる🤖
- UI変更してもDomainは基本ノーダメージ🛡️
- DB変更してもUseCaseは基本ノーダメージ🛡️
- モジュール化したときも「外へ漏らさない形」が作れる🏠🧩
7) ミニ演習📝✨
演習1:関心を色分けしよ🖍️
さっきの“混ぜた地獄”コードをコピーして、コメントで👇を付けてみてね😊
[UI][Domain][DB][External][CrossCutting(ログ等)]→ どれが多いか数える📊(だいたい地獄は全部多い😇)
演習2:分割プランを3つ書く✍️
- Domainに残すルールは?🔒
- UseCaseでやる手順は?📋
- Infrastructureに追い出すI/Oは?🧰
演習3:テスト1本だけ書く🧪
Order.Create()に「Itemsが空なら例外」みたいなテストを追加✨ (最初の1本がいちばん大事!🎉)
8) AI活用プロンプト例🤖✨(コピペOK)
目的:混ぜたコードをSoCに直す✂️
このC#のCheckoutメソッドを、SoC(関心の分離)でリファクタしてください。
- UI(Controller)は薄く
- Application(UseCase)を新規作成
- Domainにビジネスルールを集約
- DB/外部API/メールはInfrastructureに移動し、interface(ポート)越しに呼ぶ
生成物:フォルダ構成案 / クラス一覧 / 主要コード
目的:テストを増やす🧪
Domain層のOrder.Createに対して、xUnitでテストケースを5つ提案して。
境界値(0円、空配列、qty=0、通貨違いなど)を含めて、テストコードも生成して。
目的:依存の方向をチェックする🚦
このプロジェクト構成で、依存関係ルール(内側が外側を参照しない)を守れているかレビューして。
違反の可能性がある参照例も挙げて。
9) まとめ(覚える1行)📌✨
「ルールは中へ🔒、I/Oは外へ🧰、UIは薄く🖥️」 😎✂️✨
次の第5章(境界づけ=Bounded Contextミニ版🧱🌍)に行くと、「どこまでを1モジュールにする?」がもっと気持ちよく決められるようになるよ😊💡