WPF MVVMで保存/キャンセルコマンドを共通化する方法|BaseViewModel継承とAsyncRelayCommand実装例

WPFのMVVMで画面ごとにViewModelを増やしていくと、保存/キャンセルのコマンド、入力チェック、例外表示、実行中の無効化などが毎回コピペになりがちです。この記事では、BaseViewModelに共通処理を集約して継承する設計の考え方と、CommunityToolkit.Mvvmを使った実装例、現場で困りやすいポイントをまとめます。

目次

Save / Cancel が重複しやすい理由

登録画面、編集画面、設定画面など、WPFでMVVMを採用すると「画面ごとにViewModelを1つ用意する」構成になりやすいです。すると次のような処理が、ほぼ全画面で同じ形で現れます。

  • 保存ボタンを押したら、サービス層(UseCase / ApplicationService など)の保存処理を呼ぶ
  • 入力チェック(必須、範囲、形式)を行い、NGなら保存させない
  • 例外を捕捉してエラーメッセージを表示する
  • 保存中は二重クリックを防ぐ(実行中は無効化 / Busy表示)
  • キャンセル時は変更を破棄して画面を閉じる、または前の画面に戻す

これらを各ViewModelに書くと、ボタン名や保存先が違うだけで「ほぼ同じコード」が量産されます。重複が増えると、後から仕様が変わったとき(例:保存時に共通で監査ログを追加したい、例外表示の文言を統一したい)に、修正漏れが発生しやすくなります。

結論:BaseViewModelに共通コマンドを集約して継承する設計は妥当

共通のSave / Cancel コマンドと、保存前後の共通処理(入力チェック、Busy制御、例外ハンドリング、メッセージ表示)をBaseViewModel(基底ViewModel)にまとめて、各画面ViewModelが継承して使う設計は妥当です。MVVMにおける「共通の振る舞い」をまとめるのは、典型的にはTemplate Method(テンプレートメソッド)の考え方に近く、派生クラスは「画面固有の保存処理だけ」を実装します。

ただし、何でもかんでも基底に詰め込むと、基底が肥大化して逆に保守性が落ちます。ポイントは次の2つです。

  • 基底はUI操作の流れ(保存を押したら、バリデーションして、サービスを呼び、結果を反映する)だけを持つ
  • 業務処理の中身(DB更新、API呼び出しなど)はサービス層に委譲し、基底は呼び出しと状態管理に徹する

設計を崩さないための役割分担

「どこまでをViewModelに書くか」を曖昧にすると、SaveCommandが巨大化してテスト不能になりがちです。おすすめの役割分担を表にまとめます。

層責務例やらないこと(アンチパターン)
View(XAML)表示と入力、CommandバインドButton.Command、Progress表示保存の業務判断をXAML側で行う
ViewModel(Base)保存フローの共通化、状態管理IsBusy、ErrorMessage、Save/CancelコマンドDBやHTTPに直接アクセス
ViewModel(各画面)画面固有の入力項目・変換・保存データ組み立て画面固有のプロパティ、SaveCoreAsyncの実装例外文言の統一やBusy制御を各画面で重複実装
サービス層ユースケース実行、トランザクション、外部I/OSaveCustomerAsync、UpdateOrderAsyncMessageBox表示などUI依存

実装例:CommunityToolkit.MvvmでBaseViewModelにSave / Cancelを集約する

ここでは、CommunityToolkit.MvvmのObservableObject / ObservableValidatorと、AsyncRelayCommandを前提に、実運用で扱いやすい形を紹介します。同期コマンドでも作れますが、保存がI/O(DB・Web API)に絡むなら最初から非同期に寄せた方が後で困りません。

抽象化するサービス(UI表示をViewModelに直書きしない)

ViewModelからMessageBoxを直接呼ぶと、単体テストが難しくなります。最低限、確認ダイアログと通知を抽象化しておくと、テストと将来のUI差し替えが楽になります。

using System.Threading.Tasks;

public interface IDialogService
{
    Task ShowInfoAsync(string message, string title);
    Task ShowErrorAsync(string message, string title);
    Task<bool> ConfirmAsync(string message, string title);
}

基底ViewModel(Save / Cancel の共通フロー)

基底側は、保存の「流れ」を固定し、保存の中身は派生側のSaveCoreAsyncへ委譲します。コマンドのプロパティはgetのみにして差し替えできないようにすると、想定外の状態になりにくいです。

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using System;
using System.Threading;
using System.Threading.Tasks;

public class KnownBusinessException : Exception
{
    public KnownBusinessException(string message) : base(message) { }
}

public abstract class SaveCancelViewModelBase : ObservableValidator
{
    private readonly IDialogService _dialog;

    private bool _isBusy;
    private bool _isDirty;
    private string? _errorMessage;

    protected SaveCancelViewModelBase(IDialogService dialog)
    {
        _dialog = dialog;

        SaveCommand = new AsyncRelayCommand(SaveAsync, CanSave);
        CancelCommand = new AsyncRelayCommand(CancelAsync, CanCancel);

        // バリデーション結果が変わったらCanExecuteも更新
        ErrorsChanged += (_, __) => SaveCommand.NotifyCanExecuteChanged();
    }

    public IAsyncRelayCommand SaveCommand { get; }
    public IAsyncRelayCommand CancelCommand { get; }

    public bool IsBusy
    {
        get => _isBusy;
        protected set
        {
            if (SetProperty(ref _isBusy, value))
            {
                SaveCommand.NotifyCanExecuteChanged();
                CancelCommand.NotifyCanExecuteChanged();
            }
        }
    }

    public bool IsDirty
    {
        get => _isDirty;
        protected set
        {
            if (SetProperty(ref _isDirty, value))
            {
                SaveCommand.NotifyCanExecuteChanged();
                CancelCommand.NotifyCanExecuteChanged();
            }
        }
    }

    public string? ErrorMessage
    {
        get => _errorMessage;
        protected set => SetProperty(ref _errorMessage, value);
    }

    protected virtual bool CanSave() => !IsBusy && IsDirty && !HasErrors;
    protected virtual bool CanCancel() => !IsBusy;

    protected void MarkDirty() => IsDirty = true;

    private async Task SaveAsync()
    {
        ErrorMessage = null;

        // 保存前の共通入力チェック
        ValidateAllProperties();
        if (HasErrors)
        {
            ErrorMessage = "入力内容に誤りがあります。各項目を確認してください。";
            return;
        }

        IsBusy = true;

        try
        {
            // 画面固有の保存処理は派生に委譲
            await SaveCoreAsync(CancellationToken.None);

            IsDirty = false;
            await _dialog.ShowInfoAsync("保存しました。", "保存");
        }
        catch (KnownBusinessException ex)
        {
            // 想定内エラー(業務エラー)はそのまま表示
            ErrorMessage = ex.Message;
            await _dialog.ShowErrorAsync(ErrorMessage, "保存エラー");
        }
        catch (Exception)
        {
            // 想定外エラーは一般化して表示(詳細はログへ)
            ErrorMessage = "予期しないエラーが発生しました。時間をおいて再度お試しください。";
            await _dialog.ShowErrorAsync(ErrorMessage, "保存エラー");

            // ここで例外を再throwするかは方針次第(監視基盤・ログ設計に合わせる)
        }
        finally
        {
            IsBusy = false;
        }
    }

    private async Task CancelAsync()
    {
        ErrorMessage = null;

        if (IsDirty)
        {
            var ok = await _dialog.ConfirmAsync("変更内容を破棄しますか?", "キャンセル確認");
            if (!ok) return;
        }

        IsBusy = true;
        try
        {
            await CancelCoreAsync();
            IsDirty = false;
        }
        finally
        {
            IsBusy = false;
        }
    }

    // 派生ViewModelが必ず実装する「保存の中身」
    protected abstract Task SaveCoreAsync(CancellationToken ct);

    // 必要な画面だけ上書きする「キャンセル固有処理」
    protected virtual Task CancelCoreAsync() => Task.CompletedTask;
}

この形にしておくと、各画面ViewModelに残るのは「画面固有の保存データを組み立ててサービス層に渡す」部分だけになります。Save/Cancelに共通で必要になりがちな処理(入力チェック、ダイアログ、Busy制御、二重実行防止の前段)も一箇所に集約できます。

派生ViewModelの実装例:登録・更新で同じSaveCommandを使う

例として「顧客情報の編集画面」を想定します。保存はサービス層に委譲し、成功/失敗のハンドリングは基底が担当します。

using System.ComponentModel.DataAnnotations;
using System.Threading;
using System.Threading.Tasks;

public interface ICustomerService
{
    Task SaveAsync(CustomerDto dto, CancellationToken ct);
}

public sealed class CustomerDto
{
    public string? Name { get; set; }
    public string? Email { get; set; }
}

public sealed class CustomerEditViewModel : SaveCancelViewModelBase
{
    private readonly ICustomerService _service;

    private string? _name;
    private string? _email;

    public CustomerEditViewModel(ICustomerService service, IDialogService dialog)
        : base(dialog)
    {
        _service = service;
    }

    [Required(ErrorMessage = "名前は必須です。")]
    public string? Name
    {
        get => _name;
        set
        {
            if (SetProperty(ref _name, value, true))
            {
                MarkDirty();
            }
        }
    }

    [Required(ErrorMessage = "メールアドレスは必須です。")]
    [EmailAddress(ErrorMessage = "メールアドレスの形式が正しくありません。")]
    public string? Email
    {
        get => _email;
        set
        {
            if (SetProperty(ref _email, value, true))
            {
                MarkDirty();
            }
        }
    }

    protected override async Task SaveCoreAsync(CancellationToken ct)
    {
        // 画面固有:DTOに詰め替えてサービスへ
        var dto = new CustomerDto
        {
            Name = Name,
            Email = Email
        };

        await _service.SaveAsync(dto, ct);
    }

    protected override Task CancelCoreAsync()
    {
        // 画面固有:必要なら初期値に戻す / ナビゲーションする
        // ここでは省略
        return Task.CompletedTask;
    }
}

この例では、NameやEmailの更新時にMarkDirty()を呼ぶことで、保存ボタンを「変更があるときだけ有効」にできます。入力チェックはObservableValidator+DataAnnotationsで行い、基底がValidateAllProperties()を呼びます。

XAMLのバインド例:保存中の無効化とエラー表示

コマンドと状態をViewにバインドすると、画面側のコードビハインドはほぼ不要になります。例として、保存/キャンセルボタン、保存中表示、エラーメッセージ表示の基本形を示します。

<Grid>
  <Grid.RowDefinitions>
    <RowDefinition Height="Auto"/>
    <RowDefinition Height="*"/>
    <RowDefinition Height="Auto"/>
  </Grid.RowDefinitions>

  <!-- エラー表示 -->
  <TextBlock Grid.Row="0"
             Text="{Binding ErrorMessage}"
             Foreground="Red"
             TextWrapping="Wrap"/>

  <!-- 入力フォーム(例) -->
  <StackPanel Grid.Row="1" Margin="0,12,0,12">
    <TextBox Text="{Binding Name, UpdateSourceTrigger=PropertyChanged, ValidatesOnNotifyDataErrors=True}"/>
    <TextBox Text="{Binding Email, UpdateSourceTrigger=PropertyChanged, ValidatesOnNotifyDataErrors=True}"/>
  </StackPanel>

  <!-- ボタン -->
  <StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Right">
    <Button Content="保存" Command="{Binding SaveCommand}" Margin="0,0,8,0"/>
    <Button Content="キャンセル" Command="{Binding CancelCommand}"/>
  </StackPanel>

  <!-- 保存中のオーバーレイ(例) -->
  <Border Background="#66000000"
          Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibilityConverter}}">
    <TextBlock Text="保存中..." HorizontalAlignment="Center" VerticalAlignment="Center"/>
  </Border>
</Grid>

Busy表示はアプリ全体で統一したくなるポイントです。基底ViewModelにIsBusyを持たせておけば、どの画面でも同じパターンで表示できます。

現場で困りやすいポイントを先に潰す

コマンドの差し替えを防ぐ(public get; のみ)

SaveCommandやCancelCommandをpublic set;にしてしまうと、外部から別コマンドが代入されて挙動が壊れるリスクがあります。MVVMでは「ViewはViewModelのCommandを呼ぶだけ」なので、原則はgetのみで十分です。

保存処理の重さに備えて最初からasync/awaitにする

保存は最初は軽く見えても、後から「Web APIに送る」「ファイル出力する」「監査ログを残す」などが追加されがちです。最初からAsyncRelayCommandで統一しておくと、後で大規模な修正になりにくいです。

二重実行防止はCanExecuteだけに頼らない

ボタンの無効化(CanExecute)だけでも多くのケースで十分ですが、環境やタイミングによっては短い間隔で2回実行される可能性を完全にはゼロにできません。サービス層側でも冪等性(同じ保存が二重に走っても破綻しない)を意識しつつ、UI側はIsBusyで確実にブロックするのが安全です。

例外処理の設計:想定内と想定外を分ける

保存処理では例外を「全部同じメッセージ」にすると、ユーザーは直せるのか判断できません。おすすめは、業務上起こり得るエラー(想定内)と、システム障害系(想定外)を分けることです。

種類例表示メッセージの方針ログの方針
想定内(業務)重複登録、在庫不足、権限不足原因と対処が分かる文言をそのまま表示警告レベルで記録(監査要件次第)
想定外(システム)DB接続断、NullReference、タイムアウト一般化した文言+再試行案内例外詳細を必ず記録(スタックトレース含む)

基底ViewModelでこの方針を統一しておくと、画面ごとにメッセージがバラつきにくくなり、UX(ユーザー体験)も整います。

バリデーションの統合:保存前チェックとリアルタイムチェック

「Save押下時だけチェック」でも動きますが、入力中にエラーを出す方がユーザーは直しやすいです。CommunityToolkit.MvvmのObservableValidatorを使うと、DataAnnotationsによるリアルタイム検証が比較的簡単に実現できます。

  • プロパティのsetterでSetProperty(ref field, value, true)のようにvalidateOnChangeを有効化する
  • XAML側でValidatesOnNotifyDataErrors=Trueを指定して、入力欄にエラーを反映する
  • 保存前にValidateAllProperties()で一括検証して取りこぼしを防ぐ

さらに「保存できない理由」をUIに見せたい場合は、エラー一覧をまとめて表示する方法もあります。ただし、一覧を作り込み過ぎるとUXが悪化しやすいので、まずは入力欄のエラー表示+上部の簡易メッセージから始めるのがおすすめです。

Cancelの設計:変更破棄と確認のベストプラクティス

キャンセルは「画面を閉じるだけ」と思われがちですが、実運用では次の要件が入りやすいです。

  • 変更があるときだけ確認ダイアログを出したい(Dirtyチェック)
  • 編集前の状態に戻したい(フォーム初期化、Undo)
  • 別画面へ遷移したい(ナビゲーション)

このうち、Dirtyチェック+確認は基底に寄せやすい一方、戻し方や遷移は画面固有です。そこで基底は「Dirtyなら確認してからCancelCoreAsyncを呼ぶ」だけにし、派生で画面固有の処理を実装する形が扱いやすくなります。

編集前のスナップショットを持つ(Mementoの簡易版)

キャンセル時に値を戻したい場合は、編集開始時にDTOのコピーを保持しておく方法がシンプルです。

public sealed class EditSnapshot<T>
{
    public EditSnapshot(T original)
    {
        Original = original;
    }

    public T Original { get; }

    // 必要ならDeepCopyの実装をここに置く(シリアライズなど)
}

派生ViewModelでスナップショットから値を戻すようにすると、Cancelの動きが安定します。複雑な画面では「戻す項目が増えるほど漏れが出る」ため、DTOのコピーから再ロードする方がミスが減ります。

DI(依存性注入)との相性:BaseViewModelは薄く保つ

基底ViewModelがサービス層を直接抱えると、画面固有の依存が基底に混ざり、再利用性が落ちます。おすすめは、基底は共通のUIサービス(ダイアログ、ログ、ナビゲーション)だけを受け取り、業務サービスは派生で受け取る形です。

依存基底に置く派生に置く
業務サービス×○
ダイアログ○○(画面固有に必要なら)
ログ○(共通)○(詳細ログが必要なら)
ナビゲーション△(アプリ設計による)○

「基底=共通の枠組み」「派生=画面固有の差分」という境界が崩れなければ、継承は強力な武器になります。

継承以外の選択肢:コンポジションで共通化する方法

継承は手軽ですが、将来的にViewModelの階層が増えすぎたり、別フレームワーク(Prism、ReactiveUIなど)に移行したくなったりすると、基底の影響範囲が大きくなります。継承に抵抗がある場合は、共通のSave/Cancelを「部品」として注入するコンポジション方式も候補になります。

例:Save/Cancelの共通ロジックをハンドラとして切り出す

using CommunityToolkit.Mvvm.Input;
using System;
using System.Threading.Tasks;

public interface ISaveCancelHandler
{
IAsyncRelayCommand CreateSaveCommand(Func save, Func canSave);
IAsyncRelayCommand CreateCancelCommand(Func cancel, Func canCancel);
}

public sealed class SaveCancelHandler : ISaveCancelHandler
{
public IAsyncRelayCommand CreateSaveCommand(Func save, Func canSave)
=> new AsyncRelayCommand(save, canSave);


public IAsyncRelayCommand CreateCancelCommand(Func<Task> cancel, Func<bool> canCancel)
    => new AsyncRelayCommand(cancel, canCancel);


}

この方式では、ViewModelは基底を継承せず、必要な共通ロジックだけを委譲できます。複数の基底クラスを持てないC#の制約(単一継承)を避けられるため、既に別の基底(例:PrismのBindableBase)を使っている場合に特に有効です。

どの方式を選ぶべきか(判断基準)

継承とコンポジションにはそれぞれ向き不向きがあります。迷ったら次の表で判断すると整理しやすいです。

観点基底ViewModel継承コンポジション(ハンドラ注入)
導入の手軽さ○(最短で重複を消せる)△(設計とDIが必要)
共通化できる範囲○(状態やUIフローまで統一)△(部品単位の共通化が中心)
基底の肥大化リスク△(運用で膨らみやすい)○(責務を分割しやすい)
別フレームワーク移行△(基底依存が強い)○(差し替えしやすい)
テストのしやすさ○(共通ロジックを一括でテスト可能)○(部品ごとにテスト可能)

「画面数が増えるのが確定」「Save/Cancelの仕様統一が強い要件」という状況なら、まずは基底継承で一気に整理するのが現実的です。一方、アプリが大規模で、画面によって保存手順が大きく異なるなら、共通部分を小さく分割できるコンポジションも検討する価値があります。

よくある落とし穴と対策

  • 基底が神クラス化する:基底には「共通の流れ」だけを置き、業務処理はサービスへ。画面固有は必ず派生へ。
  • 例外を握りつぶして原因不明になる:ユーザーには一般化したメッセージ、ログには詳細(例外+コンテキスト)を残す。想定内例外は型で分ける。
  • Busy解除漏れで操作不能になる:必ずtry/finallyでIsBusyを戻す。非同期でも同じ。
  • Dirty判定がズレる:初期ロード中はDirtyを立てない、または「編集開始後の変更だけ」をDirtyにする設計にする。
  • 保存成功後に画面の状態が更新されない:保存後にサーバー側で補正された値(採番ID、更新日時)がある場合、サービスから返ってきた値でViewModelを更新する。

実装チェックリスト

最後に、Save/Cancel共通化を導入するときに押さえておくと事故が減るチェック項目をまとめます。

チェック項目理由実装の目安
SaveCommand/CancelCommandはgetのみ外部差し替えを防ぐpublic IAsyncRelayCommand SaveCommand { get; }
保存はサービス層に委譲ViewModel肥大化を防ぐDTOを組み立てて_service.SaveAsyncを呼ぶ
例外は想定内/想定外で分けるユーザーが対処できる情報を出す業務例外型を作り、表示方針を統一
保存中は操作をブロック二重実行や状態破壊を防ぐIsBusy+CanExecute更新
UI表示はサービスで抽象化テスト容易性と将来変更に強いIDialogServiceを注入
CancelはDirty時だけ確認UXを損なわず誤操作も防ぐIsDirty+確認ダイアログ

まとめ

WPF(MVVM)で複数のViewModelに共通するSave / Cancelを持たせたい場合、BaseViewModelに共通コマンドと共通フローを集約し、各ViewModelが継承して使う設計は有効です。共通化のコツは、基底を「UIフローと状態管理」に絞り、保存の中身はサービス層へ委譲することです。さらに、ダイアログの抽象化、非同期化、Dirty管理、例外分類まで最初から押さえると、画面数が増えても破綻しにくいMVVMになります。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次