11 KiB
ADR-004: Static Managers for Theming
Status
Accepted
Context
The theming system requires global coordination across:
- Application resource dictionary management
- System theme synchronization
- Accent color application
- Window appearance updates
Multiple approaches exist:
- Static classes (global singleton)
- Instance-based services (registered in DI container)
- Ambient context pattern (ThreadStatic/AsyncLocal)
Decision
Use static class singleton pattern for core theme managers:
ApplicationThemeManagerApplicationAccentColorManagerSystemThemeWatcherWindowBackgroundManagerResourceDictionaryManager(internal)
Implementation Pattern
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;
}
}
No Constructor, No Instances
public static class ApplicationThemeManager
{
// All members are static
// Cannot be instantiated
// Cannot be inherited
// Cannot be mocked/substituted
}
Rationale
Simple API Surface
// 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();
Single Source of Truth
Application theme is fundamentally global state:
- Only one theme can be active
- All windows share the same theme
- Resource dictionaries are process-wide
Static class enforces singleton semantics at compile-time.
No DI Configuration Required
// 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 Application Model Alignment
WPF itself uses static patterns extensively:
Application.Current(static property)Application.Current.Resources(global resource dictionary)SystemColors(static class)SystemParameters(static class)
Trade-offs
Advantages
1. Simplicity
- No DI configuration required
- No interface abstraction needed
- Clear that state is global
- Immediate usability
2. Performance
- Zero overhead (no interface dispatch)
- No allocation for manager instances
- Direct static method calls
3. Discoverability
- Easy to find with IntelliSense
- Self-documenting via naming (
ApplicationThemeManagerclearly global) - No need to understand DI container
4. Compatibility
- Works in non-DI scenarios (simple WPF apps)
- Compatible with .NET Framework patterns
- No breaking changes if DI added later
Disadvantages
1. Limited Testability
- Cannot mock static classes
- Difficult to isolate in unit tests
- Tests may affect each other through shared state
Mitigation:
- Extract
IThemeServicefor consumers who need testability - Test through integration tests instead of unit tests
- Use
[Collection]attribute in XUnit to isolate test state
2. Hidden Dependencies
- Static call hides dependency
- Harder to track theme manager usage
- Violates dependency injection principle
Mitigation:
- Theme management is intentionally global
- Not a "hidden" dependency if explicitly documented as global
3. Global State
- Mutable global state
- Concurrent access concerns
- No lifetime management
Mitigation:
- Theme changes are inherently single-threaded (UI thread)
Application.Current.Resourcesis already global mutable state- No need for lifetime management (lives entire process lifetime)
4. No Polymorphism
- Cannot substitute alternative implementations
- Cannot extend behavior through inheritance
Mitigation:
- Theme system is not extensible by design
- Alternative implementations not a use case
Service Interface for DI
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 Pattern
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:
- Non-static class instantiated with an
Applicationparameter - Uses
[ThreadStatic]backing field_uiApplicationfor thread-local singleton - Each UI thread gets its own
UiApplicationinstance - Safely wraps
Application.Current(which is also thread-local) - Allows future expansion with per-thread state
Enforcement
MUST Follow
-
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); } }
MUST NOT Do
-
Never attempt to mock static classes in unit tests
- Use
IThemeServiceinstead if testing is needed
- Use
-
Never cache theme state in instance fields
- Always query
ApplicationThemeManager.GetAppTheme()for current state
- Always query
-
Never create parallel theme systems
- Static managers are the single source of truth
Verification
- Code review enforces proper usage
- Documentation clearly marks classes as static global managers
IThemeServiceprovides tested alternative where needed
Testing Strategy
Integration Tests
Theme 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();
}
Unit Tests for Consumer Code
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);
}
Documentation Requirements
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
{
// ...
}
Future Considerations
Potential Migration to Instances
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.
References
- WPF Static Classes:
Application.Current,SystemColors,SystemParameters - Gang of Four Singleton Pattern
- Dependency Injection Anti-Pattern: Service Locator