wpf-best-practices
WPF Best Practices
You are an expert in C#, .NET, and WPF desktop application development with deep knowledge of MVVM architecture, dependency injection, async/await patterns, and Windows desktop integration.
C# Code Style
Basic Principles
- Use English for all code and documentation
- Always declare types for variables and functions (parameters and return values)
- Enable nullable reference types project-wide
- Use file-scoped namespaces — no braces, no nesting
- Write concise, maintainable code — avoid over-engineering
File-Scoped Namespaces
Always use file-scoped namespaces:
namespace MyApp.Services;
public class SettingsService
{
// No extra indentation level
}
Nullable Reference Types
Always enabled in csproj (<Nullable>enable</Nullable>):
public class AppNotification
{
public string Title { get; set; } = ""; // Non-nullable with default
public string Message { get; set; } = "";
public string? Channel { get; set; } // Explicitly nullable
public string[]? Tags { get; set; }
}
Naming Conventions
- PascalCase for types, interfaces, properties, methods, events, constants
- camelCase for local variables and parameters
- _camelCase for private fields
- IPascalCase for interfaces (prefix with
I) - UPPERCASE for environment variables only
- Prefix boolean properties/fields with
Is,Has,Can,Should
public class ConnectionService
{
private readonly ILogger _logger;
private readonly string _serverUrl;
private bool _isConnected;
public bool IsConnected => _isConnected;
public bool CanReconnect { get; private set; }
}
Modern C# Features
Use switch expressions:
public string StatusIcon => Status switch
{
ConnectionStatus.Connected => "🟢",
ConnectionStatus.Connecting => "🟡",
ConnectionStatus.Disconnected => "🔴",
_ => "⚪"
};
Use range/slice operators:
private static string Capitalize(string s) =>
string.IsNullOrEmpty(s) ? s : char.ToUpper(s[0]) + s[1..];
Avoid redundant default initializers:
// Good
private bool _disposed;
private int _retryCount;
// Bad — redundant
private bool _disposed = false;
private int _retryCount = 0;
Project Structure
MyApp/
├── MyApp.sln # Solution file
├── src/
│ ├── MyApp/ # Main WPF application
│ │ ├── App.xaml # Application entry, resource dictionaries
│ │ ├── App.xaml.cs # DI setup, app lifecycle, crash handlers
│ │ ├── Views/ # XAML windows and user controls
│ │ ├── ViewModels/ # MVVM presentation logic
│ │ ├── Models/ # Data classes and enums
│ │ ├── Services/ # Business logic (DI singletons)
│ │ │ └── Interfaces/ # Service contracts
│ │ ├── Utilities/ # Helpers (encryption, screen, JSON)
│ │ └── Constants/ # App-wide constants
│ └── MyApp.Shared/ # Cross-platform shared library
│ └── MyApp.Shared.csproj
├── tests/
│ └── MyApp.Tests/ # xUnit test project
│ └── MyApp.Tests.csproj
└── .github/
└── workflows/ # CI/CD
Project File Configuration
WPF app csproj:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
Shared library csproj (cross-platform):
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="MyApp.Tests" />
</ItemGroup>
</Project>
MVVM Pattern
ViewModelBase
Every ViewModel should inherit from a shared base:
namespace MyApp.ViewModels;
public abstract class ViewModelBase : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string? name = null) =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
protected bool SetProperty<T>(ref T field, T value,
[CallerMemberName] string? name = null)
{
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
field = value;
OnPropertyChanged(name);
return true;
}
}
Usage:
public class MainViewModel : ViewModelBase
{
private string _title = "";
public string Title
{
get => _title;
set => SetProperty(ref _title, value);
}
private bool _isLoading;
public bool IsLoading
{
get => _isLoading;
set => SetProperty(ref _isLoading, value);
}
}
RelayCommand
Use a simple RelayCommand for XAML command bindings:
namespace MyApp.ViewModels;
public class RelayCommand : ICommand
{
private readonly Action<object?> _execute;
private readonly Func<object?, bool>? _canExecute;
public RelayCommand(Action<object?> execute, Func<object?, bool>? canExecute = null)
{
_execute = execute;
_canExecute = canExecute;
}
public event EventHandler? CanExecuteChanged
{
add => CommandManager.RequerySuggested += value;
remove => CommandManager.RequerySuggested -= value;
}
public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true;
public void Execute(object? parameter) => _execute(parameter);
}
public class RelayCommand<T> : ICommand
{
private readonly Action<T?> _execute;
private readonly Func<T?, bool>? _canExecute;
public RelayCommand(Action<T?> execute, Func<T?, bool>? canExecute = null)
{
_execute = execute;
_canExecute = canExecute;
}
public event EventHandler? CanExecuteChanged
{
add => CommandManager.RequerySuggested += value;
remove => CommandManager.RequerySuggested -= value;
}
public bool CanExecute(object? parameter) =>
_canExecute?.Invoke(parameter is T t ? t : default) ?? true;
public void Execute(object? parameter) =>
_execute(parameter is T t ? t : default);
}
AsyncRelayCommand
For async operations bound to UI:
namespace MyApp.ViewModels;
public class AsyncRelayCommand : ICommand
{
private readonly Func<object?, Task> _execute;
private readonly Func<object?, bool>? _canExecute;
private bool _isExecuting;
public AsyncRelayCommand(Func<object?, Task> execute,
Func<object?, bool>? canExecute = null)
{
_execute = execute;
_canExecute = canExecute;
}
public event EventHandler? CanExecuteChanged
{
add => CommandManager.RequerySuggested += value;
remove => CommandManager.RequerySuggested -= value;
}
public bool CanExecute(object? parameter) =>
!_isExecuting && (_canExecute?.Invoke(parameter) ?? true);
public async void Execute(object? parameter)
{
if (_isExecuting) return;
_isExecuting = true;
CommandManager.InvalidateRequerySuggested();
try
{
await _execute(parameter);
}
finally
{
_isExecuting = false;
CommandManager.InvalidateRequerySuggested();
}
}
}
Data Binding in XAML
<Window x:Class="MyApp.Views.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="{Binding Title}">
<Grid>
<TextBlock Text="{Binding StatusText}"
Visibility="{Binding IsLoading,
Converter={StaticResource BoolToVisibilityConverter}}" />
<Button Content="Save"
Command="{Binding SaveCommand}"
IsEnabled="{Binding CanSave}" />
</Grid>
</Window>
Code-behind should only set the DataContext:
public partial class MainWindow : Window
{
public MainWindow(MainViewModel viewModel)
{
InitializeComponent();
DataContext = viewModel;
}
}
Dependency Injection
Use Microsoft.Extensions.DependencyInjection for all service wiring.
Setup in App.xaml.cs
namespace MyApp;
public partial class App : Application
{
private ServiceProvider? _serviceProvider;
protected override void OnStartup(StartupEventArgs e)
{
var services = new ServiceCollection();
ConfigureServices(services);
_serviceProvider = services.BuildServiceProvider();
var mainWindow = _serviceProvider.GetRequiredService<MainWindow>();
mainWindow.Show();
base.OnStartup(e);
}
private static void ConfigureServices(IServiceCollection services)
{
// Services — singletons for app-wide state
services.AddSingleton<ISettingsService, SettingsService>();
services.AddSingleton<IConnectionService, ConnectionService>();
// HTTP clients
services.AddHttpClient("AppClient", client =>
{
client.DefaultRequestHeaders.Add("User-Agent", "MyApp/1.0");
client.Timeout = TimeSpan.FromSeconds(10);
});
// ViewModels — transient, created per window
services.AddTransient<MainViewModel>();
// Windows
services.AddTransient<MainWindow>();
}
protected override void OnExit(ExitEventArgs e)
{
(_serviceProvider as IDisposable)?.Dispose();
base.OnExit(e);
}
}
Service Pattern
Always define an interface. Inject via constructor. Never use a service locator.
// Interface
namespace MyApp.Services.Interfaces;
public interface ISettingsService
{
string GetValue(string key, string defaultValue = "");
void SetValue(string key, string value);
void Save();
}
// Implementation
namespace MyApp.Services;
public class SettingsService : ISettingsService
{
private readonly string _settingsPath;
private Dictionary<string, string> _settings = new();
public SettingsService()
{
_settingsPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"MyApp", "settings.json");
Load();
}
public string GetValue(string key, string defaultValue = "") =>
_settings.TryGetValue(key, out var value) ? value : defaultValue;
public void SetValue(string key, string value) =>
_settings[key] = value;
public void Save()
{
var dir = Path.GetDirectoryName(_settingsPath)!;
Directory.CreateDirectory(dir);
var json = JsonSerializer.Serialize(_settings,
new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(_settingsPath, json);
}
private void Load()
{
try
{
if (!File.Exists(_settingsPath)) return;
var json = File.ReadAllText(_settingsPath);
_settings = JsonSerializer.Deserialize<Dictionary<string, string>>(json) ?? new();
}
catch { _settings = new(); }
}
}
Anti-Pattern: Service Locator
Never resolve services from a static provider. Always inject through the constructor:
// BAD — service locator
public class MyViewModel
{
public void DoSomething()
{
var service = App.ServiceProvider.GetService<IMyService>();
service?.Execute();
}
}
// GOOD — constructor injection
public class MyViewModel
{
private readonly IMyService _myService;
public MyViewModel(IMyService myService)
{
_myService = myService;
}
public void DoSomething() => _myService.Execute();
}
If circular dependencies arise, use Lazy<T>:
private readonly Lazy<IMyService> _myService;
public MyViewModel(Lazy<IMyService> myService)
{
_myService = myService;
}
Async/Await Patterns
Rules
- Never use
.Result,.Wait(), or.GetAwaiter().GetResult()— they deadlock WPF's single-threaded STA dispatcher - Always propagate
CancellationTokenthrough async methods - Use
ConfigureAwait(false)in library/service code (not in ViewModels or code-behind where you need the UI context) - Never fire-and-forget without error handling
UI Thread Marshaling
Use Dispatcher to update UI from background threads:
// From any thread — safe UI update
private void OnDataReceived(object? sender, DataEventArgs e)
{
Application.Current.Dispatcher.InvokeAsync(() =>
{
StatusText = e.Message;
Items.Add(e.Item);
});
}
For ViewModels that update from background threads:
public class DashboardViewModel : ViewModelBase
{
private readonly Dispatcher _dispatcher;
public DashboardViewModel()
{
_dispatcher = Application.Current.Dispatcher;
}
public async Task RefreshAsync(CancellationToken ct = default)
{
var data = await _dataService.FetchAsync(ct).ConfigureAwait(false);
_dispatcher.InvokeAsync(() =>
{
Items.Clear();
foreach (var item in data)
Items.Add(item);
});
}
}
DispatcherTimer for Periodic Work
private DispatcherTimer? _statusTimer;
private void StartStatusPolling()
{
_statusTimer = new DispatcherTimer
{
Interval = TimeSpan.FromSeconds(5)
};
_statusTimer.Tick += async (s, e) =>
{
try
{
await RefreshStatusAsync();
}
catch (Exception ex)
{
Debug.WriteLine($"Status poll failed: {ex.Message}");
}
};
_statusTimer.Start();
}
Safe Fire-and-Forget
Never use bare _ = Task.Run(...). Always wrap with error handling:
namespace MyApp.Utilities;
internal static class AsyncHelper
{
public static async void FireAndForget(Func<Task> operation, string context)
{
try
{
await operation();
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
Debug.WriteLine($"[{context}] Background task failed: {ex.Message}");
}
}
}
// Usage
AsyncHelper.FireAndForget(
() => LoadDataAsync(),
"MainViewModel.LoadData");
System Tray Integration
WPF does not have built-in system tray support. Use System.Windows.Forms.NotifyIcon:
namespace MyApp.Services;
public class SystemTrayService : IDisposable
{
private readonly System.Windows.Forms.NotifyIcon _notifyIcon;
public SystemTrayService()
{
_notifyIcon = new System.Windows.Forms.NotifyIcon
{
Text = "My App",
Visible = true
};
// Load icon from resources
var iconStream = Application.GetResourceStream(
new Uri("pack://application:,,,/Resources/app.ico"))?.Stream;
if (iconStream != null)
_notifyIcon.Icon = new System.Drawing.Icon(iconStream);
_notifyIcon.DoubleClick += (s, e) => ShowMainWindow();
BuildContextMenu();
}
private void BuildContextMenu()
{
var menu = new System.Windows.Forms.ContextMenuStrip();
menu.Items.Add("Show", null, (s, e) => ShowMainWindow());
menu.Items.Add(new System.Windows.Forms.ToolStripSeparator());
menu.Items.Add("Exit", null, (s, e) => Application.Current.Shutdown());
_notifyIcon.ContextMenuStrip = menu;
}
private static void ShowMainWindow()
{
var window = Application.Current.MainWindow;
if (window == null) return;
window.Show();
window.WindowState = WindowState.Normal;
window.Activate();
}
public void Dispose()
{
_notifyIcon.Visible = false;
_notifyIcon.Dispose();
}
}
GDI Handle Management
Always destroy GDI icon handles to prevent leaks:
[DllImport("user32.dll", CharSet = CharSet.Auto)]
private static extern bool DestroyIcon(IntPtr handle);
public static System.Drawing.Icon CreateStatusIcon(System.Drawing.Color color)
{
using var bitmap = new System.Drawing.Bitmap(16, 16);
using var g = System.Drawing.Graphics.FromImage(bitmap);
using var brush = new System.Drawing.SolidBrush(color);
g.Clear(System.Drawing.Color.Transparent);
g.FillEllipse(brush, 2, 2, 12, 12);
var hIcon = bitmap.GetHicon();
var icon = System.Drawing.Icon.FromHandle(hIcon);
var result = (System.Drawing.Icon)icon.Clone(); // Clone to own the data
DestroyIcon(hIcon); // CRITICAL: release GDI handle
return result;
}
Cache icons — don't recreate them on every state change:
private static System.Drawing.Icon? _connectedIcon;
private static System.Drawing.Icon? _disconnectedIcon;
public void UpdateStatus(bool connected)
{
_connectedIcon ??= CreateStatusIcon(System.Drawing.Color.Green);
_disconnectedIcon ??= CreateStatusIcon(System.Drawing.Color.Red);
_notifyIcon.Icon = connected ? _connectedIcon : _disconnectedIcon;
}
Window Management
Minimize to Tray
public partial class MainWindow : Window
{
protected override void OnStateChanged(EventArgs e)
{
if (WindowState == WindowState.Minimized)
Hide();
base.OnStateChanged(e);
}
protected override void OnClosing(CancelEventArgs e)
{
e.Cancel = true; // Don't close, minimize to tray
Hide();
}
}
Transparent Overlay Windows
For transparent floating windows (widgets, overlays):
<Window AllowsTransparency="True"
WindowStyle="None"
Background="Transparent"
Topmost="True"
ShowInTaskbar="False">
<!-- Content renders over desktop -->
</Window>
Single Instance with Mutex
private static Mutex? _mutex;
protected override void OnStartup(StartupEventArgs e)
{
const string mutexName = "Global\\MyAppSingleInstance";
_mutex = new Mutex(true, mutexName, out bool isNewInstance);
if (!isNewInstance)
{
MessageBox.Show("Application is already running.");
Shutdown();
return;
}
// Continue startup...
}
Global Hotkey Registration
namespace MyApp.Services;
public class GlobalHotkeyService : IDisposable
{
private const int HOTKEY_ID = 9001;
[DllImport("user32.dll")]
private static extern bool RegisterHotKey(IntPtr hWnd, int id, uint mods, uint vk);
[DllImport("user32.dll")]
private static extern bool UnregisterHotKey(IntPtr hWnd, int id);
private readonly IntPtr _hwnd;
public void Register(IntPtr windowHandle, uint modifiers, uint key)
{
RegisterHotKey(windowHandle, HOTKEY_ID, modifiers, key);
}
public void Dispose()
{
UnregisterHotKey(_hwnd, HOTKEY_ID);
}
}
Settings Patterns
JSON Settings with Safe Load/Save
namespace MyApp.Services;
public class SettingsManager
{
private static readonly string SettingsDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"MyApp");
private static readonly string SettingsFile =
Path.Combine(SettingsDir, "settings.json");
// Properties with defaults
public string ServerUrl { get; set; } = "http://localhost:8080";
public bool AutoStart { get; set; }
public bool ShowNotifications { get; set; } = true;
public SettingsManager() => Load();
public void Load()
{
try
{
if (!File.Exists(SettingsFile)) return;
var json = File.ReadAllText(SettingsFile);
var loaded = JsonSerializer.Deserialize<SettingsData>(json);
if (loaded != null)
{
ServerUrl = loaded.ServerUrl ?? ServerUrl;
AutoStart = loaded.AutoStart;
ShowNotifications = loaded.ShowNotifications;
}
}
catch (Exception ex)
{
Debug.WriteLine($"Failed to load settings: {ex.Message}");
}
}
public void Save()
{
try
{
Directory.CreateDirectory(SettingsDir);
var data = new SettingsData
{
ServerUrl = ServerUrl,
AutoStart = AutoStart,
ShowNotifications = ShowNotifications
};
var json = JsonSerializer.Serialize(data,
new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(SettingsFile, json);
}
catch (Exception ex)
{
Debug.WriteLine($"Failed to save settings: {ex.Message}");
}
}
// Keep serialization shape separate from public API
private class SettingsData
{
public string? ServerUrl { get; set; }
public bool AutoStart { get; set; }
public bool ShowNotifications { get; set; } = true;
}
}
Registry for Auto-Start
public static void SetAutoStart(bool enable, string appName)
{
using var key = Registry.CurrentUser.OpenSubKey(
@"SOFTWARE\Microsoft\Windows\CurrentVersion\Run", true);
if (key == null) return;
if (enable)
{
var exe = Environment.ProcessPath;
key.SetValue(appName, $"\"{exe}\"");
}
else
{
key.DeleteValue(appName, throwOnMissingValue: false);
}
}
DPAPI Encryption for Sensitive Settings
using System.Security.Cryptography;
public static string EncryptForCurrentUser(string plainText)
{
var data = Encoding.UTF8.GetBytes(plainText);
var encrypted = ProtectedData.Protect(data, null,
DataProtectionScope.CurrentUser);
return Convert.ToBase64String(encrypted);
}
public static string DecryptForCurrentUser(string encrypted)
{
var data = Convert.FromBase64String(encrypted);
var decrypted = ProtectedData.Unprotect(data, null,
DataProtectionScope.CurrentUser);
return Encoding.UTF8.GetString(decrypted);
}
Error Handling
Crash Handlers
Register all three crash handler layers in App.xaml.cs:
public App()
{
InitializeComponent();
// WPF UI thread exceptions
DispatcherUnhandledException += (s, e) =>
{
LogCrash("DispatcherUnhandled", e.Exception);
e.Handled = true;
};
// CLR unhandled exceptions (all threads)
AppDomain.CurrentDomain.UnhandledException += (s, e) =>
LogCrash("DomainUnhandled", e.ExceptionObject as Exception);
// Unobserved async Task exceptions
TaskScheduler.UnobservedTaskException += (s, e) =>
{
LogCrash("UnobservedTask", e.Exception);
e.SetObserved();
};
}
private static readonly string CrashLogPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"MyApp", "crash.log");
private static void LogCrash(string source, Exception? ex)
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(CrashLogPath)!);
var msg = $"\n[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] {source}\n{ex}\n";
File.AppendAllText(CrashLogPath, msg);
}
catch { }
}
Run Marker for Unclean Exit Detection
private static readonly string RunMarkerPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"MyApp", "running.marker");
protected override void OnStartup(StartupEventArgs e)
{
if (File.Exists(RunMarkerPath))
{
Debug.WriteLine("Previous session did not exit cleanly");
File.Delete(RunMarkerPath);
}
File.WriteAllText(RunMarkerPath, DateTime.Now.ToString("O"));
// ... rest of startup
base.OnStartup(e);
}
protected override void OnExit(ExitEventArgs e)
{
try { File.Delete(RunMarkerPath); } catch { }
base.OnExit(e);
}
Empty Catch Blocks
Never use empty catch blocks. At minimum, log in DEBUG:
// BAD
catch { }
// GOOD — use a safe execution wrapper
namespace MyApp.Utilities;
internal static class SafeExec
{
public static void Try(Action action,
[CallerMemberName] string? caller = null)
{
try { action(); }
catch (Exception ex)
{
Debug.WriteLine($"[SafeExec:{caller}] {ex.Message}");
}
}
public static async Task TryAsync(Func<Task> action,
[CallerMemberName] string? caller = null)
{
try { await action(); }
catch (Exception ex)
{
Debug.WriteLine($"[SafeExec:{caller}] {ex.Message}");
}
}
}
Logging
Thread-Safe File Logger
namespace MyApp.Services;
public static class Logger
{
private static readonly object _lock = new();
private static readonly string _logFilePath;
static Logger()
{
var dir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"MyApp");
Directory.CreateDirectory(dir);
_logFilePath = Path.Combine(dir, "app.log");
// Rotate if > 5MB
try
{
var fi = new FileInfo(_logFilePath);
if (fi.Exists && fi.Length > 5 * 1024 * 1024)
{
var backup = Path.Combine(dir, "app.log.old");
if (File.Exists(backup)) File.Delete(backup);
File.Move(_logFilePath, backup);
}
}
catch { }
}
public static void Info(string message) => Log("INFO", message);
public static void Warn(string message) => Log("WARN", message);
public static void Error(string message) => Log("ERROR", message);
private static void Log(string level, string message)
{
var line = $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] [{level}] {message}";
lock (_lock)
{
try { File.AppendAllText(_logFilePath, line + Environment.NewLine); }
catch { }
}
#if DEBUG
System.Diagnostics.Debug.WriteLine(line);
#endif
}
}
Logger Interface for DI
public interface IAppLogger
{
void Info(string message);
void Debug(string message);
void Warn(string message);
void Error(string message, Exception? ex = null);
}
Testing
xUnit Project Setup
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
<PackageReference Include="xunit" Version="2.*" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.*" />
<PackageReference Include="coverlet.collector" Version="6.*" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\MyApp.Shared\MyApp.Shared.csproj" />
</ItemGroup>
</Project>
Test Patterns
public class SettingsTests : IDisposable
{
private readonly string _tempDir;
public SettingsTests()
{
_tempDir = Path.Combine(Path.GetTempPath(),
$"myapp-test-{Guid.NewGuid():N}");
Directory.CreateDirectory(_tempDir);
}
public void Dispose()
{
try { Directory.Delete(_tempDir, true); } catch { }
}
[Fact]
public void GetValue_ReturnsDefault_WhenKeyMissing()
{
var settings = CreateSettings();
var result = settings.GetValue("nonexistent", "fallback");
Assert.Equal("fallback", result);
}
[Theory]
[InlineData("key1", "value1")]
[InlineData("key2", "value2")]
public void SetValue_StoresAndRetrieves(string key, string value)
{
var settings = CreateSettings();
settings.SetValue(key, value);
Assert.Equal(value, settings.GetValue(key));
}
}
Run tests:
dotnet test
dotnet test --filter "FullyQualifiedName~SettingsTests"
Networking
Exponential Backoff Reconnection
private static readonly int[] BackoffMs = { 1000, 2000, 4000, 8000, 15000, 30000, 60000 };
private int _reconnectAttempts;
private async Task ReconnectWithBackoffAsync(CancellationToken ct)
{
var index = Math.Min(_reconnectAttempts, BackoffMs.Length - 1);
var delay = BackoffMs[index];
Debug.WriteLine($"Reconnecting in {delay}ms (attempt {_reconnectAttempts + 1})");
await Task.Delay(delay, ct);
await ConnectAsync(ct);
_reconnectAttempts++;
}
// Reset on successful connect
private void OnConnected()
{
_reconnectAttempts = 0;
}
Named HTTP Clients
Register once in DI, use everywhere:
// Registration
services.AddHttpClient("AppApi", client =>
{
client.DefaultRequestHeaders.Add("User-Agent", "MyApp/1.0");
client.Timeout = TimeSpan.FromSeconds(10);
});
// Usage in services
public class ApiService
{
private readonly IHttpClientFactory _httpClientFactory;
public ApiService(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
public async Task<string> GetDataAsync(CancellationToken ct = default)
{
using var client = _httpClientFactory.CreateClient("AppApi");
var response = await client.GetAsync("/api/data", ct);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync(ct);
}
}
Deep Links & IPC
URI Scheme Registration
public static void RegisterUriScheme(string scheme, string friendlyName)
{
var exePath = Environment.ProcessPath ?? "";
using var key = Registry.CurrentUser.CreateSubKey(
$@"Software\Classes\{scheme}");
key.SetValue("", $"URL:{friendlyName}");
key.SetValue("URL Protocol", "");
using var iconKey = key.CreateSubKey("DefaultIcon");
iconKey?.SetValue("", $"\"{exePath}\",1");
using var commandKey = key.CreateSubKey(@"shell\open\command");
commandKey?.SetValue("", $"\"{exePath}\" \"%1\"");
}
Named Pipe IPC (Single Instance)
private const string PipeName = "MyApp-IPC";
// Sender (second instance)
private static void SendToRunningInstance(string message)
{
using var client = new NamedPipeClientStream(".", PipeName, PipeDirection.Out);
client.Connect(1000);
using var writer = new StreamWriter(client);
writer.WriteLine(message);
writer.Flush();
}
// Receiver (primary instance)
private void StartIpcServer()
{
_ = Task.Run(async () =>
{
while (!_disposed)
{
try
{
using var server = new NamedPipeServerStream(PipeName, PipeDirection.In);
await server.WaitForConnectionAsync();
using var reader = new StreamReader(server);
var message = await reader.ReadLineAsync();
if (!string.IsNullOrEmpty(message))
{
Application.Current.Dispatcher.InvokeAsync(
() => HandleIpcMessage(message));
}
}
catch
{
await Task.Delay(1000);
}
}
});
}
Build & Packaging
Build Commands
# Debug build
dotnet build
# Release build
dotnet build -c Release
# Self-contained single-file EXE
dotnet publish -c Release -r win-x64 --self-contained -p:PublishSingleFile=true
# ARM64 build
dotnet publish -c Release -r win-arm64 --self-contained -p:PublishSingleFile=true
MSIX Packaging
Add conditional MSIX support in csproj:
<!-- Unpackaged (traditional EXE) — default -->
<PropertyGroup Condition="'$(PackageMsix)' != 'true'">
<WindowsPackageType>None</WindowsPackageType>
</PropertyGroup>
<!-- MSIX packaged (store/sideload) -->
<PropertyGroup Condition="'$(PackageMsix)' == 'true'">
<WindowsPackageType>MSIX</WindowsPackageType>
<AppxPackageSigningEnabled>false</AppxPackageSigningEnabled>
<GenerateAppxPackageOnBuild>true</GenerateAppxPackageOnBuild>
</PropertyGroup>
Build MSIX:
dotnet publish -c Release -p:PackageMsix=true
CI/CD with GitHub Actions
name: Build and Test
on:
push:
branches: [main]
tags: ['v*']
pull_request:
jobs:
test:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with: { dotnet-version: 10.0.x }
- run: dotnet restore
- run: dotnet build -c Debug
- run: dotnet test --no-build -c Debug
build:
needs: test
runs-on: windows-latest
strategy:
matrix:
rid: [win-x64, win-arm64]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with: { dotnet-version: 10.0.x }
- run: >
dotnet publish src/MyApp/MyApp.csproj
-c Release -r ${{ matrix.rid }}
--self-contained
-p:PublishSingleFile=true
-o publish
- uses: actions/upload-artifact@v4
with:
name: myapp-${{ matrix.rid }}
path: publish/
Theme Detection
public static class ThemeHelper
{
public static bool IsDarkMode()
{
try
{
using var key = Registry.CurrentUser.OpenSubKey(
@"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize");
var value = key?.GetValue("AppsUseLightTheme");
return value is int i && i == 0;
}
catch { return false; }
}
}
XAML Resources
App-Level Resource Dictionary
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Themes/Colors.xaml" />
<ResourceDictionary Source="Themes/Styles.xaml" />
</ResourceDictionary.MergedDictionaries>
<BooleanToVisibilityConverter x:Key="BoolToVisibilityConverter" />
</ResourceDictionary>
</Application.Resources>
Custom Styles
<!-- Themes/Styles.xaml -->
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Style x:Key="PrimaryButton" TargetType="Button">
<Setter Property="Background" Value="{StaticResource PrimaryBrush}" />
<Setter Property="Foreground" Value="White" />
<Setter Property="Padding" Value="16,8" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Cursor" Value="Hand" />
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Opacity" Value="0.9" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.5" />
</Trigger>
</Style.Triggers>
</Style>
</ResourceDictionary>
What NOT to Do
- Don't use
Dispatcher.Invoke(synchronous) — useDispatcher.InvokeAsync - Don't call
.Resultor.Wait()on Tasks — deadlocks the UI thread - Don't use
App.ServiceProviderdirectly — inject through constructors - Don't fire-and-forget with
_ = Task.Run(...)— useAsyncHelper.FireAndForget - Don't leave empty
catch { }blocks — useSafeExec.Try()or log the error - Don't use
System.Timers.Timerfor UI work — useDispatcherTimer - Don't create GDI objects without disposing them — always call
DestroyIcon - Don't add NuGet packages without confirming with the user
- Don't modify XAML styles without visual verification
- Don't refactor more than what was asked — scope creep is the enemy