本記事では、C#のWinFormsアプリケーションを例に、MVPパターンにおける各クラスの役割と特徴を解説します。
MVP(Model-View-Presenter)パターンは、GUIアプリケーションの構造を明確にし、保守性とテスト容易性を高めるための設計パターンです。
1.前提条件
- Visual studio 2022 Version 17.13.6
- .Net 9
なお、すべてのソースコードを公開しています。
このデモプログラムのDBアクセスは「あえて」簡易版です。
(DB構築なしで即実行できるようにしました。)
DBアクセスを含むフルスタックのサンプルについては、以下の記事をご覧ください。
2.MVP(Model + View + Presenter)パターンの概要
(1) MVPの概要
MVPパターンは、以下の3つのコンポーネントで構成されます:
コンポーネント | 役割 |
---|---|
Model | アプリケーションのデータとビジネスロジックを管理 |
View | ユーザーインターフェースを担当し、ユーザーの入力を受け取る(WinFormsだとForm) |
Presenter | ModelとViewの仲介役であり、ユーザーの操作に応じてModelを更新し、その結果をViewに反映させる |
この構造により、各コンポーネントの責務が明確になり、コードの再利用性やテストのしやすさが向上します。
(2) なぜMVCではなくMVPなのか?
- MVCは元々「Webアプリ(リクエスト・レスポンス)」に最適化された構造です
- MVPは「UIイベント中心のアプリ(WinForms/WPFなど)」に向いています
- WinFormsではViewに直接Button.Clickなどのイベントが来るため、それをPresenterへ委譲するMVPが自然なんです
(3) WinFormsとMVPの相性が良い理由
観点 | MVP | MVC |
---|---|---|
UIイベントとの対応 | View → Presenterに直接イベント通知できる(eventで) | コントローラーが直接イベントを監視しづらい |
自動テスト | PresenterはViewのインターフェースで疎結合→モック化しやすい | ControllerがUIに依存しやすい |
責務分離 | ViewはUI描画だけ、Presenterがロジック全て持つ | ControllerとViewの責務が曖昧になりやすい |
WinFormsとの親和性 | Formにイベント+バインドを集約しやすい | ViewとControllerの役割分担が混ざりやすい |
3.画面イメージから「View」と「リポジトリ」インターフェースの定義
リポジトリとビューは、それぞれインターフェースを通じて「何を提供できるか」を外部に明示します。
これにより、内部の実装に依存することなく、外部から必要な機能だけを利用できるようになります。
(1) 画面イメージ
(2) UI操作とそれに対応する処理の責任範囲
UI上の操作 | プレゼンターの責任 | リポジトリの責任 |
---|---|---|
画面起動 | GetAll() を呼び出して View に一覧を渡す | 利用者一覧を DB(またはメモリ)から取得 |
追加ボタンクリック | View の入力欄を初期化し編集モードに切り替える | なし |
グリッドの選択 | 選択データを View に渡し、編集モードに切り替える | なし |
保存クリック | 新規か更新かで Add / Edit を分岐し実行 | データの Insert または Update を実行 |
削除クリック | Delete を実行し View に結果を通知する | データの Delete を実行 |
キャンセルクリック | View の入力状態をクリアし一覧表示に戻す | なし |
🎯 Presenter は「UI操作」と「データ操作」の橋渡し役です。
View や Repository に直接仕事をさせず、「どの操作で、何が起きるか」をプレゼンターがコントロールしています。
(3) UI上の操作からVIEWのインターフェースを定義する
public interface IView利用者
{
// 画面起動(一覧表示用のデータを受け取る)
void Set利用者ListBindingSource(BindingSource 利用者list);
// 追加ボタンクリック
event EventHandler AddNewEvent;
// グリッドの選択(修正開始要求)
event EventHandler EditEvent;
// 保存ボタンクリック
event EventHandler SaveEvent;
// 削除ボタンクリック
event EventHandler DeleteEvent;
// キャンセルボタンクリック
event EventHandler CancelEvent;
}
(4) 必要なDBアクセスからリポジトリのインターフェースを定義する
public interface I利用者Repository
{
// DBから利用者の一覧をSelectする
IEnumerable<利用者Model> GetAll();
// 入力した利用者をDBへInsert
bool Add(利用者Model user);
// 入力した利用者をDBへUpdate
bool Edit(利用者Model user);
// 選択した利用者をDBからDeleteする
bool Delete(利用者Model user);
}
4.各コンポーネントの役割と実装
(1) 🔷 Model の役割
データの構造や、データ操作(追加・修正・削除・検索)を提供します。ビジネスロジックも含めます。
極力データアノテーションを指定して、ビジネスルールをデータの構造で表現します。
これによりビジネスルールに合致しないデータの受入れを構造的に排除できます。
public class 利用者Model
{
[Key]
public int ID { get; set; }
[Required(ErrorMessage = "利用者名は必須です。")]
[StringLength(20, MinimumLength = 2, ErrorMessage = "利用者名は2文字以上+20文字以内で入力してください。")]
public string 利用者名 { get; set; } = string.Empty;
[StringLength(200, ErrorMessage = "住所は200文字以内で入力してください。")]
public string? 住所 { get; set; }
[Required(ErrorMessage = "誕生日は必須です。")]
[DataType(DataType.Date, ErrorMessage = "誕生日は日付を入力してください。")]
public DateTime 誕生日 { get; set; }
[Required]
public int Version { get; set; }
}
通常リポジトリーはDBアクセスを担当します。
今回のデモでは下記のように、DBの代わりにBindingList<利用者Model>を利用してます。
public class 利用者Repository : I利用者Repository
{
private BindingList<利用者Model> _users = new();
public 利用者Repository()
{
// 初期データ
_users.Add(new 利用者Model { ID = 1, 利用者名 = "山田太郎", 住所 = "東京都", 誕生日 = new DateTime(1990, 1, 1), Version = 1 });
_users.Add(new 利用者Model { ID = 2, 利用者名 = "鈴木花子", 住所 = "大阪府", 誕生日 = new DateTime(1995, 5, 5), Version = 1 });
_users.Add(new 利用者Model { ID = 3, 利用者名 = "佐藤次郎", 住所 = "北海道", 誕生日 = new DateTime(1988, 8, 8), Version = 1 });
}
public bool Edit(利用者Model user)
{
var index = _users.FindIndex(u => u.ID == user.ID && u.Version == user.Version);
if (index < 0) return false;
user.Version++;
_users[index] = user;
return true;
}
(2) 🔷 View の役割
WinFormsでは一般にFormのこと。
ただしMVPではFormの中にビジネスロジックを直接書かず、プレゼンターへ通知する。
public interface IView利用者
{
event EventHandler AddNewEvent; // 「追加を要求されたときの振る舞い」がある
event EventHandler EditEvent;
event EventHandler DeleteEvent;
event EventHandler SaveEvent;
event EventHandler CancelEvent;
}
この IView利用者
を実装した 利用者View
クラスは、以下のようにイベント発行を行います:
this.Button追加.Click += delegate
{
// Button追加.Click時にInterfaceで定義した「追加を要求されたときの振る舞い」を起動する
AddNewEvent?.Invoke(this, EventArgs.Empty);
Panel操作.Visible = true;
this.Button削除.Visible = false;
};
(3) 🔷 Presenter の役割
View からのイベント通知を受け取り、Model を操作し、結果を View に反映させます。
public class 利用者Presenter
{
public 利用者Presenter(IView利用者 view, I利用者Repository repository)
{
// 「追加を要求されたときの振る舞い」とは「AddNew利用者」である
_view.AddNewEvent += AddNew利用者;
_view.EditEvent += LoadSelected利用者ToEdit;
_view.DeleteEvent += Delete利用者;
_view.SaveEvent += Save利用者;
_view.CancelEvent += Cancel利用者;
_view.Set利用者ListBindingSource(_bindingSource利用者);
LoadAll利用者();
_view.Show();
}
Presenter は Model の戻り値を元に View hメッセージを返したり、状態を反映させます:
// ここで初めて、「追加を要求されたときの振る舞い」が実装される
private void AddNew利用者(object? sender, EventArgs e)
{
// 追加時の初期値
_view.IsEdit = false;
CleanViewFields();
}
(4) 🔷 Model / View / Presenter の呼び出し方
[STAThread]
static void Main()
{
IView利用者 view = new 利用者View();
I利用者Repository repo = new 利用者Repository();
_ = new 利用者Presenter(view, repo);
Application.Run((Form)view);
}
IView利用者 view = new 利用者View();
→ これは「変数はインターフェース型で宣言し、実体は具体クラスで生成する」という設計の例です。
つまり、「実装にではなく抽象に依存する(Depend on abstractions)」という原則に従った書き方です。
(5) 🔷 プロジェクト全体のクラスダイアグラム
5.Presenterの自動単体テスト(Moq使用)
MVPパターンの最大のメリットは、PresenterがViewやModelから疎結合であるため、単体テストが非常に容易になることです。
(1) テストプロジェクトの追加と実行イメージ
(2) テストクラスの定義
以下のように、ViewとRepositoryをMockし、Presenterの動作のみを検証できます。
[TestMethod]
public void LoadAll利用者_初期表示で全件取得される()
{
var mockView = new Mock<IView利用者>();
var mockRepo = new Mock<I利用者Repository>();
// --------------------------------------------------------------------
// 単体テストの基本的な考え方:
// 実際のDBの値に依存しないように、モックを使ってテストする。
// ( ここでモックが返す値を定義している )
// --------------------------------------------------------------------
var 利用者一覧 = new List<利用者Model>
{
new 利用者Model { ID = 1, 利用者名 = "山田太郎", Version = 1 },
new 利用者Model { ID = 2, 利用者名 = "鈴木花子", Version = 1 }
};
mockRepo.Setup(r => r.GetAll()).Returns(利用者一覧);
BindingSource? capturedBindingSource = null;
mockView.Setup(v => v.Set利用者ListBindingSource(It.IsAny<BindingSource>()))
.Callback<BindingSource>(bs => capturedBindingSource = bs);
mockView.Setup(v => v.Show());
// Act
var presenter = new 利用者Presenter(mockView.Object, mockRepo.Object);
// Assert
Assert.IsNotNull(capturedBindingSource);
var bs = capturedBindingSource;
// 件数の確認
Assert.AreEqual(2, bs.Count);
// 1件目の利用者名の確認
Assert.AreEqual("山田太郎", ((利用者Model)bs[0]!).利用者名);
}
このように、Presenterだけをテストすることで、UIなしでもアプリのロジックを検証できます。
他にも「登録成功」「削除成功」「排他ロック失敗」など、様々なケースをテストできます。
6.MVPのメリットまとめ
- Viewの疎結合化 → テストや画面差し替えが容易
- Presenterのテスト容易性 → Moqを使ったPresenter単体テストが実現可能
- Modelの独立性 → ビジネスロジックをViewと切り離して管理
7.おわりに
MVPパターンは、WinFormsのようなデスクトップアプリケーションでも非常に有効なアーキテクチャです。
この記事が少しでもお役に立てれば嬉しく思います。