This guide provides in-depth information about developing Modulus modules, including architecture, best practices, and advanced topics.
A Modulus module consists of multiple projects:
MyModule/
├── MyModule.sln # Solution file
├── extension.vsixmanifest # Module manifest
├── MyModule.Core/ # Core logic (required)
│ ├── MyModule.Core.csproj
│ ├── MyModuleModule.cs # Entry point
│ ├── ViewModels/
│ │ └── MainViewModel.cs
│ └── Services/
│ └── MyService.cs
├── MyModule.UI.Avalonia/ # Avalonia UI (optional)
│ ├── MyModule.UI.Avalonia.csproj
│ ├── MyModuleAvaloniaModule.cs
│ └── Views/
│ └── MainView.axaml
└── MyModule.UI.Blazor/ # Blazor UI (optional)
├── MyModule.UI.Blazor.csproj
├── MyModuleBlazorModule.cs
└── Pages/
└── MainView.razor
Modules run in an isolated AssemblyLoadContext to prevent conflicts with other modules.
Shared assemblies (loaded once by host):
Modulus.CoreModulus.SdkModulus.UI.AbstractionsModulus.UI.Avalonia/Modulus.UI.Blazor
Module assemblies (isolated per module):
- Your module DLLs
- Third-party dependencies
Every module must have at least one class that extends ModulusPackage:
using Modulus.Sdk;
namespace MyModule.Core;
public class MyModuleModule : ModulusPackage
{
public override void ConfigureServices(IModuleLifecycleContext context)
{
// Register your services
context.Services.AddSingleton<IMyService, MyService>();
context.Services.AddTransient<MainViewModel>();
}
public override Task OnActivatedAsync(IModuleActivationContext context)
{
// Called when module is activated
var logger = context.Services.GetRequiredService<ILogger<MyModuleModule>>();
logger.LogInformation("MyModule activated!");
return Task.CompletedTask;
}
public override Task OnDeactivatingAsync(IModuleDeactivationContext context)
{
// Called before module is deactivated
// Clean up resources here
return Task.CompletedTask;
}
}Modulus uses Microsoft.Extensions.DependencyInjection. Register services in ConfigureServices:
public override void ConfigureServices(IModuleLifecycleContext context)
{
// Singleton - one instance for entire module lifetime
context.Services.AddSingleton<IMyService, MyService>();
// Scoped - one instance per scope (e.g., per page/view)
context.Services.AddScoped<IDataContext, DataContext>();
// Transient - new instance every time
context.Services.AddTransient<MainViewModel>();
}Host services are automatically available:
public class MainViewModel
{
private readonly INavigationService _navigation;
private readonly IDialogService _dialog;
private readonly ILogger<MainViewModel> _logger;
public MainViewModel(
INavigationService navigation,
IDialogService dialog,
ILogger<MainViewModel> logger)
{
_navigation = navigation;
_dialog = dialog;
_logger = logger;
}
}Use CommunityToolkit.Mvvm for MVVM support:
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace MyModule.ViewModels;
public partial class MainViewModel : ObservableObject
{
[ObservableProperty]
private string _title = "My Module";
[ObservableProperty]
private string _message = "";
[RelayCommand]
private async Task LoadDataAsync()
{
Message = "Loading...";
// Load data
Message = "Done!";
}
}Navigate between views using INavigationService:
[RelayCommand]
private async Task NavigateToDetailsAsync()
{
await _navigation.NavigateToAsync<DetailsViewModel>(new { Id = 123 });
}<!-- MainView.axaml -->
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:MyModule.ViewModels"
x:Class="MyModule.UI.Avalonia.MainView"
x:DataType="vm:MainViewModel">
<StackPanel Spacing="16" Margin="24">
<TextBlock Text="{Binding Title}"
Theme="{StaticResource TitleTextBlockStyle}" />
<TextBox Text="{Binding Message}"
Watermark="Enter message..." />
<Button Content="Load Data"
Command="{Binding LoadDataCommand}" />
</StackPanel>
</UserControl>@* MainView.razor *@
@using MyModule.ViewModels
@inherits ModulusComponentBase<MainViewModel>
<div class="container">
<h1>@ViewModel.Title</h1>
<input @bind="ViewModel.Message"
placeholder="Enter message..." />
<button @onclick="ViewModel.LoadDataCommand.Execute">
Load Data
</button>
</div>For each UI platform, create a separate module class:
// MyModuleAvaloniaModule.cs
using Modulus.UI.Avalonia;
namespace MyModule.UI.Avalonia;
public class MyModuleAvaloniaModule : ModulusPackage
{
public override void ConfigureServices(IModuleLifecycleContext context)
{
// Register Avalonia-specific views
context.Services.AddTransient<MainView>();
}
}<?xml version="1.0" encoding="utf-8"?>
<PackageManifest Version="2.0.0"
xmlns="http://schemas.microsoft.com/developer/vsx-schema/2011">
<Metadata>
<Identity Id="unique-guid" Version="1.0.0" Publisher="YourName" />
<DisplayName>My Module</DisplayName>
<Description>Module description</Description>
<Tags>category, keywords</Tags>
</Metadata>
<Installation>
<InstallationTarget Id="Modulus.Host.Avalonia" Version="[1.0,)" />
<InstallationTarget Id="Modulus.Host.Blazor" Version="[1.0,)" />
</Installation>
<Assets>
<!-- Core assembly (always loaded) -->
<Asset Type="Modulus.Package" Path="MyModule.Core.dll" />
<!-- UI assemblies (loaded based on host) -->
<Asset Type="Modulus.Package" Path="MyModule.UI.Avalonia.dll"
TargetHost="Modulus.Host.Avalonia" />
<Asset Type="Modulus.Package" Path="MyModule.UI.Blazor.dll"
TargetHost="Modulus.Host.Blazor" />
<!-- Menu items are NOT declared in the manifest anymore.
They are declared via attributes on the host-specific module entry type and projected to DB at install/update time. -->
</Assets>
</PackageManifest>| Location | Description |
|---|---|
Main |
Main sidebar (default) |
Bottom |
Bottom of sidebar |
Settings |
Settings section |
// Avalonia UI module entry
[AvaloniaMenu("dashboard", "Dashboard", typeof(DashboardViewModel), Icon = IconKind.Home, Location = MenuLocation.Main, Order = 10)]
[AvaloniaMenu("settings", "Settings", typeof(SettingsViewModel), Icon = IconKind.Settings, Location = MenuLocation.Bottom, Order = 100)]
public sealed class MyModuleAvaloniaModule : ModulusPackage { }
// Blazor UI module entry
[BlazorMenu("dashboard", "Dashboard", "/dashboard", Icon = IconKind.Home, Location = MenuLocation.Main, Order = 10)]
[BlazorMenu("settings", "Settings", "/settings", Icon = IconKind.Settings, Location = MenuLocation.Bottom, Order = 100)]
public sealed class MyModuleBlazorModule : ModulusPackage { }[Fact]
public async Task LoadData_ShouldUpdateMessage()
{
// Arrange
var vm = new MainViewModel();
// Act
await vm.LoadDataCommand.ExecuteAsync(null);
// Assert
Assert.Equal("Done!", vm.Message);
}[Fact]
public async Task Module_ShouldLoadSuccessfully()
{
// Arrange
var services = new ServiceCollection();
var context = new TestModuleLifecycleContext(services);
var module = new MyModuleModule();
// Act
module.ConfigureServices(context);
var provider = services.BuildServiceProvider();
// Assert
var service = provider.GetService<IMyService>();
Assert.NotNull(service);
}Don't reference Avalonia or Blazor in your Core project:
// ❌ Wrong - in Core project
using Avalonia.Controls;
// ✅ Correct - use abstractions
using Modulus.UI.Abstractions;// ❌ Wrong
public void LoadData()
{
var data = _service.GetData().Result; // Blocks thread
}
// ✅ Correct
public async Task LoadDataAsync()
{
var data = await _service.GetDataAsync();
}public override async Task OnDeactivatingAsync(IModuleDeactivationContext context)
{
// Clean up subscriptions, timers, etc.
_subscription?.Dispose();
await _database.CloseAsync();
}[RelayCommand]
private async Task LoadDataAsync()
{
try
{
Data = await _service.GetDataAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load data");
await _dialog.ShowErrorAsync("Failed to load data", ex.Message);
}
}- Set the Host project as startup project
- Set breakpoints in your module code
- Run with debugger (F5)
public class MyService
{
private readonly ILogger<MyService> _logger;
public MyService(ILogger<MyService> logger)
{
_logger = logger;
}
public async Task DoWorkAsync()
{
_logger.LogInformation("Starting work...");
_logger.LogDebug("Details: {Details}", someDetails);
_logger.LogWarning("Something unusual: {Issue}", issue);
_logger.LogError(exception, "Work failed");
}
}Version is read from the manifest Identity/@Version:
<Identity Id="mymodule-id" Version="1.2.3" Publisher="Acme" /># Package
modulus pack
# The .modpkg file can be distributed via:
# - Direct download
# - GitHub Releases
# - (Future) Modulus Module Store