Level Up Your Avalonia Apps: Mastering Dependency Injection for Professional Development

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.

Why Dependency Injection Will Change Your Development Game

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;
    }
}

Setting Up Microsoft.Extensions.DependencyInjection

Let’s enhance our todo application with proper dependency injection. Fire up your project in Rider and let’s get started.

Installing the DI Container

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.

Building a Layered Architecture with Services

Before implementing DI, we need proper services to inject. Let’s create a realistic application structure.

Creating the Data Layer

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();
}

Implementing the Repository

// 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);
    }
}

Creating the Business Service Layer

// 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)";
    }
}

Updating Your ViewModel for Dependency Injection

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();
    }
}

Updated TodoItemViewModel

// 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";
    }
}

Creating the Service Registration Extension

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>();
    }
}

Understanding Service Lifetimes

Singleton: One instance for the entire application lifetime

  • Use for: Stateless services, caches, configuration
  • Example: Repository, business services

Transient: New instance every time it’s requested

  • Use for: Lightweight, stateless objects, ViewModels
  • Example: ViewModels, DTOs, commands

Scoped: One instance per scope (not commonly used in desktop apps)

  • Use for: Web applications with request scopes
  • Example: Database contexts in web apps

Configuring Dependency Injection in App.xaml.cs

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;
    }
}

Enhancing the UI with Loading States

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>

Adding a Toggle Text Converter

// 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>

Testing Your Dependency-Injected Application

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

Advanced DI Patterns and Best Practices

Configuration-Based Service Registration

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>();
    }
}

Factory Pattern with DI

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>();
    }
}

Decorator Pattern Implementation

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>>()
    ));

Performance Considerations for DI

Avoiding Common Pitfalls

  1. Don’t over-inject: Not everything needs to be injected
  2. Choose appropriate lifetimes: Singletons for expensive-to-create objects
  3. Avoid circular dependencies: Use interfaces and careful design
  4. Consider lazy loading: Use Lazy<T> for expensive dependencies
public 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();
    }
}

Running Your Professional Application

Hit F5 in Rider and marvel at your professionally architected application! You now have:

  • ✅ Proper separation of concerns
  • ✅ Testable architecture with mocked dependencies
  • ✅ Flexible service registration
  • ✅ Loading states and error handling
  • ✅ Productivity statistics
  • ✅ Professional code structure

Troubleshooting Common DI Issues

Service Not Found Exceptions

System.InvalidOperationException: Unable to resolve service for type 'IBusinessService'

Solution: Ensure the service is registered in ServiceCollectionExtensions

Circular Dependency Errors

System.InvalidOperationException: A circular dependency was detected

Solution: Break the cycle by using interfaces or restructuring dependencies

Performance Issues

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.

Mohammed Chami
Mohammed Chami
Articles: 45

Leave a Reply

Your email address will not be published. Required fields are marked *