本文へスキップ
BecomeCoder

C# MVVMコース · 第8章 周辺の道具立て ― DI・ヘルパー・アンチパターン · レッスン31

Composition Root ― 依存を組み立てる場所は1つだけ

ブラウザで完結

導入

レッスン3で「MyAppプロジェクトのApp起動処理」として予告した仕組みがこれです。DIコンテナを使うにせよ手動でnewするにせよ、どこかで具体的な実装(InMemoryTaskRepositoryなど)を組み立てる必要があります。それをプログラム中のただ1箇所に押し込め、それ以外のコードは抽象(ITaskRepositoryITaskService)にしか依存しないようにする――これがComposition Rootという考え方です。

説明

flowchart TB
    subgraph CR["Composition Root(アプリ全体でただ1箇所)"]
        direction LR
        REPO["new InMemoryTaskRepository()"] --> SVC["new TaskService(repository)"]
        SVC --> VM["new TaskListViewModel(service)"]
    end
    CR --> APP["アプリの残り全部<br/>(抽象にしか依存しない)"]
    style CR fill:#e1f5fe
  • TaskServiceTaskListViewModelのコンストラクタは「抽象を受け取る」だけで、自分では何もnewしない(第4〜6章で徹底してきた通り)
  • では具体的な実装は誰がnewするのか――その答えを1箇所に決めるのがComposition Root
  • 実務ではWPFのApp.xaml.csOnStartup)やコンソールアプリのMainがこの役割を担う。「保存先をファイルに変える」といった変更が起きても、書き換えるのはComposition Rootだけで済む

やってみよう

ここまでの層(Repository → Service → ViewModel)を、実際に1箇所で組み立てるCompositionRootを書いてみましょう。

演習

using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows.Input;

var vm = CompositionRoot.Compose();
vm.NewTaskTitle = "牛乳を買う";
vm.AddCommand.Execute(null);

Assert(vm.Tasks.Count == 1, "組み立てたViewModelで実際にタスクを追加できる");
Assert(vm.Tasks[0].Title == "牛乳を買う", "タイトルが反映される");
Console.WriteLine("全テスト成功");

// TODO: ITaskRepository → ITaskService → TaskListViewModel の順に実体を組み立てて
//       返す Compose() を実装してください(これがComposition Root)
static class CompositionRoot
{
    ___
}

void Assert(bool c, string n) { if (!c) throw new Exception($"FAIL: {n}"); }

// --- 型のコピー ---
public readonly struct TaskTitle : IEquatable<TaskTitle>
{
    public string Value { get; }
    public TaskTitle(string value)
    {
        if (string.IsNullOrWhiteSpace(value)) throw new ArgumentException("タイトルは空にできません");
        if (value.Length > 50) throw new ArgumentException("タイトルは50文字以内です");
        Value = value.Trim();
    }
    public bool Equals(TaskTitle other) => Value == other.Value;
    public override string ToString() => Value;
}
public enum Priority { Low, Medium, High }
public class TaskItem
{
    public Guid Id { get; }
    public TaskTitle Title { get; private set; }
    public Priority Priority { get; private set; }
    public bool IsCompleted { get; private set; }
    public TaskItem(Guid id, TaskTitle title, Priority priority)
    { Id = id; Title = title; Priority = priority; }
    public void Complete() => IsCompleted = true;
    public void Rename(TaskTitle newTitle) => Title = newTitle;
}
public static class TaskFactory
{
    public static TaskItem Create(string title, Priority priority = Priority.Medium)
        => new TaskItem(Guid.NewGuid(), new TaskTitle(title), priority);
}
public interface ITaskRepository
{
    void Add(TaskItem task);
    IReadOnlyList<TaskItem> All();
    TaskItem? FindById(Guid id);
}
public class InMemoryTaskRepository : ITaskRepository
{
    private readonly List<TaskItem> _tasks = new();
    public void Add(TaskItem task) => _tasks.Add(task);
    public IReadOnlyList<TaskItem> All() => _tasks;
    public TaskItem? FindById(Guid id) => _tasks.FirstOrDefault(t => t.Id == id);
}
public interface ITaskService
{
    TaskItem AddTask(string title, Priority priority);
    void CompleteTask(Guid id);
    IReadOnlyList<TaskItem> GetActiveTasks();
}
public class TaskService : ITaskService
{
    private readonly ITaskRepository _repository;
    public TaskService(ITaskRepository repository) => _repository = repository;
    public TaskItem AddTask(string title, Priority priority)
    {
        if (_repository.All().Any(t => t.Title.Value == title))
            throw new InvalidOperationException("同じタイトルのタスクが既にあります");
        var task = TaskFactory.Create(title, priority);
        _repository.Add(task);
        return task;
    }
    public void CompleteTask(Guid id) => _repository.FindById(id)?.Complete();
    public IReadOnlyList<TaskItem> GetActiveTasks() => _repository.All().Where(t => !t.IsCompleted).ToList();
}
public record TaskDto(Guid Id, string Title, string Priority, bool IsCompleted);
public static class TaskMapper
{
    public static TaskDto ToDto(TaskItem task)
        => new(task.Id, task.Title.Value, task.Priority.ToString(), task.IsCompleted);
}
public abstract class ViewModelBase : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler? PropertyChanged;
    protected void OnPropertyChanged([CallerMemberName] string? name = null)
        => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
    protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string? name = null)
    {
        if (EqualityComparer<T>.Default.Equals(field, value)) return false;
        field = value;
        OnPropertyChanged(name);
        return true;
    }
}
public class RelayCommand : ICommand
{
    private readonly Action _execute;
    private readonly Func<bool>? _canExecute;
    public RelayCommand(Action execute, Func<bool>? canExecute = null) { _execute = execute; _canExecute = canExecute; }
    public bool CanExecute(object? parameter) => _canExecute?.Invoke() ?? true;
    public void Execute(object? parameter) => _execute();
    public event EventHandler? CanExecuteChanged;
    public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
public class TaskListViewModel : ViewModelBase
{
    private readonly ITaskService _service;
    private string _newTaskTitle = "";
    public ObservableCollection<TaskDto> Tasks { get; } = new();
    public string NewTaskTitle
    {
        get => _newTaskTitle;
        set => SetProperty(ref _newTaskTitle, value);
    }
    public ICommand AddCommand { get; }

    public TaskListViewModel(ITaskService service)
    {
        _service = service;
        AddCommand = new RelayCommand(Add, () => !string.IsNullOrWhiteSpace(NewTaskTitle));
    }

    private void Add()
    {
        var task = _service.AddTask(NewTaskTitle, Priority.Medium);
        Tasks.Add(TaskMapper.ToDto(task));
        NewTaskTitle = "";
    }
}
  • 期待される出力: 全テスト成功
ヒント1を見る

static class CompositionRoot { public static TaskListViewModel Compose() { ITaskRepository repository = new InMemoryTaskRepository(); ITaskService service = new TaskService(repository); return new TaskListViewModel(service); } }

ヒント2を見る

Repository → Service → ViewModelの順に、それぞれのコンストラクタへ前段で作った実体を渡すだけです

まとめ

  • Composition Rootは「具体的な実装をnewする場所」をアプリ全体でただ1箇所に集約する
  • それ以外のクラスTaskServiceTaskListViewModel)は抽象にしか依存しない
  • 実装を差し替えたくなったとき、書き換える場所がComposition Rootの1箇所だけに閉じる

次回: 共通処理を切り出す「ヘルパークラス」の設計指針です。

実際に動かしてみよう

本文のサンプルや演習のコードは、コードブロック右上の「コピー」ボタンでコピーして、下のエディタに貼り付ければそのまま実行できます。

C# — ブラウザ内で実行

ブラウザ内でC#を動かす環境を読み込みます(初回のみ数秒)。
スクロールして表示された時点でも自動で読み込まれます。