Desktop UI

Dependency Injection у WPF та Avalonia

Інтеграція Microsoft.Extensions.DependencyInjection у десктопні проєкти. Реєстрація ViewModels та сервісів. Lifecycles, Composition Root, тестування.

Dependency Injection у WPF та Avalonia

Уявіть: ваш MainViewModel створює SettingsViewModel через new, SettingsViewModel створює SettingsService через new, SettingsService створює FileRepository через new. Все працює... поки не з'являється 20 залежностей. Поки не потрібно використати той самий UserService у 5 різних ViewModels. Поки не потрібно замінити реальний DatabaseService на mock для тестування.

Ручне створення залежностей (Poor Man's DI) швидко перетворюється на кошмар підтримки. Composition Root розмивається по всьому коду, тестування стає неможливим, зміна однієї залежності вимагає правок у десятках місць.

Рішення — Dependency Injection Container. У .NET екосистемі це Microsoft.Extensions.DependencyInjection — той самий DI, що використовується в ASP.NET Core. Ви реєструєте всі залежності в одному місці (Composition Root), а контейнер автоматично створює об'єкти з усіма їх залежностями. Змінити реалізацію? Один рядок коду. Замінити на mock? Один рядок коду. Додати нову залежність? Просто додайте параметр у конструктор.

У цій статті ми детально розберемо інтеграцію DI у WPF та Avalonia: від налаштування IServiceProvider до реєстрації ViewModels, від lifecycles (Transient/Singleton/Scoped) до тестування з mock-об'єктами.

Словник теми:Dependency Injection (DI) — патерн, де клас отримує залежності ззовні, а не створює їх сам. IoC Container — контейнер для автоматичного створення об'єктів з залежностями. IServiceCollection — колекція для реєстрації сервісів. IServiceProvider — провайдер для отримання (резолвінгу) сервісів. Transient — новий екземпляр кожен раз. Singleton — один екземпляр на весь додаток. Scoped — один екземпляр на scope (область видимості). Composition Root — місце, де реєструються всі залежності.

Проблема: Poor Man's DI

Розглянемо типовий MVVM-додаток без DI:

public class MainViewModel
{
    private readonly SettingsViewModel _settingsViewModel;
    private readonly UserService _userService;
    
    public MainViewModel()
    {
        // Ручне створення залежностей
        var fileRepository = new FileRepository();
        var settingsService = new SettingsService(fileRepository);
        _settingsViewModel = new SettingsViewModel(settingsService);
        
        var apiClient = new ApiClient();
        _userService = new UserService(apiClient);
    }
}

Проблеми:

  1. Жорстка прив'язка — MainViewModel знає про конкретні реалізації (FileRepository, ApiClient)
  2. Дублювання коду — якщо UserService потрібен у 5 ViewModels, код створення дублюється 5 разів
  3. Неможливість тестування — не можна замінити ApiClient на mock
  4. Порушення Single Responsibility — MainViewModel відповідає за створення залежностей
  5. Складність зміни — щоб змінити FileRepository на DatabaseRepository, потрібно правити всі місця створення

Приклад: каскадні залежності

// UserService залежить від ApiClient та Logger
public class UserService
{
    public UserService(IApiClient apiClient, ILogger logger) { }
}

// MainViewModel створює UserService
public class MainViewModel
{
    public MainViewModel()
    {
        var logger = new ConsoleLogger();
        var apiClient = new ApiClient(logger); // ApiClient теж потребує Logger!
        var userService = new UserService(apiClient, logger);
        
        // Що якщо ApiClient потребує ще HttpClient?
        // Що якщо Logger потребує FileWriter?
        // Граф залежностей росте експоненційно...
    }
}

Рішення: Dependency Injection Container автоматично резолвить весь граф залежностей.


🔵 Recap: Що таке Dependency Injection?

Якщо ви не знайомі з DI або хочете освіжити знання, ось короткий огляд.

Принцип Dependency Injection

Без DI (залежність створюється всередині):

public class OrderService
{
    private readonly EmailSender _emailSender;
    
    public OrderService()
    {
        _emailSender = new EmailSender(); // Жорстка прив'язка
    }
}

З DI (залежність передається ззовні):

public class OrderService
{
    private readonly IEmailSender _emailSender;
    
    public OrderService(IEmailSender emailSender) // Ін'єкція через конструктор
    {
        _emailSender = emailSender;
    }
}

Переваги:

  • Можна передати будь-яку реалізацію IEmailSender (EmailSender, MockEmailSender, SmsEmailSender)
  • Легко тестувати (передати mock)
  • Легко змінити реалізацію (один рядок коду)

IoC Container: автоматичне створення

Замість ручного створення:

var emailSender = new EmailSender();
var orderService = new OrderService(emailSender);

Використовуємо контейнер:

// Реєстрація (один раз при старті)
services.AddSingleton<IEmailSender, EmailSender>();
services.AddTransient<OrderService>();

// Резолвінг (коли потрібно)
var orderService = serviceProvider.GetRequiredService<OrderService>();
// Контейнер автоматично створить EmailSender і передасть у OrderService

Lifecycles: Transient, Singleton, Scoped

LifecycleОписКоли використовувати
TransientНовий екземпляр кожен разЛегковагові об'єкти без стану (ViewModels, Commands)
SingletonОдин екземпляр на весь додатокСервіси зі станом, що має бути спільним (UserService, SettingsService)
ScopedОдин екземпляр на scopeУ десктопних додатках рідко використовується (більше для веб)
// Transient — новий кожен раз
services.AddTransient<MainViewModel>();

// Singleton — один на весь додаток
services.AddSingleton<IUserService, UserService>();

// Scoped — один на scope (рідко в десктопі)
services.AddScoped<IDatabaseContext, DatabaseContext>();
Детальніше про DI: Якщо хочете глибше зрозуміти принципи DI, Inversion of Control, Service Locator pattern, читайте розділ 4.5 Architecture Patterns. Тут ми зосередимося на практичній інтеграції у десктопні проєкти.

Інтеграція DI у WPF

Крок 1: Встановлення пакетів

dotnet add package Microsoft.Extensions.DependencyInjection
dotnet add package Microsoft.Extensions.Hosting

Microsoft.Extensions.Hosting надає зручні методи для налаштування DI та конфігурації.

Крок 2: Налаштування в App.xaml.cs

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using System.Windows;

public partial class App : Application
{
    private IHost? _host;
    
    protected override void OnStartup(StartupEventArgs e)
    {
        base.OnStartup(e);
        
        // Створення Host з DI
        _host = Host.CreateDefaultBuilder()
            .ConfigureServices((context, services) =>
            {
                // Реєстрація сервісів
                ConfigureServices(services);
            })
            .Build();
        
        // Отримання MainWindow через DI
        var mainWindow = _host.Services.GetRequiredService<MainWindow>();
        mainWindow.Show();
    }
    
    private void ConfigureServices(IServiceCollection services)
    {
        // ViewModels (Transient — новий кожен раз)
        services.AddTransient<MainViewModel>();
        services.AddTransient<SettingsViewModel>();
        services.AddTransient<UserListViewModel>();
        
        // Services (Singleton — один на додаток)
        services.AddSingleton<IUserService, UserService>();
        services.AddSingleton<ISettingsService, SettingsService>();
        services.AddSingleton<IApiClient, ApiClient>();
        
        // Repositories
        services.AddSingleton<IUserRepository, UserRepository>();
        
        // Windows (Transient)
        services.AddTransient<MainWindow>();
    }
    
    protected override void OnExit(ExitEventArgs e)
    {
        _host?.Dispose();
        base.OnExit(e);
    }
}

Крок 3: Видалення StartupUri з App.xaml

<!-- Видаліть StartupUri, бо ми створюємо MainWindow через DI -->
<Application x:Class="MyApp.App"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
    <Application.Resources>
        <!-- Resources -->
    </Application.Resources>
</Application>

Крок 4: Ін'єкція ViewModel у Window

public partial class MainWindow : Window
{
    public MainWindow(MainViewModel viewModel)
    {
        InitializeComponent();
        DataContext = viewModel;
    }
}
<!-- MainWindow.xaml -->
<Window x:Class="MyApp.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        Title="My App" Width="800" Height="600">
    <Grid>
        <TextBlock Text="{Binding Title}" FontSize="24"/>
        <!-- UI прив'язується до ViewModel через Binding -->
    </Grid>
</Window>

Крок 5: ViewModel з залежностями

public class MainViewModel : ViewModelBase
{
    private readonly IUserService _userService;
    private readonly ISettingsService _settingsService;
    
    public MainViewModel(IUserService userService, ISettingsService settingsService)
    {
        _userService = userService;
        _settingsService = settingsService;
        
        LoadDataCommand = new RelayCommand(LoadData);
    }
    
    public string Title => "Main Window";
    
    public ICommand LoadDataCommand { get; }
    
    private async void LoadData()
    {
        var users = await _userService.GetUsersAsync();
        // ...
    }
}

Що відбувається:

  1. App.OnStartup() створює IHost з DI
  2. Реєструємо всі сервіси та ViewModels у ConfigureServices()
  3. Отримуємо MainWindow через GetRequiredService<MainWindow>()
  4. DI бачить, що MainWindow потребує MainViewModel
  5. DI бачить, що MainViewModel потребує IUserService та ISettingsService
  6. DI автоматично створює весь граф залежностей і передає у конструктори

Static Service Locator: доступ до DI з будь-якого місця

Іноді потрібен доступ до DI з місць, де ін'єкція через конструктор неможлива (наприклад, у Converter або Markup Extension).

Варіант 1: App.Current.Services

public partial class App : Application
{
    public static IServiceProvider Services { get; private set; } = null!;
    
    protected override void OnStartup(StartupEventArgs e)
    {
        base.OnStartup(e);
        
        _host = Host.CreateDefaultBuilder()
            .ConfigureServices((context, services) => ConfigureServices(services))
            .Build();
        
        Services = _host.Services; // Зберігаємо для глобального доступу
        
        var mainWindow = Services.GetRequiredService<MainWindow>();
        mainWindow.Show();
    }
}

Використання:

// З будь-якого місця в коді
var userService = App.Services.GetRequiredService<IUserService>();

Варіант 2: ServiceLocator клас

public static class ServiceLocator
{
    private static IServiceProvider? _serviceProvider;
    
    public static void Initialize(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }
    
    public static T GetService<T>() where T : notnull
    {
        if (_serviceProvider == null)
            throw new InvalidOperationException("ServiceLocator not initialized");
        
        return _serviceProvider.GetRequiredService<T>();
    }
}

// В App.xaml.cs
protected override void OnStartup(StartupEventArgs e)
{
    // ...
    ServiceLocator.Initialize(_host.Services);
    // ...
}
Service Locator — анти-патерн? Так, Service Locator вважається анти-патерном, бо приховує залежності (не видно в конструкторі). Використовуйте його лише там, де ін'єкція через конструктор неможлива (Converters, Markup Extensions, Attached Properties). Для ViewModels та сервісів завжди використовуйте ін'єкцію через конструктор.

Інтеграція DI у Avalonia

Avalonia має трохи інший підхід до життєвого циклу додатку.

Крок 1: Встановлення пакетів

dotnet add package Microsoft.Extensions.DependencyInjection
dotnet add package Microsoft.Extensions.Hosting

Крок 2: Налаштування в Program.cs

using Avalonia;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

public class Program
{
    public static IHost? AppHost { get; private set; }
    
    [STAThread]
    public static void Main(string[] args)
    {
        // Створення Host з DI
        AppHost = Host.CreateDefaultBuilder(args)
            .ConfigureServices((context, services) =>
            {
                ConfigureServices(services);
            })
            .Build();
        
        BuildAvaloniaApp()
            .StartWithClassicDesktopLifetime(args);
    }
    
    public static AppBuilder BuildAvaloniaApp()
        => AppBuilder.Configure<App>()
            .UsePlatformDetect()
            .LogToTrace();
    
    private static void ConfigureServices(IServiceCollection services)
    {
        // ViewModels
        services.AddTransient<MainViewModel>();
        services.AddTransient<SettingsViewModel>();
        
        // Services
        services.AddSingleton<IUserService, UserService>();
        services.AddSingleton<ISettingsService, SettingsService>();
        
        // Views
        services.AddTransient<MainWindow>();
    }
}

Крок 3: App.axaml.cs

using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Microsoft.Extensions.DependencyInjection;

public partial class App : Application
{
    public override void Initialize()
    {
        AvaloniaXamlLoader.Load(this);
    }
    
    public override void OnFrameworkInitializationCompleted()
    {
        if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
        {
            // Отримання MainWindow через DI
            desktop.MainWindow = Program.AppHost!.Services.GetRequiredService<MainWindow>();
        }
        
        base.OnFrameworkInitializationCompleted();
    }
}

Крок 4: MainWindow з ViewModel

public partial class MainWindow : Window
{
    public MainWindow(MainViewModel viewModel)
    {
        InitializeComponent();
        DataContext = viewModel;
    }
}

Різниця з WPF:

  • У WPF DI налаштовується в App.OnStartup()
  • В Avalonia DI налаштовується в Program.Main(), а MainWindow створюється в App.OnFrameworkInitializationCompleted()

Lifecycles: Transient vs Singleton vs Scoped

Transient: новий екземпляр кожен раз

services.AddTransient<MainViewModel>();

// Кожен виклик створює новий екземпляр
var vm1 = serviceProvider.GetRequiredService<MainViewModel>();
var vm2 = serviceProvider.GetRequiredService<MainViewModel>();
// vm1 != vm2

Коли використовувати:

  • ViewModels (кожне вікно має свій ViewModel)
  • Commands (якщо вони stateless)
  • Легковагові об'єкти без стану

Singleton: один екземпляр на додаток

services.AddSingleton<IUserService, UserService>();

// Завжди повертається той самий екземпляр
var service1 = serviceProvider.GetRequiredService<IUserService>();
var service2 = serviceProvider.GetRequiredService<IUserService>();
// service1 == service2

Коли використовувати:

  • Сервіси зі станом, що має бути спільним (UserService, SettingsService)
  • Кеші (CacheService)
  • Логери (ILogger)
  • API клієнти (HttpClient wrapper)

Scoped: один екземпляр на scope

services.AddScoped<IDatabaseContext, DatabaseContext>();

using (var scope = serviceProvider.CreateScope())
{
    var db1 = scope.ServiceProvider.GetRequiredService<IDatabaseContext>();
    var db2 = scope.ServiceProvider.GetRequiredService<IDatabaseContext>();
    // db1 == db2 (в межах одного scope)
}

using (var scope = serviceProvider.CreateScope())
{
    var db3 = scope.ServiceProvider.GetRequiredService<IDatabaseContext>();
    // db3 != db1 (новий scope — новий екземпляр)
}

Коли використовувати в десктопі:

  • Database contexts (EF Core DbContext)
  • Unit of Work pattern
  • Транзакційні операції

Приклад: Scoped для діалогових вікон

private async void OpenDialog()
{
    using var scope = App.Services.CreateScope();
    
    var dialogViewModel = scope.ServiceProvider.GetRequiredService<DialogViewModel>();
    var dialogWindow = new DialogWindow { DataContext = dialogViewModel };
    
    dialogWindow.ShowDialog();
    
    // Після закриття діалогу scope dispose, і всі Scoped сервіси очищаються
}

ViewModelLocator: автоматичне прив'язування ViewModel

Замість ручного встановлення DataContext у code-behind, можна створити ViewModelLocator для автоматичного прив'язування.

Варіант 1: Attached Property

public static class ViewModelLocator
{
    public static readonly DependencyProperty AutoWireViewModelProperty =
        DependencyProperty.RegisterAttached(
            "AutoWireViewModel",
            typeof(bool),
            typeof(ViewModelLocator),
            new PropertyMetadata(false, AutoWireViewModelChanged));
    
    public static bool GetAutoWireViewModel(DependencyObject obj)
        => (bool)obj.GetValue(AutoWireViewModelProperty);
    
    public static void SetAutoWireViewModel(DependencyObject obj, bool value)
        => obj.SetValue(AutoWireViewModelProperty, value);
    
    private static void AutoWireViewModelChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        if ((bool)e.NewValue && d is FrameworkElement element)
        {
            // Отримуємо тип ViewModel на основі імені View
            var viewType = element.GetType();
            var viewModelTypeName = viewType.FullName!.Replace("View", "ViewModel");
            var viewModelType = viewType.Assembly.GetType(viewModelTypeName);
            
            if (viewModelType != null)
            {
                var viewModel = App.Services.GetRequiredService(viewModelType);
                element.DataContext = viewModel;
            }
        }
    }
}

Використання:

<Window x:Class="MyApp.Views.MainWindow"
        xmlns:local="clr-namespace:MyApp"
        local:ViewModelLocator.AutoWireViewModel="True">
    <!-- ViewModel автоматично прив'язується -->
</Window>

Варіант 2: Convention-based через Markup Extension

public class ViewModelExtension : MarkupExtension
{
    public Type? ViewModelType { get; set; }
    
    public override object ProvideValue(IServiceProvider serviceProvider)
    {
        if (ViewModelType == null)
        {
            // Визначаємо тип ViewModel на основі View
            var target = (IProvideValueTarget)serviceProvider.GetService(typeof(IProvideValueTarget))!;
            var targetType = target.TargetObject.GetType();
            var viewModelTypeName = targetType.FullName!.Replace("View", "ViewModel");
            ViewModelType = targetType.Assembly.GetType(viewModelTypeName);
        }
        
        return App.Services.GetRequiredService(ViewModelType!);
    }
}

Використання:

<Window x:Class="MyApp.Views.MainWindow"
        DataContext="{local:ViewModel}">
    <!-- ViewModel автоматично резолвиться через DI -->
</Window>
Рекомендація: Для невеликих проєктів використовуйте просту ін'єкцію через конструктор. ViewModelLocator корисний для великих проєктів з десятками вікон, де хочеться уніфікувати підхід.

Тестування з DI: Mock-об'єкти

Одна з головних переваг DI — легке тестування через підміну реалізацій.

Приклад: Unit-тест для ViewModel

// Інтерфейс сервісу
public interface IUserService
{
    Task<List<User>> GetUsersAsync();
}

// Реальна реалізація
public class UserService : IUserService
{
    private readonly IApiClient _apiClient;
    
    public UserService(IApiClient apiClient)
    {
        _apiClient = apiClient;
    }
    
    public async Task<List<User>> GetUsersAsync()
    {
        return await _apiClient.GetAsync<List<User>>("/users");
    }
}

// ViewModel
public class UserListViewModel : ViewModelBase
{
    private readonly IUserService _userService;
    private ObservableCollection<User> _users = new();
    
    public UserListViewModel(IUserService userService)
    {
        _userService = userService;
        LoadUsersCommand = new AsyncRelayCommand(LoadUsers);
    }
    
    public ObservableCollection<User> Users
    {
        get => _users;
        set => SetProperty(ref _users, value);
    }
    
    public IAsyncRelayCommand LoadUsersCommand { get; }
    
    private async Task LoadUsers()
    {
        var users = await _userService.GetUsersAsync();
        Users = new ObservableCollection<User>(users);
    }
}

Unit-тест з Mock

using Moq;
using Xunit;

public class UserListViewModelTests
{
    [Fact]
    public async Task LoadUsers_ShouldPopulateUsersCollection()
    {
        // Arrange
        var mockUserService = new Mock<IUserService>();
        mockUserService
            .Setup(s => s.GetUsersAsync())
            .ReturnsAsync(new List<User>
            {
                new User { Id = 1, Name = "Alice" },
                new User { Id = 2, Name = "Bob" }
            });
        
        var viewModel = new UserListViewModel(mockUserService.Object);
        
        // Act
        await viewModel.LoadUsersCommand.ExecuteAsync(null);
        
        // Assert
        Assert.Equal(2, viewModel.Users.Count);
        Assert.Equal("Alice", viewModel.Users[0].Name);
        Assert.Equal("Bob", viewModel.Users[1].Name);
    }
    
    [Fact]
    public async Task LoadUsers_ShouldCallServiceOnce()
    {
        // Arrange
        var mockUserService = new Mock<IUserService>();
        mockUserService
            .Setup(s => s.GetUsersAsync())
            .ReturnsAsync(new List<User>());
        
        var viewModel = new UserListViewModel(mockUserService.Object);
        
        // Act
        await viewModel.LoadUsersCommand.ExecuteAsync(null);
        
        // Assert
        mockUserService.Verify(s => s.GetUsersAsync(), Times.Once);
    }
}

Без DI це було б неможливо — ViewModel створював би UserService через new, і не було б способу підмінити його на mock.


Реєстрація за конвенцією: автоматична реєстрація

Для великих проєктів ручна реєстрація кожного сервісу стає громіздкою. Можна автоматизувати через рефлексію.

Приклад: реєстрація всіх ViewModels

private void ConfigureServices(IServiceCollection services)
{
    // Автоматична реєстрація всіх ViewModels
    var assembly = Assembly.GetExecutingAssembly();
    var viewModelTypes = assembly.GetTypes()
        .Where(t => t.Name.EndsWith("ViewModel") && !t.IsAbstract && !t.IsInterface);
    
    foreach (var type in viewModelTypes)
    {
        services.AddTransient(type);
    }
    
    // Автоматична реєстрація всіх сервісів
    var serviceTypes = assembly.GetTypes()
        .Where(t => t.Name.EndsWith("Service") && !t.IsAbstract && !t.IsInterface);
    
    foreach (var type in serviceTypes)
    {
        var interfaceType = type.GetInterfaces()
            .FirstOrDefault(i => i.Name == $"I{type.Name}");
        
        if (interfaceType != null)
        {
            services.AddSingleton(interfaceType, type);
        }
        else
        {
            services.AddSingleton(type);
        }
    }
}

Приклад: реєстрація через атрибути

// Атрибут для позначення lifecycle
[AttributeUsage(AttributeTargets.Class)]
public class ServiceAttribute : Attribute
{
    public ServiceLifetime Lifetime { get; }
    
    public ServiceAttribute(ServiceLifetime lifetime = ServiceLifetime.Singleton)
    {
        Lifetime = lifetime;
    }
}

// Використання
[Service(ServiceLifetime.Singleton)]
public class UserService : IUserService
{
    // ...
}

[Service(ServiceLifetime.Transient)]
public class MainViewModel : ViewModelBase
{
    // ...
}

// Автоматична реєстрація
private void ConfigureServices(IServiceCollection services)
{
    var assembly = Assembly.GetExecutingAssembly();
    var typesWithAttribute = assembly.GetTypes()
        .Where(t => t.GetCustomAttribute<ServiceAttribute>() != null);
    
    foreach (var type in typesWithAttribute)
    {
        var attribute = type.GetCustomAttribute<ServiceAttribute>()!;
        var interfaceType = type.GetInterfaces().FirstOrDefault();
        
        if (interfaceType != null)
        {
            services.Add(new ServiceDescriptor(interfaceType, type, attribute.Lifetime));
        }
        else
        {
            services.Add(new ServiceDescriptor(type, type, attribute.Lifetime));
        }
    }
}

Практичні завдання

Рівень 1: Базова інтеграція DI

Мета: Навчитися налаштовувати DI у WPF/Avalonia проєкті.

Завдання:

Створіть простий додаток з DI:

  1. Структура:
    • MainWindow з MainViewModel
    • IUserService інтерфейс та UserService реалізація
    • User модель
  2. Функціональність:
    • MainViewModel має кнопку "Load Users"
    • При натисканні викликається IUserService.GetUsersAsync()
    • Результат відображається в ListBox
  3. DI налаштування:
    • Встановіть Microsoft.Extensions.DependencyInjection
    • Налаштуйте DI в App.xaml.cs (WPF) або Program.cs (Avalonia)
    • Зареєструйте MainViewModel як Transient
    • Зареєструйте IUserService як Singleton
    • Ін'єкція ViewModel у MainWindow через конструктор

Критерії успіху:

  • DI налаштовано правильно
  • MainWindow отримує ViewModel через DI
  • ViewModel отримує IUserService через DI
  • Додаток працює без new для створення залежностей

Підказка (App.xaml.cs для WPF):

public partial class App : Application
{
    private IHost? _host;
    
    protected override void OnStartup(StartupEventArgs e)
    {
        base.OnStartup(e);
        
        _host = Host.CreateDefaultBuilder()
            .ConfigureServices((context, services) =>
            {
                services.AddTransient<MainViewModel>();
                services.AddSingleton<IUserService, UserService>();
                services.AddTransient<MainWindow>();
            })
            .Build();
        
        var mainWindow = _host.Services.GetRequiredService<MainWindow>();
        mainWindow.Show();
    }
}

Рівень 2: Scoped lifecycle для діалогів

Мета: Навчитися використовувати Scoped lifecycle для діалогових вікон.

Завдання:

Створіть додаток з діалоговим вікном:

  1. Структура:
    • MainWindow з кнопкою "Open Dialog"
    • DialogWindow з DialogViewModel
    • IDatabaseService (Scoped) для роботи з даними
  2. Функціональність:
    • При натисканні "Open Dialog" відкривається діалог
    • Діалог має форму для редагування даних
    • При збереженні дані зберігаються через IDatabaseService
    • Після закриття діалогу Scoped сервіси очищаються
  3. DI налаштування:
    • Зареєструйте IDatabaseService як Scoped
    • Створіть scope при відкритті діалогу
    • Передайте scope.ServiceProvider у DialogViewModel

Критерії успіху:

  • Scoped lifecycle працює правильно
  • Кожен діалог має свій екземпляр IDatabaseService
  • Після закриття діалогу сервіси очищаються (Dispose викликається)
  • Головне вікно не залежить від діалогу

Підказка:

private async void OpenDialog_Click(object sender, RoutedEventArgs e)
{
    using var scope = App.Services.CreateScope();
    
    var dialogViewModel = scope.ServiceProvider.GetRequiredService<DialogViewModel>();
    var dialogWindow = new DialogWindow { DataContext = dialogViewModel };
    
    var result = dialogWindow.ShowDialog();
    
    if (result == true)
    {
        // Дані збережено
        RefreshData();
    }
    
    // Scope dispose — IDatabaseService.Dispose() викликається автоматично
}

Рівень 3: Unit-тести з Mock

Мета: Навчитися тестувати ViewModels з mock-об'єктами.

Завдання:

Створіть unit-тести для ViewModel:

  1. Структура:
    • UserListViewModel з IUserService залежністю
    • LoadUsersCommand для завантаження даних
    • Users ObservableCollection для відображення
  2. Тести:
    • Тест 1: LoadUsers повинен заповнити Users колекцію
    • Тест 2: LoadUsers повинен викликати GetUsersAsync один раз
    • Тест 3: При помилці сервісу повинно встановлюватися ErrorMessage
  3. Mock:
    • Використайте Moq для створення mock IUserService
    • Setup різні сценарії (успіх, помилка)
    • Verify виклики методів

Критерії успіху:

  • Всі тести проходять
  • Використано Moq для mock-об'єктів
  • Тести не залежать від реальної реалізації IUserService
  • Покриття коду > 80%

Підказка:

[Fact]
public async Task LoadUsers_WhenServiceThrows_ShouldSetErrorMessage()
{
    // Arrange
    var mockUserService = new Mock<IUserService>();
    mockUserService
        .Setup(s => s.GetUsersAsync())
        .ThrowsAsync(new Exception("Network error"));
    
    var viewModel = new UserListViewModel(mockUserService.Object);
    
    // Act
    await viewModel.LoadUsersCommand.ExecuteAsync(null);
    
    // Assert
    Assert.NotNull(viewModel.ErrorMessage);
    Assert.Contains("Network error", viewModel.ErrorMessage);
}

Підсумок

Dependency Injection трансформує архітектуру десктопних додатків, роблячи код тестованим, підтримуваним та гнучким. Microsoft.Extensions.DependencyInjection інтегрується в WPF та Avalonia природно, використовуючи ті ж принципи, що й ASP.NET Core.

Ключові висновки:

🎯 Composition Root

Всі залежності реєструються в одному місці (App.OnStartup або Program.Main). Решта коду отримує залежності через конструктор.

⚡ Lifecycles

Transient для ViewModels, Singleton для сервісів, Scoped для транзакційних операцій. Вибір lifecycle впливає на продуктивність та стан.

🧪 Тестування

DI робить unit-тести тривіальними. Mock-об'єкти замінюють реальні сервіси без зміни коду ViewModel.

🔌 Гнучкість

Зміна реалізації — один рядок коду. Додавання нової залежності — параметр у конструкторі. Без правок у десятках місць.

Що далі?

Наступна стаття — SQLite та EF Core покаже, як зберігати дані локально через Entity Framework Core.


Словник термінів

Dependency Injection (DI) — патерн проєктування, де клас отримує залежності ззовні (через конструктор, властивість або метод), а не створює їх сам.Inversion of Control (IoC) — принцип, де контроль над створенням об'єктів передається зовнішньому контейнеру.IoC Container — контейнер для автоматичного створення об'єктів з усіма їх залежностями.IServiceCollection — колекція для реєстрації сервісів у DI контейнері.IServiceProvider — провайдер для отримання (резолвінгу) зареєстрованих сервісів.Service Lifetime — час життя сервісу (Transient, Singleton, Scoped).Transient — новий екземпляр створюється кожен раз при запиті.Singleton — один екземпляр на весь додаток, створюється при першому запиті.Scoped — один екземпляр на scope (область видимості), створюється при створенні scope.Composition Root — місце в додатку, де реєструються всі залежності (зазвичай App.OnStartup або Program.Main).Service Locator — патерн (анти-патерн), де об'єкт запитує залежності у глобального провайдера замість отримання через конструктор.Constructor Injection — передача залежностей через конструктор (найпопулярніший спосіб).Property Injection — передача залежностей через властивості (рідко використовується).Method Injection — передача залежностей через параметри методу (рідко використовується).Mock Object — тестовий об'єкт, що імітує поведінку реального об'єкта для unit-тестів.GetRequiredService() — метод для отримання сервісу з контейнера (викидає виняток, якщо сервіс не зареєстровано).GetService() — метод для отримання сервісу з контейнера (повертає null, якщо сервіс не зареєстровано).CreateScope() — створення нового scope для Scoped сервісів.

Додаткові ресурси

📖 DI in .NET Docs

Офіційна документація про Dependency Injection у .NET.

🏗️ Architecture Patterns

Детальніше про DI, IoC, Service Locator у розділі Architecture.

🧪 Moq Documentation

Документація Moq для створення mock-об'єктів.

📚 Попередня стаття: Media & Graphics

Повернутися до 2D графіки та мультимедіа.

📚 Наступна стаття: SQLite & EF Core

Дізнатися про локальне зберігання даних.