第39章:Bridge ③:演習(通知フォーマット×通知先)🔔✉️
ねらい 🎯
- 「通知フォーマット(例:プレーン/JSON)」と「通知先(例:コンソール/ファイル)」の2軸が独立して増えるときに、
if/switchの掛け算地獄を回避する🌪️ - Bridgeで、“何を送るか(フォーマット)” と “どこへ送るか(通知先)” を分離して、差し替えやすくする🌉✨
- テスト(MSTest)で「差し替えできてる感」をちゃんと体験する🧪💕
到達目標 ✅
- 2軸が増える問題(組み合わせ爆発)を、言葉で説明できる🙂
- “導入前”の素朴実装を作り、テストで現状を固定できる🧷
- Bridgeでリファクタし、新しいフォーマット/通知先の追加がラクになったと説明できる🎉
TextWriter/StringWriter/StreamWriterなど 標準クラス中心で実装できる🧰✨
手順 🧭
1) 導入前:掛け算地獄の素朴実装を作る😵💫
まずは「ありがちな形」を作ります。ポイントは、通知サービスが“フォーマット”も“通知先”も全部知ってる状態になってること💥
NotificationFormat(フォーマット)NotificationTarget(通知先)NotificationServiceLegacy(全部知ってる神クラス化しがち😇→⚠️)
using System;
using System.IO;
using System.Text;
using System.Text.Json;
public enum NotificationFormat
{
PlainText,
Json,
}
public enum NotificationTarget
{
Console,
File,
}
public sealed record Order(string OrderId, string CustomerEmail, decimal Total);
public sealed class NotificationServiceLegacy
{
public void Notify(
Order order,
NotificationFormat format,
NotificationTarget target,
string? filePath = null)
{
// “通知先”ごとに微妙に仕様が違う…とかが始まると、だいたいこうなる😇
switch (target)
{
case NotificationTarget.Console:
switch (format)
{
case NotificationFormat.PlainText:
Console.WriteLine(BuildPlainText(order));
break;
case NotificationFormat.Json:
// 例えば「コンソールでは見やすくインデントしたい」みたいな差が出ると分岐が太る🍖
Console.WriteLine(BuildJson(order, indented: true));
break;
}
break;
case NotificationTarget.File:
if (filePath is null) throw new ArgumentNullException(nameof(filePath));
switch (format)
{
case NotificationFormat.PlainText:
File.AppendAllText(filePath, BuildPlainText(order) + Environment.NewLine, Encoding.UTF8);
break;
case NotificationFormat.Json:
// ファイルは1行JSONが良い、などまた差が出る🌀
File.AppendAllText(filePath, BuildJson(order, indented: false) + Environment.NewLine, Encoding.UTF8);
break;
}
break;
}
}
private static string BuildPlainText(Order order)
=> $"OrderId={order.OrderId}, Email={order.CustomerEmail}, Total={order.Total}";
private static string BuildJson(Order order, bool indented)
=> JsonSerializer.Serialize(order, new JsonSerializerOptions { WriteIndented = indented });
}
ここでの「つらさ」ポイント😇💦
- フォーマットを1個増やす →
switch(format)が増える - 通知先を1個増やす →
switch(target)が増える - しかも「通知先ごとの微妙な仕様差」が混ざって、ネストが深くなる🌋
2) テストで“現状”を固定する🧪🧷
この章は演習なので、「壊さない」ために最初にテストで縛ります✨
MSTest は NuGet 上で MSTest.TestFramework 4.1.0 / MSTest.TestAdapter 4.1.0 が 2026-02-03 更新になっています。(nuget.org)
(テストプロジェクト作成時に合わせて入ることが多いけど、NuGet 更新日はこういう感じだよ〜🗓️✨)
Microsoft.NET.Test.Sdk は 18.0.1(2025-11-11 更新) が見えています。(nuget.org)
テストの狙い:
- Console 出力できてる?(
Console.SetOutで捕まえる🎣) - File 出力できてる?(一時ファイルで確認📄)
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
using System.IO;
[TestClass]
public class NotificationServiceLegacyTests
{
[TestMethod]
public void Notify_Console_PlainText_WritesMessage()
{
var svc = new NotificationServiceLegacy();
var order = new Order("A-001", "a@example.com", 1200m);
var original = Console.Out;
try
{
using var sw = new StringWriter();
Console.SetOut(sw);
svc.Notify(order, NotificationFormat.PlainText, NotificationTarget.Console);
var text = sw.ToString();
StringAssert.Contains(text, "OrderId=A-001");
}
finally
{
Console.SetOut(original); // 後片付け大事🙏
}
}
[TestMethod]
public void Notify_File_Json_WritesMessage()
{
var svc = new NotificationServiceLegacy();
var order = new Order("B-999", "b@example.com", 999m);
var temp = Path.GetTempFileName();
try
{
svc.Notify(order, NotificationFormat.Json, NotificationTarget.File, filePath: temp);
var text = File.ReadAllText(temp);
StringAssert.Contains(text, "\"OrderId\"");
StringAssert.Contains(text, "B-999");
}
finally
{
File.Delete(temp);
}
}
}
ここまでで、まず 「今の動き」をテストが保証してくれます🧪✨ (このあと安心してリファクタできる〜!🎉)
3) Bridgeへリファクタ:2軸を分離する🌉✨

ここからが本番!
今回の“Bridgeの形”はこうします👇
- フォーマット側(抽象/派生):通知本文をどう作る?どう書き出す?📝
- 通知先側(実装):どこへ書き出す?(標準の
TextWriterを採用)✍️
TextWriter は最初から「書き込み先の抽象」なので、Bridgeの“実装側”にピッタリ💖
- コンソール:
Console.Out - ファイル:
StreamWriter - テスト:
StringWriter(神👼✨)
3-1) Bridgeのコード(最小)🧩
using System;
using System.IO;
using System.Text;
using System.Text.Json;
// “抽象(Abstraction)”:通知という概念
public abstract class OrderNotification
{
protected TextWriter Writer { get; }
protected OrderNotification(TextWriter writer)
=> Writer = writer ?? throw new ArgumentNullException(nameof(writer));
public void Send(Order order)
{
if (order is null) throw new ArgumentNullException(nameof(order));
Write(order);
Writer.WriteLine(); // 行区切りは共通ルールにしておくとラク✨
Writer.Flush();
}
// “差分”だけを派生側へ
protected abstract void Write(Order order);
}
// “派生(RefinedAbstraction)”:プレーンテキスト通知
public sealed class PlainTextOrderNotification : OrderNotification
{
public PlainTextOrderNotification(TextWriter writer) : base(writer) { }
protected override void Write(Order order)
{
Writer.Write($"OrderId={order.OrderId}, Email={order.CustomerEmail}, Total={order.Total}");
}
}
// “派生(RefinedAbstraction)”:JSON通知
public sealed class JsonOrderNotification : OrderNotification
{
private readonly JsonSerializerOptions _options;
public JsonOrderNotification(TextWriter writer, bool indented)
: base(writer)
{
_options = new JsonSerializerOptions { WriteIndented = indented };
}
protected override void Write(Order order)
{
var json = JsonSerializer.Serialize(order, _options);
Writer.Write(json);
}
}
// 使い方サンプル(通知先はTextWriterで差し替える)
public static class NotificationExamples
{
public static void SendToConsole(Order order)
{
OrderNotification n = new PlainTextOrderNotification(Console.Out);
n.Send(order);
}
public static void SendToFile(Order order, string path)
{
using var writer = new StreamWriter(path, append: true, encoding: Encoding.UTF8);
OrderNotification n = new JsonOrderNotification(writer, indented: false);
n.Send(order);
}
}
3-2) 何が嬉しいの?😍
- フォーマット追加(例:Markdown)→
OrderNotificationの派生を1個増やすだけ🎁 - 通知先追加(例:ネットワーク/別ストレージ)→
TextWriterを差し替える or それっぽいWriterを用意🧰 - 通知サービスが「掛け算」を抱えない!これが超大事🌈
4) テストを“Bridge版”に寄せる🧪🎉
Bridge化したら、テストがめちゃ楽になります💕
- 通知先に
StringWriterを刺すだけでOK(副作用ゼロ!)✨ - ファイルも
StreamWriterで確認できる📄
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System.IO;
[TestClass]
public class OrderNotificationBridgeTests
{
[TestMethod]
public void PlainText_ToStringWriter_WritesMessage()
{
using var sw = new StringWriter();
OrderNotification n = new PlainTextOrderNotification(sw);
n.Send(new Order("C-100", "c@example.com", 100m));
var text = sw.ToString();
StringAssert.Contains(text, "OrderId=C-100");
StringAssert.Contains(text, "Total=100");
}
[TestMethod]
public void Json_ToStringWriter_WritesJson()
{
using var sw = new StringWriter();
OrderNotification n = new JsonOrderNotification(sw, indented: false);
n.Send(new Order("D-200", "d@example.com", 200m));
var text = sw.ToString();
StringAssert.Contains(text, "\"OrderId\"");
StringAssert.Contains(text, "D-200");
}
[TestMethod]
public void Json_ToFile_WritesToDisk()
{
var temp = Path.GetTempFileName();
try
{
using (var writer = new StreamWriter(temp, append: false))
{
OrderNotification n = new JsonOrderNotification(writer, indented: false);
n.Send(new Order("E-300", "e@example.com", 300m));
}
var text = File.ReadAllText(temp);
StringAssert.Contains(text, "E-300");
}
finally
{
File.Delete(temp);
}
}
}
よくある落とし穴 ⚠️😵
-
WriterのDispose責務が曖昧
OrderNotificationがDispose()しちゃうと、Console.Outみたいな“外から渡されるWriter”を壊す事故が起きる😇- この章では「Writerの寿命は呼び出し側が管理」に寄せてるよ🧯✨
-
フォーマット側に業務ルールを混ぜる
- 例:「合計が1万円以上なら文言変える」みたいなのをフォーマッタに入れると、責務が太る🍖💦
- まずは“表示”だけに集中しよ〜🙂
-
Flush/改行のルールがバラバラ
- “どこで改行する?”が散らばると地味に崩壊する🌀
- 今回は
Send()側で統一(最後にWriteLine())してるよ✨
-
Console.SetOutの後始末忘れ
- ほかのテストに伝染するので、必ず戻す🙏
ミニ演習(10〜30分)🧁⏱️
- フォーマットを1個追加してみよう🎀
-
例:Markdownっぽい通知
## Order- OrderId: ...- Total: ...
- **通知先を増やす(でも標準寄せ)**📦
-
例:
StreamWriterをBufferedStreamで包んで高速化(書き込みが多い想定)- 「通知先の改善」を、フォーマットに触れずにできたら勝ち🏆✨
- Copilot / AI拡張に頼むときのプロンプト例🤖💬
- 「
OrderNotificationを抽象クラスにして、TextWriterを受け取り、PlainTextとJsonの派生を作って。Disposeはしないで。MSTestのテストも3本(StringWriter/Json/File)で。」 👉 出てきたコードは、Dispose責務と改行/Flushの場所を必ず人間がレビューしてね👀💕
自己チェック ✅🔍
- 「フォーマット追加」と「通知先追加」が、別々の変更としてできる?(片方を足すときにもう片方のコードを触ってない?)
switchを増やす代わりに、クラス追加だけで拡張できる?- テストで
StringWriterを刺したとき、余計な準備なしで検証できる? - “通知の本文ロジック”と“書き込み先ロジック”が混ざってない?(責務が薄い?)✨