第6章:UIの関心を分ける(イベントハンドラを痩せさせる)🖥️🍃
この章のゴールはこれだけです👇😊 「ボタン押下のイベントハンドラ」を“薄く”して、画面の変更が怖くなくなる状態にすること✨ WinFormsでもWPFでも、考え方は共通だよ〜👌💕
(ちなみに今の最新ラインは .NET 10 / C# 14、IDEは Visual Studio 2026(GAは2025/11/11) だよ📦✨ (Microsoft for Developers))
1) まず結論:UIがやるのは「3つだけ」🙆♀️✨
イベントハンドラ(例:Button_Click)がやっていいのは基本これだけ👇
- 入力を集める(TextBoxの文字、選択状態など)🧺
- 業務(アプリ側)を呼ぶ(UseCase / Service を呼ぶ)📞
- 結果を表示する(ラベル更新、メッセージ表示、画面遷移)🪄
逆に、イベントハンドラに入れちゃダメな代表👇😇💥
- 割引計算・状態遷移・在庫チェックみたいな業務ルール🧠
- SQL・HTTP・ファイルI/Oみたいな外部都合🗄️🌐
- 画面の都合(TextBoxの文字列)がそのまま業務ロジックに混ざること🧨
2) “イベントハンドラが太る”と何が起きる?😵💫
ありがちな地獄あるある👇
- UIの見た目を変えたいだけなのに、計算ロジックが壊れる💥
- DBの都合を変えたいだけなのに、画面が連鎖爆発🔥
- テストできない(クリックしないと動かせない)🧪❌
- 「どこを直したらいいか分からない」が発生🌀
ここを止める第一歩が、**イベントハンドラの“ダイエット”**です🍃✨
3) 目安:イベントハンドラは「3〜10行」くらいが気持ちいい😊👌
「えっ無理…」って思うかもだけど、やり方があるよ〜✨ 合言葉はこれ👇
“イベントハンドラは配線(Wiring)!” 🔌✨ つまり「呼び出す」だけにする!
4) Before → After で感覚を掴もう🎮✨

❌ Before:100行クリック地獄(例)😇🔥
- 画面からパース
- 計算
- DB保存
- エラー文言生成 ぜんぶ1つに混ざるやつ…!
private void btnPurchase_Click(object sender, EventArgs e)
{
int qty = int.Parse(txtQty.Text);
decimal price = decimal.Parse(txtUnitPrice.Text);
// 業務ルール(割引)までUIに混在😇
var subtotal = qty * price;
var discountRate = subtotal >= 10000m ? 0.10m : 0m;
var total = subtotal * (1 - discountRate);
// さらにDB保存まで混ざる😇😇😇(例)
using var conn = new SqlConnection(_connStr);
conn.Open();
using var cmd = new SqlCommand("INSERT ...", conn);
cmd.ExecuteNonQuery();
lblTotal.Text = total.ToString("N0") + " 円";
}
✅ After:UIは「呼ぶ→表示」だけ🍃✨
private async void btnPurchase_Click(object sender, EventArgs e)
{
try
{
await _presenter.PurchaseAsync();
}
catch (Exception ex)
{
MessageBox.Show(ex.Message);
}
}
たったこれだけにするのが目標😍✨
(async void はイベントハンドラでは必要になることがあるよ、って公式にも明記されてるよ📌 (Microsoft Learn))
5) WinForms編:いちばん現実的な分け方(MVPライト版)🪟🧩
WinFormsは「コードビハインド文化」が強いから、Presenterを1枚挟むのがすごく相性いいよ👌 MVPは「UIからロジックを外に逃がす」ための定番パターンとして紹介されてるよ📚✨ (Microsoft Learn)
ステップA:View(画面)が提供するものを“窓口”にする🚪
public interface IPurchaseView
{
string QtyText { get; }
string UnitPriceText { get; }
void ShowTotal(string text);
void ShowError(string message);
void SetBusy(bool isBusy);
}
ステップB:Presenterに「画面の外の判断」を集める🧠✨
public sealed class PurchasePresenter
{
private readonly IPurchaseView _view;
private readonly PurchaseUseCase _useCase;
public PurchasePresenter(IPurchaseView view, PurchaseUseCase useCase)
{
_view = view;
_useCase = useCase;
}
public async Task PurchaseAsync()
{
_view.SetBusy(true);
try
{
if (!int.TryParse(_view.QtyText, out var qty))
{
_view.ShowError("数量が数字じゃないよ〜😵");
return;
}
if (!decimal.TryParse(_view.UnitPriceText, out var price))
{
_view.ShowError("単価が数字じゃないよ〜😵");
return;
}
var res = await _useCase.ExecuteAsync(new PurchaseRequest(qty, price));
_view.ShowTotal($"{res.Total:N0} 円");
}
finally
{
_view.SetBusy(false);
}
}
}
ステップC:UseCaseは「業務を呼び出す」側(UI都合なし)📦✨
public sealed record PurchaseRequest(int Qty, decimal UnitPrice);
public sealed record PurchaseResponse(decimal Total);
public sealed class PurchaseUseCase
{
private readonly DiscountPolicy _discountPolicy;
public PurchaseUseCase(DiscountPolicy discountPolicy)
{
_discountPolicy = discountPolicy;
}
public Task<PurchaseResponse> ExecuteAsync(PurchaseRequest req)
{
var total = _discountPolicy.CalculateTotal(req.Qty, req.UnitPrice);
return Task.FromResult(new PurchaseResponse(total));
}
}
public sealed class DiscountPolicy
{
public decimal CalculateTotal(int qty, decimal unitPrice)
{
var subtotal = qty * unitPrice;
var rate = subtotal >= 10000m ? 0.10m : 0m;
return subtotal * (1 - rate);
}
}
ステップD:Formは“Viewとして振る舞う”だけ🪄
public partial class MainForm : Form, IPurchaseView
{
private readonly PurchasePresenter _presenter;
public MainForm()
{
InitializeComponent();
var useCase = new PurchaseUseCase(new DiscountPolicy());
_presenter = new PurchasePresenter(this, useCase);
}
public string QtyText => txtQty.Text;
public string UnitPriceText => txtUnitPrice.Text;
public void ShowTotal(string text) => lblTotal.Text = text;
public void ShowError(string message) => MessageBox.Show(message);
public void SetBusy(bool isBusy) => btnPurchase.Enabled = !isBusy;
private async void btnPurchase_Click(object sender, EventArgs e)
{
try
{
await _presenter.PurchaseAsync();
}
catch (Exception ex)
{
ShowError(ex.Message);
}
}
}
これで、UI変更(ラベル名変更とか)と、業務変更(割引ルール変更)が別々に直せるようになるよ😍✨
6) WPF編:イベントより「Command」に寄せるのが王道🪄⌨️
WPFは Commanding(コマンド) という仕組みがあって、 「ボタン押したら何をする?」をイベントじゃなく“命令”として扱えるよ✨ (Microsoft Learn)
ざっくりイメージ👇😊
- View(XAML)は
Command="{Binding PurchaseCommand}"みたいに“紐づけ” - ViewModelに
ICommand(または Async Command)を置く - クリック時の判断や処理はViewModel → UseCaseへ
ICommand 自体も公式ドキュメントがあるよ📘 (Microsoft Learn)
7) asyncの注意:UIイベントは “async void OK、でも中身は薄く”🧯✨
ここ超大事〜〜〜!😵💫💥
async voidは基本NGだけど、イベントハンドラは例外になりがち- ただし、
async voidの中に重い処理を詰め込むと事故る - だから イベントハンドラは
await presenter/usecaseだけに寄せる - そして awaitする処理はtry-catchで包む(例外が表に飛びやすい)
このあたり、WinFormsのイベント解説でも注意が書かれてるよ📌 (Microsoft Learn)
8) ミニチェックリスト✅✨(今日から使える)
イベントハンドラにこれが入ってたら赤信号🚥😇
SqlConnection/HttpClient/File.*が出てくる🗄️🌐if (売上 >= 10000) 割引みたいな業務判断がある🧠- 例外メッセージを組み立てるロジックが多い🧵
- 画面の値(
TextBox.Text)がそのまま深い層まで渡ってる🧨 - 1つのイベントハンドラが30行超えてる📜🔥
OK側のサインはこれ👇😊✨
- イベントハンドラが短い(呼び出し+表示)🍃
- 業務名のメソッドがある(
PurchaseAsync,ApplyDiscountなど)📛 - 画面の都合(文字列)→ Request(型)に詰め替えて渡してる📦
9) 演習(15〜30分)✍️🎯
演習1:クリック肥大コードを“3分割”しよう🧩
あなたの過去コード(またはサンプル)から、Button_Click を1個選んで👇
- 業務計算だけ
DiscountPolicyへ移動 - 処理の流れを
UseCaseへ移動 - Formは
Presenter.UseCase呼び出しだけにする
演習2:イベントハンドラを「10行以内」にするダイエット🥗
try { await ... } catch { MessageBox }だけ残す- それ以外はPresenterへ!
10) Copilot/Codexに頼むときの“勝ちプロンプト”🤖✨
そのままコピペで使えるやつ置いとくね👇💕
- 「この
btnPurchase_Clickの中の業務ロジックをPurchaseUseCaseとDiscountPolicyに分離して。イベントハンドラは呼び出しだけにして」 - 「WinFormsでMVPライト版にしたい。
IPurchaseViewとPurchasePresenterを作って、FormはView実装にして」 - 「入力パース(TryParse)とエラー表示をPresenter側に寄せて、UIは薄くして」
AIの提案を採用する前のチェック✅
- UI層にDB/HTTPが残ってない?🗄️❌
- UseCase/Domainが
TextBoxとか参照してない?🧨❌ - “業務名”になってる?(
DoWorkじゃなくPurchaseAsyncみたいに)📛✨
まとめ🎀✨
この章の勝ち筋はこれだけ👇😊
- イベントハンドラは「入力→呼ぶ→表示」だけ🍃
- WinFormsは Presenterを1枚挟むと一気に綺麗🧩
- WPFは Command / ViewModel に寄せると自然🪄
async voidはイベントで必要でも、中身は薄く+try-catch🧯
次の第7章(業務ロジックの置き場所)に行くと、この分離がさらに気持ちよくなるよ〜🧠✨