docs/architecture/decisions/ADR-004-static-managers-for-theming.md
Accepted
The theming system requires global coordination across:
Multiple approaches exist:
Use static class singleton pattern for core theme managers:
ApplicationThemeManagerApplicationAccentColorManagerSystemThemeWatcherWindowBackgroundManagerResourceDictionaryManager (internal)public static class ApplicationThemeManager
{
// Global state
private static ApplicationTheme _currentTheme = ApplicationTheme.Unknown;
// Global event
public static event ThemeChangedEvent? Changed;
// Static methods
public static void Apply(ApplicationTheme theme)
{
if (_currentTheme == theme)
return;
ResourceDictionaryManager manager = new(LibraryNamespace);
manager.UpdateDictionary("theme", GetThemeUri(theme));
_currentTheme = theme;
Changed?.Invoke(theme, GetSystemAccent());
}
public static ApplicationTheme GetAppTheme()
{
return _currentTheme;
}
}
public static class ApplicationThemeManager
{
// All members are static
// Cannot be instantiated
// Cannot be inherited
// Cannot be mocked/substituted
}
// Immediate clarity of global scope
ApplicationThemeManager.Apply(ApplicationTheme.Dark);
var current = ApplicationThemeManager.GetAppTheme();
Compared to instance-based:
// Requires context about where themeManager comes from
_themeManager.Apply(ApplicationTheme.Dark);
var current = _themeManager.GetAppTheme();
Application theme is fundamentally global state:
Static class enforces singleton semantics at compile-time.
// Works immediately without setup
public MainWindow()
{
InitializeComponent();
ApplicationThemeManager.Apply(ApplicationTheme.Dark);
}
Instance-based would require:
// App.xaml.cs
services.AddSingleton<IThemeManager, ThemeManager>();
// MainWindow.xaml.cs
public MainWindow(IThemeManager themeManager)
{
_themeManager = themeManager;
InitializeComponent();
_themeManager.Apply(ApplicationTheme.Dark);
}
WPF itself uses static patterns extensively:
Application.Current (static property)Application.Current.Resources (global resource dictionary)SystemColors (static class)SystemParameters (static class)1. Simplicity
2. Performance
3. Discoverability
ApplicationThemeManager clearly global)4. Compatibility
1. Limited Testability
Mitigation:
IThemeService for consumers who need testability[Collection] attribute in XUnit to isolate test state2. Hidden Dependencies
Mitigation:
3. Global State
Mitigation:
Application.Current.Resources is already global mutable state4. No Polymorphism
Mitigation:
For consumers requiring testability, IThemeService wraps static managers:
public interface IThemeService
{
ApplicationTheme GetTheme();
SystemTheme GetNativeSystemTheme();
ApplicationTheme GetSystemTheme();
bool SetTheme(ApplicationTheme applicationTheme);
bool SetSystemAccent();
bool SetAccent(Color accentColor);
bool SetAccent(SolidColorBrush accentSolidBrush);
}
public partial class ThemeService : IThemeService
{
public ApplicationTheme GetTheme()
=> ApplicationThemeManager.GetAppTheme();
public SystemTheme GetNativeSystemTheme()
=> ApplicationThemeManager.GetSystemTheme();
public ApplicationTheme GetSystemTheme()
=> ApplicationThemeManager.GetSystemTheme() switch { ... };
public bool SetTheme(ApplicationTheme applicationTheme)
{
ApplicationThemeManager.Apply(applicationTheme);
return true;
}
public bool SetSystemAccent()
{
ApplicationAccentColorManager.ApplySystemAccent();
return true;
}
public bool SetAccent(Color accentColor)
{
ApplicationAccentColorManager.Apply(accentColor);
return true;
}
public bool SetAccent(SolidColorBrush accentSolidBrush)
{
ApplicationAccentColorManager.Apply(accentSolidBrush.Color);
return true;
}
}
Registration:
services.AddSingleton<IThemeService, ThemeService>();
Usage in testable code:
public class SettingsViewModel
{
private readonly IThemeService _themeService;
public SettingsViewModel(IThemeService themeService)
{
_themeService = themeService;
}
public void ApplyDarkMode()
{
_themeService.SetTheme(ApplicationTheme.Dark);
}
}
Testing with mock:
[Fact]
public void ApplyDarkMode_CallsThemeService()
{
// Arrange
var mockThemeService = Substitute.For<IThemeService>();
var viewModel = new SettingsViewModel(mockThemeService);
// Act
viewModel.ApplyDarkMode();
// Assert
mockThemeService.Received(1).SetTheme(ApplicationTheme.Dark);
}
UiApplication uses [ThreadStatic] for thread-local singleton:
public class UiApplication
{
[ThreadStatic]
private static UiApplication? _uiApplication;
public static UiApplication? Current => _uiApplication;
public UiApplication(Application application)
{
// Stores the application reference and sets _uiApplication
}
// Instance methods operate on the wrapped Application
public ResourceDictionary Resources => Application.Current.Resources;
}
Rationale:
Application parameter[ThreadStatic] backing field _uiApplication for thread-local singletonUiApplication instanceApplication.Current (which is also thread-local)Use static managers directly for simple scenarios:
ApplicationThemeManager.Apply(ApplicationTheme.Dark);
Use IThemeService in testable/DI-dependent code:
public ViewModel(IThemeService themeService) { }
Document global nature in XML docs:
/// <summary>
/// Global theme manager. Applies themes application-wide.
/// </summary>
Never create wrapper instances of static managers:
// BAD: Don't do this
public class ThemeManagerWrapper
{
private ApplicationTheme _cachedTheme;
public void Apply(ApplicationTheme theme)
{
_cachedTheme = theme;
ApplicationThemeManager.Apply(theme);
}
}
Never attempt to mock static classes in unit tests
IThemeService instead if testing is neededNever cache theme state in instance fields
ApplicationThemeManager.GetAppTheme() for current stateNever create parallel theme systems
IThemeService provides tested alternative where neededTheme system is tested through integration tests that exercise full stack:
[Fact]
public async Task ThemeChange_UpdatesWindowAppearance()
{
// Arrange
var window = new FluentWindow();
window.Show();
// Act
ApplicationThemeManager.Apply(ApplicationTheme.Dark);
await Task.Delay(500); // Allow visual update
// Assert
var theme = ApplicationThemeManager.GetAppTheme();
Assert.Equal(ApplicationTheme.Dark, theme);
// Cleanup
window.Close();
}
Consumer code uses IThemeService for testability:
[Fact]
public void ApplyDarkTheme_UpdatesCurrentTheme()
{
var themeService = Substitute.For<IThemeService>();
var viewModel = new SettingsViewModel(themeService);
viewModel.ApplyDarkTheme();
themeService.Received().SetTheme(ApplicationTheme.Dark);
}
All static manager classes include XML doc warning:
/// <summary>
/// Global static manager for application theming.
/// </summary>
/// <remarks>
/// <para>
/// This is a static class managing process-wide theme state.
/// Theme changes affect all windows in the application.
/// </para>
/// <para>
/// For testable code, use <see cref="IThemeService"/> instead.
/// </para>
/// </remarks>
public static class ApplicationThemeManager
{
// ...
}
If future requirements demand it, can migrate while maintaining compatibility:
// New instance-based implementation
public sealed class ThemeManager : IThemeManager
{
// Instance implementation
}
// Static facade maintains compatibility
public static class ApplicationThemeManager
{
private static readonly IThemeManager _instance = new ThemeManager();
public static void Apply(ApplicationTheme theme)
=> _instance.Apply(theme);
}
This preserves existing code while enabling DI-based usage.
Application.Current, SystemColors, SystemParameters