Mohammed Chami
.NET Developer | Content Creator
Mohammed Chami
.NET Developer | Content Creator

You’ve built solid MVVM applications, but now it’s time to take your architecture to the professional level. Today, we’re diving into Dependency Injection (DI) – the pattern that separates hobby projects from enterprise-grade applications.
If you’ve ever struggled with tightly coupled code, hard-to-test classes, or ViewModels that know too much about where their data comes from, dependency injection is your solution. We’ll transform our todo application into a properly architected system that would make senior developers proud.
Before we jump into code, let’s understand the problems DI solves:
The Problem: Your ViewModels directly create their dependencies
public class MainViewModel
{
private readonly TodoService _service = new TodoService(); // Tightly coupled!
private readonly ApiClient _client = new ApiClient("hardcoded-url"); // Not testable!
}
The Solution: Dependencies are injected, making your code flexible and testable
public class MainViewModel
{
private readonly ITodoService _service;
private readonly IApiClient _client;
public MainViewModel(ITodoService service, IApiClient client) // Injected!
{
_service = service;
_client = client;
}
}
Let’s enhance our todo application with proper dependency injection. Fire up your project in Rider and let’s get started.
Open your terminal in Rider and install the Microsoft DI package:
dotnet add package Microsoft.Extensions.DependencyInjection
This lightweight container is Microsoft’s official DI solution and integrates seamlessly with the broader .NET ecosystem.
Before implementing DI, we need proper services to inject. Let’s create a realistic application structure.
First, let’s define our domain model and repository pattern:
// Models/TodoItem.cs
namespace TodoApp.Models;
public class TodoItem
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Text { get; set; } = string.Empty;
public bool IsCompleted { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.Now;
public DateTime? CompletedAt { get; set; }
}
// Services/Interfaces/IRepository.cs
using TodoApp.Models;
namespace TodoApp.Services.Interfaces;
public interface IRepository
{
Task<IEnumerable<TodoItem>> GetAllAsync();
Task<TodoItem?> GetByIdAsync(Guid id);
Task<TodoItem> AddAsync(TodoItem item);
Task<TodoItem> UpdateAsync(TodoItem item);
Task<bool> DeleteAsync(Guid id);
Task<int> GetCompletedCountAsync();
}
// Services/Repository.cs
using TodoApp.Models;
using TodoApp.Services.Interfaces;
namespace TodoApp.Services;
public class Repository : IRepository
{
private readonly List<TodoItem> _items = new();
public Task<IEnumerable<TodoItem>> GetAllAsync()
{
// Simulate async operation
await Task.Delay(10);
return _items.ToList();
}
public Task<TodoItem?> GetByIdAsync(Guid id)
{
await Task.Delay(10);
return _items.FirstOrDefault(x => x.Id == id);
}
public Task<TodoItem> AddAsync(TodoItem item)
{
await Task.Delay(10);
_items.Add(item);
return item;
}
public Task<TodoItem> UpdateAsync(TodoItem item)
{
await Task.Delay(10);
var existingItem = _items.FirstOrDefault(x => x.Id == item.Id);
if (existingItem != null)
{
existingItem.Text = item.Text;
existingItem.IsCompleted = item.IsCompleted;
existingItem.CompletedAt = item.IsCompleted ? DateTime.Now : null;
}
return item;
}
public Task<bool> DeleteAsync(Guid id)
{
await Task.Delay(10);
var item = _items.FirstOrDefault(x => x.Id == id);
if (item != null)
{
_items.Remove(item);
return true;
}
return false;
}
public Task<int> GetCompletedCountAsync()
{
await Task.Delay(10);
return _items.Count(x => x.IsCompleted);
}
}
// Services/Interfaces/IBusinessService.cs
using TodoApp.Models;
namespace TodoApp.Services.Interfaces;
public interface IBusinessService
{
Task<IEnumerable<TodoItem>> GetAllTodosAsync();
Task<TodoItem> AddTodoAsync(string text);
Task<TodoItem> ToggleCompleteAsync(Guid id);
Task<bool> DeleteTodoAsync(Guid id);
Task<string> GetProductivityStatsAsync();
}
// Services/BusinessService.cs
using TodoApp.Models;
using TodoApp.Services.Interfaces;
namespace TodoApp.Services;
public class BusinessService : IBusinessService
{
private readonly IRepository _repository;
public BusinessService(IRepository repository)
{
_repository = repository;
}
public async Task<IEnumerable<TodoItem>> GetAllTodosAsync()
{
return await _repository.GetAllAsync();
}
public async Task<TodoItem> AddTodoAsync(string text)
{
if (string.IsNullOrWhiteSpace(text))
throw new ArgumentException("Task text cannot be empty", nameof(text));
var item = new TodoItem { Text = text.Trim() };
return await _repository.AddAsync(item);
}
public async Task<TodoItem> ToggleCompleteAsync(Guid id)
{
var item = await _repository.GetByIdAsync(id);
if (item == null)
throw new ArgumentException($"Todo item with ID {id} not found");
item.IsCompleted = !item.IsCompleted;
item.CompletedAt = item.IsCompleted ? DateTime.Now : null;
return await _repository.UpdateAsync(item);
}
public async Task<bool> DeleteTodoAsync(Guid id)
{
return await _repository.DeleteAsync(id);
}
public async Task<string> GetProductivityStatsAsync()
{
var allTodos = await _repository.GetAllAsync();
var totalCount = allTodos.Count();
var completedCount = await _repository.GetCompletedCountAsync();
if (totalCount == 0)
return "No tasks yet - time to get productive!";
var completionRate = (double)completedCount / totalCount * 100;
return $"Completed {completedCount}/{totalCount} tasks ({completionRate:F1}% completion rate)";
}
}
Now let’s refactor our MainViewModel to use dependency injection:
// ViewModels/MainViewModel.cs
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using System.Collections.ObjectModel;
using TodoApp.Services.Interfaces;
using TodoApp.Models;
namespace TodoApp.ViewModels;
public partial class MainViewModel : ObservableObject
{
private readonly IBusinessService _businessService;
[ObservableProperty]
private string newTaskText = string.Empty;
[ObservableProperty]
private string productivityStats = string.Empty;
[ObservableProperty]
private bool isLoading;
public ObservableCollection<TodoItemViewModel> Tasks { get; } = new();
public MainViewModel(IBusinessService businessService)
{
_businessService = businessService;
_ = LoadTasksAsync(); // Fire and forget - proper error handling needed in real apps
}
[RelayCommand(CanExecute = nameof(CanAddTask))]
private async Task AddTaskAsync()
{
if (string.IsNullOrWhiteSpace(NewTaskText)) return;
try
{
IsLoading = true;
var newItem = await _businessService.AddTodoAsync(NewTaskText);
var viewModel = new TodoItemViewModel(newItem, _businessService);
Tasks.Add(viewModel);
NewTaskText = string.Empty;
await UpdateStatsAsync();
}
catch (Exception ex)
{
// In a real app, use proper logging and user notification
System.Diagnostics.Debug.WriteLine($"Error adding task: {ex.Message}");
}
finally
{
IsLoading = false;
}
}
private bool CanAddTask() => !string.IsNullOrWhiteSpace(NewTaskText) && !IsLoading;
[RelayCommand]
private async Task RemoveTaskAsync(TodoItemViewModel taskViewModel)
{
try
{
IsLoading = true;
var success = await _businessService.DeleteTodoAsync(taskViewModel.Id);
if (success)
{
Tasks.Remove(taskViewModel);
await UpdateStatsAsync();
}
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Error removing task: {ex.Message}");
}
finally
{
IsLoading = false;
}
}
private async Task LoadTasksAsync()
{
try
{
IsLoading = true;
var items = await _businessService.GetAllTodosAsync();
Tasks.Clear();
foreach (var item in items)
{
Tasks.Add(new TodoItemViewModel(item, _businessService));
}
await UpdateStatsAsync();
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Error loading tasks: {ex.Message}");
}
finally
{
IsLoading = false;
}
}
private async Task UpdateStatsAsync()
{
ProductivityStats = await _businessService.GetProductivityStatsAsync();
}
partial void OnNewTaskTextChanged(string value)
{
AddTaskCommand.NotifyCanExecuteChanged();
}
}
// ViewModels/TodoItemViewModel.cs
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using TodoApp.Models;
using TodoApp.Services.Interfaces;
namespace TodoApp.ViewModels;
public partial class TodoItemViewModel : ObservableObject
{
private readonly IBusinessService _businessService;
public Guid Id { get; }
[ObservableProperty]
private string text = string.Empty;
[ObservableProperty]
private bool isCompleted;
[ObservableProperty]
private string completionStatus = "Pending";
[ObservableProperty]
private DateTime createdAt;
public TodoItemViewModel(TodoItem model, IBusinessService businessService)
{
_businessService = businessService;
Id = model.Id;
Text = model.Text;
IsCompleted = model.IsCompleted;
CreatedAt = model.CreatedAt;
UpdateCompletionStatus();
}
[RelayCommand]
private async Task ToggleCompleteAsync()
{
try
{
var updatedItem = await _businessService.ToggleCompleteAsync(Id);
IsCompleted = updatedItem.IsCompleted;
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Error toggling task: {ex.Message}");
// Revert the UI change if the operation failed
IsCompleted = !IsCompleted;
}
}
partial void OnIsCompletedChanged(bool value)
{
UpdateCompletionStatus();
}
private void UpdateCompletionStatus()
{
CompletionStatus = IsCompleted ? "Completed ✓" : "Pending";
}
}
Now let’s create the extension method to register all our services:
// Extensions/ServiceCollectionExtensions.cs
using Microsoft.Extensions.DependencyInjection;
using TodoApp.Services;
using TodoApp.Services.Interfaces;
using TodoApp.ViewModels;
namespace TodoApp.Extensions;
public static class ServiceCollectionExtensions
{
public static void AddCommonServices(this IServiceCollection collection)
{
// Register repository as singleton (one instance for the app lifetime)
collection.AddSingleton<IRepository, Repository>();
// Register business service as singleton (stateless, can be shared)
collection.AddSingleton<IBusinessService, BusinessService>();
// Register ViewModels as transient (new instance each time)
collection.AddTransient<MainViewModel>();
}
}
Singleton: One instance for the entire application lifetime
Transient: New instance every time it’s requested
Scoped: One instance per scope (not commonly used in desktop apps)
Here’s where the magic happens – setting up the DI container in your application:
// App.axaml.cs
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Data.Core.Plugins;
using Avalonia.Markup.Xaml;
using Microsoft.Extensions.DependencyInjection;
using TodoApp.Extensions;
using TodoApp.ViewModels;
namespace TodoApp;
public partial class App : Application
{
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
// Remove Avalonia data validation to avoid conflicts with CommunityToolkit
if (DataValidationPluginsToRemove(BindingPlugins.DataValidators))
BindingPlugins.DataValidators.RemoveAt(0);
// Create and configure the DI container
var services = ConfigureServices();
var serviceProvider = services.BuildServiceProvider();
// Resolve the main ViewModel through DI
var mainViewModel = serviceProvider.GetRequiredService<MainViewModel>();
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow
{
DataContext = mainViewModel
};
}
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform)
{
singleViewPlatform.MainView = new MainView
{
DataContext = mainViewModel
};
}
base.OnFrameworkInitializationCompleted();
}
private static ServiceCollection ConfigureServices()
{
var services = new ServiceCollection();
// Register all application services
services.AddCommonServices();
// Add logging (optional but recommended)
services.AddLogging();
return services;
}
private static bool DataValidationPluginsToRemove(IList<IDataValidationPlugin> dataValidationPlugins)
{
return dataValidationPlugins.Count > 0 &&
dataValidationPlugins[0] is DataAnnotationsValidationPlugin;
}
}
Let’s update the UI to show our new features and loading states:
<!-- MainWindow.axaml -->
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:TodoApp.ViewModels"
x:Class="TodoApp.MainWindow"
Title="Professional Todo App with DI"
Width="600" Height="700">
<Grid RowDefinitions="Auto,Auto,Auto,*" Margin="20">
<!-- Header with Stats -->
<Border Grid.Row="0"
Background="LightBlue"
Padding="15"
CornerRadius="5"
Margin="0,0,0,15">
<StackPanel>
<TextBlock Text="Productivity Dashboard"
FontSize="18"
FontWeight="Bold"
HorizontalAlignment="Center" />
<TextBlock Text="{Binding ProductivityStats}"
HorizontalAlignment="Center"
Margin="0,5,0,0" />
</StackPanel>
</Border>
<!-- Loading Indicator -->
<ProgressBar Grid.Row="1"
IsIndeterminate="{Binding IsLoading}"
IsVisible="{Binding IsLoading}"
Margin="0,0,0,10" />
<!-- Add Task Section -->
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="10" Margin="0,0,0,20">
<TextBox Text="{Binding NewTaskText}"
Watermark="Enter a new task..."
Width="400" />
<Button Content="Add Task"
Command="{Binding AddTaskCommand}"
IsEnabled="{Binding !IsLoading}" />
</StackPanel>
<!-- Tasks List -->
<ScrollViewer Grid.Row="3">
<ItemsControl ItemsSource="{Binding Tasks}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:TodoItemViewModel">
<Border BorderBrush="LightGray"
BorderThickness="1"
Padding="15"
Margin="0,5"
CornerRadius="8"
Background="White">
<Grid ColumnDefinitions="*,Auto,Auto">
<StackPanel Grid.Column="0">
<TextBlock Text="{Binding Text}"
FontSize="14"
TextDecorations="{Binding IsCompleted, Converter={StaticResource BoolToStrikethroughConverter}}" />
<TextBlock Text="{Binding CreatedAt, StringFormat='Created: {0:MM/dd/yyyy HH:mm}'}"
FontSize="10"
Foreground="Gray"
Margin="0,2,0,0" />
</StackPanel>
<TextBlock Grid.Column="1"
Text="{Binding CompletionStatus}"
VerticalAlignment="Center"
Margin="15,0"
FontWeight="Bold"
Foreground="{Binding IsCompleted, Converter={StaticResource BoolToColorConverter}}" />
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8">
<Button Content="{Binding IsCompleted, Converter={StaticResource BoolToToggleTextConverter}}"
Command="{Binding ToggleCompleteCommand}"
Classes="accent" />
<Button Content="Bin"
Command="{Binding $parent[Window].DataContext.RemoveTaskCommand}"
CommandParameter="{Binding}"
Classes="secondary" />
</StackPanel>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
</Window>
// Converters/BoolToToggleTextConverter.cs
using Avalonia.Data.Converters;
using System;
using System.Globalization;
namespace TodoApp.Converters;
public class BoolToToggleTextConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
return value is true ? "Undo" : "Complete";
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
Register it in App.axaml:
<Application.Resources>
<converters:BoolToColorConverter x:Key="BoolToColorConverter" />
<converters:BoolToStrikethroughConverter x:Key="BoolToStrikethroughConverter" />
<converters:BoolToToggleTextConverter x:Key="BoolToToggleTextConverter" />
</Application.Resources>
One of DI’s biggest advantages is testability. Here’s how to unit test with mocked dependencies:
// Tests/MainViewModelTests.cs
using Xunit;
using Moq;
using TodoApp.ViewModels;
using TodoApp.Services.Interfaces;
using TodoApp.Models;
namespace TodoApp.Tests;
public class MainViewModelTests
{
[Fact]
public async Task AddTaskAsync_WithValidText_CallsBusinessService()
{
// Arrange
var mockBusinessService = new Mock<IBusinessService>();
var expectedItem = new TodoItem { Text = "Test task" };
mockBusinessService.Setup(x => x.AddTodoAsync(It.IsAny<string>()))
.ReturnsAsync(expectedItem);
mockBusinessService.Setup(x => x.GetProductivityStatsAsync())
.ReturnsAsync("1/1 tasks completed");
var viewModel = new MainViewModel(mockBusinessService.Object);
viewModel.NewTaskText = "Test task";
// Act
await viewModel.AddTaskCommand.ExecuteAsync(null);
// Assert
mockBusinessService.Verify(x => x.AddTodoAsync("Test task"), Times.Once);
Assert.Single(viewModel.Tasks);
Assert.Empty(viewModel.NewTaskText);
}
[Fact]
public async Task AddTaskAsync_WithEmptyText_DoesNotCallBusinessService()
{
// Arrange
var mockBusinessService = new Mock<IBusinessService>();
var viewModel = new MainViewModel(mockBusinessService.Object);
viewModel.NewTaskText = "";
// Act
await viewModel.AddTaskCommand.ExecuteAsync(null);
// Assert
mockBusinessService.Verify(x => x.AddTodoAsync(It.IsAny<string>()), Times.Never);
Assert.Empty(viewModel.Tasks);
}
}
To run these tests, install the testing packages:
dotnet add package xunit
dotnet add package xunit.runner.visualstudio
dotnet add package Moq
For more complex applications, consider configuration-driven registration:
// appsettings.json approach
public static class ServiceCollectionExtensions
{
public static void AddCommonServices(this IServiceCollection collection, IConfiguration configuration)
{
// Configure services based on settings
var dbConnectionString = configuration.GetConnectionString("DefaultConnection");
if (string.IsNullOrEmpty(dbConnectionString))
{
collection.AddSingleton<IRepository, InMemoryRepository>();
}
else
{
collection.AddSingleton<IRepository>(provider =>
new DatabaseRepository(dbConnectionString));
}
collection.AddSingleton<IBusinessService, BusinessService>();
collection.AddTransient<MainViewModel>();
}
}
For complex object creation:
public interface IViewModelFactory
{
T CreateViewModel<T>() where T : class;
}
public class ViewModelFactory : IViewModelFactory
{
private readonly IServiceProvider _serviceProvider;
public ViewModelFactory(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public T CreateViewModel<T>() where T : class
{
return _serviceProvider.GetRequiredService<T>();
}
}
For adding cross-cutting concerns:
public class LoggingBusinessService : IBusinessService
{
private readonly IBusinessService _innerService;
private readonly ILogger<LoggingBusinessService> _logger;
public LoggingBusinessService(IBusinessService innerService, ILogger<LoggingBusinessService> logger)
{
_innerService = innerService;
_logger = logger;
}
public async Task<TodoItem> AddTodoAsync(string text)
{
_logger.LogInformation("Adding new todo: {Text}", text);
try
{
var result = await _innerService.AddTodoAsync(text);
_logger.LogInformation("Successfully added todo with ID: {Id}", result.Id);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to add todo: {Text}", text);
throw;
}
}
// Implement other methods similarly...
}
// Register the decorator
services.AddSingleton<BusinessService>();
services.AddSingleton<IBusinessService, LoggingBusinessService>(provider =>
new LoggingBusinessService(
provider.GetRequiredService<BusinessService>(),
provider.GetRequiredService<ILogger<LoggingBusinessService>>()
));
Lazy<T> for expensive dependenciespublic class MainViewModel
{
private readonly Lazy<IExpensiveService> _expensiveService;
public MainViewModel(Lazy<IExpensiveService> expensiveService)
{
_expensiveService = expensiveService;
}
private void UseExpensiveService()
{
// Service is only created when first accessed
_expensiveService.Value.DoSomething();
}
}
Hit F5 in Rider and marvel at your professionally architected application! You now have:
System.InvalidOperationException: Unable to resolve service for type 'IBusinessService'
Solution: Ensure the service is registered in ServiceCollectionExtensions
System.InvalidOperationException: A circular dependency was detected
Solution: Break the cycle by using interfaces or restructuring dependencies
Symptoms: Slow application startup or memory usage Solution: Review service lifetimes and consider lazy initialization
You’ve just transformed a simple todo app into a professionally architected application using dependency injection. Your code is now more maintainable, testable, and follows industry best practices that scale to enterprise applications.
The separation between your business logic, data access, and presentation layers means you can easily swap implementations, add new features, and test components in isolation. This is the foundation upon which large, complex applications are built.