WinForms conventions
For any WinForms or NuGet API surface not pinned down here, resolve signatures with the context7 MCP rather than memory - never by grepping the NuGet cache or decompiled sources (the routing lesson from a sibling leaf: the MCP sat live and unused because the routing line lived only in a router skill this leaf never loads).
WinForms is an immediate-mode, control-tree desktop UI. The realistic work is maintenance and
modernization of line-of-business apps, not greenfield, so this skill floors new work at .NET 8 /
C# 12 while treating .NET Framework 4.8 as a supported-but-frozen maintenance surface - fully
serviced, but no new WinForms features land there. The conventions below are the same whichever
runtime you are on; the version-specific mechanics live in the references.
Control naming, event-handler naming, and designer-file conventions live in references/winforms-style.md. This SKILL.md owns the architecture (MVP passive view, DI-resolvable forms, disposal, high-DPI, virtual-mode grids); the C# naming baseline is the csharp skill. Above these general conventions, a project's own .editorconfig and its <docs-path>/PROJECT-CODE-STYLE.md win where they diverge.
Load the version reference for the concrete mechanics:
- .NET Framework 4.8 (the frozen world) -> references/net-framework-48.md
- .NET 8 / 9 / 10 (the strategic target) -> references/modern-net.md
Out of scope, by design: the async / nullable / mapping baseline -> csharp; deeper MVP, command,
observer, and memento orchestration -> csharp-design-patterns; test framework + UI-automation
mechanics -> dotnet-testing; the upgrade safety playbook (baseline, staged, rollback) ->
dotnet-migrate; SDK-style project shape and packaging -> dotnet-project-setup; general
managed-memory profiling -> dotnet-diagnostics; general perf and type design ->
dotnet-performance; a paired Windows-Service half -> the hosted-worker skill plus the Windows Service (SCM layer) skill, where installed.
Logic out of code-behind - the one rule everything rests on
Code-behind translates a UI event into a call on a presenter or ViewModel and does nothing else. No
business rules, no data access, no branching on domain state. This is the single highest-leverage
maintainability move in a WinForms codebase, because everything testable and everything reusable
lives on the far side of that line.
Two ways to draw the line; pick by the age of the code:
- MVP passive view - the workhorse for existing 4.8 and modern code because it needs no framework
support. The Form or UserControl implements a narrow
IView interface (properties for values,
events for intent) and holds no logic; the presenter reads and writes the view only through that
interface and owns every decision, so it unit-tests against a mocked view. In the supervising
controller variant the view is allowed to data-bind directly to the model for simple
synchronization and the presenter handles only complex logic - less code, weaker testability.
Default to passive view; reach for supervising controller only when a stateful model must reflect
into the view through binding anyway.
- The MVVM binding engine - a WPF-style
DataContext / Command engine, stable from .NET 8, so
it is a modern-runtime option only. It lets a ViewModel be shared with WPF/MAUI, but never reaches
WPF fidelity (no XAML, no DependencyProperty, weak converters). Its mechanics live in
references/modern-net.md. On 4.8, MVP is the only separation pattern available.
One presenter (or ViewModel) per view, injected through the constructor. The Form owns no dependency
it could receive instead.
Forms are DI-resolvable
A Form is a service like any other - resolved from the container, never newed with its
collaborators reached through a static or a field.
- Register forms and services and resolve the main form from the container. The container and startup
wiring differ by runtime (generic host on modern .NET, a hand-built
ServiceProvider on 4.8) - see
the references.
- For a transient child form that needs a runtime argument, inject a factory delegate
(
Func<OrderForm> or a typed factory), not the container itself. The parent stays ignorant of the
provider and the factory is trivially stubbed in a test.
- A Form's collaborators arrive through its constructor. If a form reaches for a singleton or a
service locator, the seam that would have made it testable is gone.
Async and the UI thread
The async baseline - return Task, never block, ConfigureAwait placement - is the csharp skill's
and applies unchanged. The WinForms-specific points:
async void only on event handlers, and wrap the body in try/catch - a fault in an
async void cannot be observed by any caller and becomes an unhandled exception. Everywhere else
return Task / Task<T>.
- Never block the UI thread with
.Result, .Wait(), or .GetAwaiter().GetResult(). The
awaited continuation needs the UI thread to resume, but you have blocked it - a deadlock against
the WindowsFormsSynchronizationContext, plus a frozen window. Async all the way, no exceptions.
- Do not
ConfigureAwait(false) in a UI event handler - after it the continuation runs on a
thread-pool thread and touching a control throws a cross-thread exception. Use it only in the
UI-agnostic library code you call into.
- Report progress with
IProgress<T> / Progress<T> - Progress<T> captures the creating
thread's SynchronizationContext and raises its callback there, so a worker reports from any
thread and the UI update lands safely. Pair it with a CancellationToken for cancellation.
- Marshal back with
Control.Invoke / BeginInvoke. From .NET 9 prefer the async
Control.InvokeAsync, which removes a class of deadlocks (see references/modern-net.md); it does
not exist on .NET 8 or 4.8 - there, Invoke/BeginInvoke stay the marshaling primitives.
BackgroundWorker is legacy - supported, but no longer the recommended model and it only
offloads CPU work, not I/O. New code uses await for I/O and an awaited Task.Run for CPU-bound
work, marshaling UI updates through IProgress<T> or InvokeAsync.
Data binding through a BindingSource
- Bind controls through a
BindingSource, not directly - it centralizes currency, position, and
change notification and lets you swap the underlying list without rebinding every control.
ordersSource.DataSource = new BindingList<Order>(orders); // IBindingList: grid sees adds/removes
ordersGrid.DataSource = ordersSource;
nameTextBox.DataBindings.Add(
"Text", ordersSource, nameof(Order.CustomerName),
formattingEnabled: true, DataSourceUpdateMode.OnPropertyChanged);
- Two-way binding requires the bound type to implement
INotifyPropertyChanged (raise it in
setters); collections must be BindingList<T> (or another IBindingList) so the grid sees
inserts and deletes. Convert display-to-storage with a Binding's Format / Parse events.
- The silent binding leak: binding to a plain CLR property that does not implement
INotifyPropertyChanged makes the framework subscribe through a PropertyDescriptor, a strong
reference that pins the source object for the app's lifetime. Implementing INotifyPropertyChanged
on bound types removes the leak (and is correct anyway). Unhook bindings and dispose the
BindingSource when a dynamically created form tears down.
- Validate with an
ErrorProvider driven off the Validating event, or implement
INotifyDataErrorInfo on the model so errors surface through binding. Do not trust UI-enforced
constraints as the only validation - the boundary rule is the dotnet-security skill's.
Secrets and the desktop trust boundary
A desktop app cannot keep a secret from the user running it - the process can recover any credential
it is able to use, whatever the runtime - so prefer removing high-value credentials from the client
entirely (broker them through a service or token endpoint) over any local-storage scheme. For the
fields that genuinely must live on the box, Windows DPAPI (ProtectedData, CurrentUser scope, never
LocalMachine) is the local-protection floor; the per-runtime ProtectedData mechanics (in-box on
4.8, a package on modern .NET) are in the references. The input-validation boundary and general secret
handling are the dotnet-security skill's.
Disposal is the failure surface - manage it deliberately
Undisposed resources are the dominant WinForms defect. Two leak families, both worth real care.
Event-handler leaks (the top managed leak)
A publisher.Event += handler is a strong reference from publisher to subscriber. It leaks only when
the publisher outlives the subscriber - a long-lived service or main form raising events into
short-lived child forms or controls that should have been collected.
- You do not need to detach a child control's handler from its parent - their lifetimes are tied
and they die together.
- You do need to unsubscribe when a shorter-lived object subscribed to a longer-lived one -
detach in
OnClosed / Dispose.
- Weak-event patterns and messenger/event-aggregator abstractions are a safety net, not a substitute
for correct lifetime management - a still-subscribed handler can run on a logically dead object.
GDI / USER object leaks (the top native leak)
System.Drawing types - Pen, Brush, Font, Graphics, Bitmap, Icon, Region - each wrap a
native handle and are IDisposable. A process has a bounded GDI-handle quota (the widely cited
default is roughly 10,000; the real ceiling is a configurable session quota), and exhausting it
throws or renders windows with missing content.
- Wrap every created drawing object in
using, especially inside OnPaint / owner-draw where they
are created per paint.
- Do not dispose
SystemPens / SystemBrushes - they are cached. Do dispose SystemFonts -
each access is a live OS fetch.
- Never dispose
PaintEventArgs.Graphics - you do not own it. Do dispose a Graphics you got
from CreateGraphics(), Graphics.FromImage, or Graphics.FromHwnd.
- In a
DataGridView, share one DataGridViewCellStyle across rows and columns; never allocate a new
Font or Brush per cell in CellFormatting / CellPainting without disposing it - a classic
font leak.
Control and component disposal
- A disposed control disposes its children, but automatic disposal only reaches the top-level form
started by
Application.Run(new Form()). Everything below inherits from that or must be handled.
- A modal dialog shown with
ShowDialog() is not auto-disposed (so you can read its state after
close) - wrap it in using.
- Dynamically added and removed controls dispose manually - dispose the topmost one (disposing a
swapped-out
Panel disposes its children).
- A non-visual
IComponent dropped in the designer (a Timer, ToolTip, ImageList,
ErrorProvider) auto-registers with the IContainer components field and is auto-disposed; the
same component created in code must be disposed by hand - an undisposed Timer keeps firing and
holding handles.
- A custom control that owns
IDisposable fields overrides Dispose(bool disposing), disposes them
inside if (disposing), and always calls base.Dispose(disposing) (analyzers CA1063 / CA2215).
Watch live GDI and USER handle counts (Task Manager's Details tab has both columns) across an
open/close stress test - flat counts are the acceptance bar before shipping or migrating. Managed
allocation profiling is the dotnet-diagnostics skill's.
Performance: batch, virtualize, bind
- Wrap bulk mutations in
SuspendLayout() / ResumeLayout(), and use BeginUpdate() / EndUpdate()
on ListView / ListBox / TreeView / ComboBox to suppress intermediate repaints.
- Enable double buffering to cut flicker - note it is a protected property on
DataGridView, so turn
it on through a subclass rather than assuming the public toggle exists.
- Populate a grid through
DataSource, not row-by-row Rows.Add - unbound population is
dramatically slower for large sets.
- For large datasets set
VirtualMode = true on DataGridView / ListView and serve cells on
demand, so only visible rows materialize. General perf and type-design guidance is the
dotnet-performance skill's.
High-DPI: PerMonitorV2 is the target
Target Per-Monitor V2 DPI awareness - it enables dynamic DPI-change handling and automatic
non-client scaling. How you declare it differs by runtime (app.config plus a manifest on 4.8, a
build property on modern .NET) - see the references.
- Every container must use the same
AutoScaleMode; mixing modes is unsupported. The default
AutoScaleMode.Font scales by the system font, which is why the default-font change across
runtimes ripples into designer layout (covered in references/modern-net.md).
- Test on a genuinely mixed-DPI multi-monitor setup - a window opened on a secondary monitor can
briefly scale at the primary monitor's DPI.
Testing: unit the presenters, automate the critical path
- The whole point of the architecture is that presenters, ViewModels, and services carry the logic
and have no WinForms dependency, so they unit-test with a mocked
IView and injected fakes - fast,
deterministic, no UI thread. This is the return on keeping code-behind thin.
- UI end-to-end automation rides Windows UI Automation; FlaUI is the modern choice and keeps to
smoke and critical-path coverage only. Do not adopt WinAppDriver fresh (see
references/net-framework-48.md for why). Test framework and structure are the
dotnet-testing
skill's.
Designer and resource hygiene
- Never hand-edit a
*.Designer.cs file in a way the designer will fight - one control per meaningful
change, and expect DPI / default-font re-serialization churn on modern .NET (mitigation in
references/modern-net.md).
- Every user-facing string comes from a
resx file with satellite assemblies (Localizable = true
plus the form Language property to generate per-culture resx). No hard-coded UI sentences; build
them with composite format strings, never concatenation.
Forbidden in a presenter or ViewModel
- Any reference to a
Form, UserControl, Control, or other view type - the moment one appears,
the line has been crossed and the logic is no longer testable without a UI host.
MessageBox.Show - go through an injected dialog abstraction.
1---2name: dotnet-winforms3description: WinForms conventions for maintenance and modernization - logic out of code-behind (MVP passive view for legacy, the .NET 8 MVVM binding engine for new), DI-resolvable forms, async/await with no UI-thread blocking, BindingSource + INotifyPropertyChanged binding, control/component/GDI disposal, PerMonitorV2 high-DPI, virtual-mode grids, presenter unit tests. Floors new work at .NET 8 / C# 12 and covers 4.8 as the supported-but-frozen maintenance surface. Load before editing any Form, UserControl, code-behind, presenter, or .Designer.cs. Do NOT load for WPF (-> dotnet-wpf), WinUI 3, MAUI, Avalonia, or Uno; async baseline -> csharp, MVP/command orchestration -> csharp-design-patterns, tests -> dotnet-testing, upgrade playbook -> dotnet-migrate, a paired Windows-Service worker -> dotnet-hosted-services + dotnet-windows-service.4---56# WinForms conventions78For any WinForms or NuGet API surface not pinned down here, resolve signatures with the `context7` MCP rather than memory - never by grepping the NuGet cache or decompiled sources (the routing lesson from a sibling leaf: the MCP sat live and unused because the routing line lived only in a router skill this leaf never loads).910WinForms is an immediate-mode, control-tree desktop UI. The realistic work is maintenance and11modernization of line-of-business apps, not greenfield, so this skill floors **new** work at .NET 8 /12C# 12 while treating **.NET Framework 4.8 as a supported-but-frozen maintenance surface** - fully13serviced, but no new WinForms features land there. The conventions below are the same whichever14runtime you are on; the version-specific mechanics live in the references.1516**Control naming, event-handler naming, and designer-file conventions live in `references/winforms-style.md`.** This SKILL.md owns the architecture (MVP passive view, DI-resolvable forms, disposal, high-DPI, virtual-mode grids); the C# naming baseline is the `csharp` skill. Above these general conventions, a project's own `.editorconfig` and its `<docs-path>/PROJECT-CODE-STYLE.md` win where they diverge.1718**Load the version reference for the concrete mechanics:**1920- .NET Framework 4.8 (the frozen world) -> **references/net-framework-48.md**21- .NET 8 / 9 / 10 (the strategic target) -> **references/modern-net.md**2223Out of scope, by design: the async / nullable / mapping baseline -> `csharp`; deeper MVP, command,24observer, and memento orchestration -> `csharp-design-patterns`; test framework + UI-automation25mechanics -> `dotnet-testing`; the upgrade safety playbook (baseline, staged, rollback) ->26`dotnet-migrate`; SDK-style project shape and packaging -> `dotnet-project-setup`; general27managed-memory profiling -> `dotnet-diagnostics`; general perf and type design ->28`dotnet-performance`; a paired Windows-Service half -> the hosted-worker skill plus the Windows Service (SCM layer) skill, where installed.2930## Logic out of code-behind - the one rule everything rests on3132Code-behind translates a UI event into a call on a presenter or ViewModel and does nothing else. No33business rules, no data access, no branching on domain state. This is the single highest-leverage34maintainability move in a WinForms codebase, because everything testable and everything reusable35lives on the far side of that line.3637Two ways to draw the line; pick by the age of the code:3839- **MVP passive view** - the workhorse for existing 4.8 and modern code because it needs no framework40 support. The Form or UserControl implements a narrow `IView` interface (properties for values,41 events for intent) and holds no logic; the presenter reads and writes the view only through that42 interface and owns every decision, so it unit-tests against a mocked view. In the *supervising43 controller* variant the view is allowed to data-bind directly to the model for simple44 synchronization and the presenter handles only complex logic - less code, weaker testability.45 Default to passive view; reach for supervising controller only when a stateful model must reflect46 into the view through binding anyway.47- **The MVVM binding engine** - a WPF-style `DataContext` / `Command` engine, stable from .NET 8, so48 it is a modern-runtime option only. It lets a ViewModel be shared with WPF/MAUI, but never reaches49 WPF fidelity (no XAML, no `DependencyProperty`, weak converters). Its mechanics live in50 **references/modern-net.md**. On 4.8, MVP is the only separation pattern available.5152One presenter (or ViewModel) per view, injected through the constructor. The Form owns no dependency53it could receive instead.5455## Forms are DI-resolvable5657A Form is a service like any other - resolved from the container, never `new`ed with its58collaborators reached through a static or a field.5960- Register forms and services and resolve the main form from the container. The container and startup61 wiring differ by runtime (generic host on modern .NET, a hand-built `ServiceProvider` on 4.8) - see62 the references.63- For a transient child form that needs a runtime argument, inject a **factory delegate**64 (`Func<OrderForm>` or a typed factory), not the container itself. The parent stays ignorant of the65 provider and the factory is trivially stubbed in a test.66- A Form's collaborators arrive through its constructor. If a form reaches for a singleton or a67 service locator, the seam that would have made it testable is gone.6869## Async and the UI thread7071The async baseline - return `Task`, never block, `ConfigureAwait` placement - is the `csharp` skill's72and applies unchanged. The WinForms-specific points:7374- **`async void` only on event handlers**, and wrap the body in try/catch - a fault in an75 `async void` cannot be observed by any caller and becomes an unhandled exception. Everywhere else76 return `Task` / `Task<T>`.77- **Never block the UI thread** with `.Result`, `.Wait()`, or `.GetAwaiter().GetResult()`. The78 awaited continuation needs the UI thread to resume, but you have blocked it - a deadlock against79 the `WindowsFormsSynchronizationContext`, plus a frozen window. Async all the way, no exceptions.80- **Do not `ConfigureAwait(false)` in a UI event handler** - after it the continuation runs on a81 thread-pool thread and touching a control throws a cross-thread exception. Use it only in the82 UI-agnostic library code you call into.83- **Report progress with `IProgress<T>` / `Progress<T>`** - `Progress<T>` captures the creating84 thread's `SynchronizationContext` and raises its callback there, so a worker reports from any85 thread and the UI update lands safely. Pair it with a `CancellationToken` for cancellation.86- **Marshal back with `Control.Invoke` / `BeginInvoke`.** From .NET 9 prefer the async87 `Control.InvokeAsync`, which removes a class of deadlocks (see **references/modern-net.md**); it does88 not exist on .NET 8 or 4.8 - there, `Invoke`/`BeginInvoke` stay the marshaling primitives.89- **`BackgroundWorker` is legacy** - supported, but no longer the recommended model and it only90 offloads CPU work, not I/O. New code uses `await` for I/O and an awaited `Task.Run` for CPU-bound91 work, marshaling UI updates through `IProgress<T>` or `InvokeAsync`.9293## Data binding through a BindingSource9495- Bind controls through a **`BindingSource`**, not directly - it centralizes currency, position, and96 change notification and lets you swap the underlying list without rebinding every control.9798```csharp99ordersSource.DataSource = new BindingList<Order>(orders); // IBindingList: grid sees adds/removes100ordersGrid.DataSource = ordersSource;101nameTextBox.DataBindings.Add(102 "Text", ordersSource, nameof(Order.CustomerName),103 formattingEnabled: true, DataSourceUpdateMode.OnPropertyChanged);104```105- Two-way binding requires the bound type to implement **`INotifyPropertyChanged`** (raise it in106 setters); collections must be **`BindingList<T>`** (or another `IBindingList`) so the grid sees107 inserts and deletes. Convert display-to-storage with a `Binding`'s `Format` / `Parse` events.108- **The silent binding leak:** binding to a plain CLR property that does *not* implement109 `INotifyPropertyChanged` makes the framework subscribe through a `PropertyDescriptor`, a strong110 reference that pins the source object for the app's lifetime. Implementing `INotifyPropertyChanged`111 on bound types removes the leak (and is correct anyway). Unhook bindings and dispose the112 `BindingSource` when a dynamically created form tears down.113- Validate with an `ErrorProvider` driven off the `Validating` event, or implement114 `INotifyDataErrorInfo` on the model so errors surface through binding. Do not trust UI-enforced115 constraints as the only validation - the boundary rule is the `dotnet-security` skill's.116117## Secrets and the desktop trust boundary118119A desktop app cannot keep a secret from the user running it - the process can recover any credential120it is able to use, whatever the runtime - so prefer removing high-value credentials from the client121entirely (broker them through a service or token endpoint) over any local-storage scheme. For the122fields that genuinely must live on the box, Windows DPAPI (`ProtectedData`, `CurrentUser` scope, never123`LocalMachine`) is the local-protection floor; the per-runtime `ProtectedData` mechanics (in-box on1244.8, a package on modern .NET) are in the references. The input-validation boundary and general secret125handling are the `dotnet-security` skill's.126127## Disposal is the failure surface - manage it deliberately128129Undisposed resources are the dominant WinForms defect. Two leak families, both worth real care.130131### Event-handler leaks (the top managed leak)132133A `publisher.Event += handler` is a strong reference from publisher to subscriber. It leaks only when134the **publisher outlives the subscriber** - a long-lived service or main form raising events into135short-lived child forms or controls that should have been collected.136137- You do **not** need to detach a child control's handler from its parent - their lifetimes are tied138 and they die together.139- You **do** need to unsubscribe when a shorter-lived object subscribed to a longer-lived one -140 detach in `OnClosed` / `Dispose`.141- Weak-event patterns and messenger/event-aggregator abstractions are a safety net, not a substitute142 for correct lifetime management - a still-subscribed handler can run on a logically dead object.143144### GDI / USER object leaks (the top native leak)145146`System.Drawing` types - `Pen`, `Brush`, `Font`, `Graphics`, `Bitmap`, `Icon`, `Region` - each wrap a147native handle and are `IDisposable`. A process has a bounded GDI-handle quota (the widely cited148default is roughly 10,000; the real ceiling is a configurable session quota), and exhausting it149throws or renders windows with missing content.150151- Wrap every created drawing object in `using`, especially inside `OnPaint` / owner-draw where they152 are created per paint.153- **Do not dispose `SystemPens` / `SystemBrushes`** - they are cached. **Do dispose `SystemFonts`** -154 each access is a live OS fetch.155- **Never dispose `PaintEventArgs.Graphics`** - you do not own it. **Do dispose** a `Graphics` you got156 from `CreateGraphics()`, `Graphics.FromImage`, or `Graphics.FromHwnd`.157- In a `DataGridView`, share one `DataGridViewCellStyle` across rows and columns; never allocate a new158 `Font` or `Brush` per cell in `CellFormatting` / `CellPainting` without disposing it - a classic159 font leak.160161### Control and component disposal162163- A disposed control disposes its children, but automatic disposal only reaches the top-level form164 started by `Application.Run(new Form())`. Everything below inherits from that or must be handled.165- **A modal dialog shown with `ShowDialog()` is not auto-disposed** (so you can read its state after166 close) - wrap it in `using`.167- **Dynamically added and removed controls dispose manually** - dispose the topmost one (disposing a168 swapped-out `Panel` disposes its children).169- A non-visual `IComponent` dropped in the designer (a `Timer`, `ToolTip`, `ImageList`,170 `ErrorProvider`) auto-registers with the `IContainer components` field and is auto-disposed; **the171 same component created in code must be disposed by hand** - an undisposed `Timer` keeps firing and172 holding handles.173- A custom control that owns `IDisposable` fields overrides `Dispose(bool disposing)`, disposes them174 inside `if (disposing)`, and always calls `base.Dispose(disposing)` (analyzers `CA1063` / `CA2215`).175176Watch live GDI and USER handle counts (Task Manager's Details tab has both columns) across an177open/close stress test - flat counts are the acceptance bar before shipping or migrating. Managed178allocation profiling is the `dotnet-diagnostics` skill's.179180## Performance: batch, virtualize, bind181182- Wrap bulk mutations in `SuspendLayout()` / `ResumeLayout()`, and use `BeginUpdate()` / `EndUpdate()`183 on `ListView` / `ListBox` / `TreeView` / `ComboBox` to suppress intermediate repaints.184- Enable double buffering to cut flicker - note it is a protected property on `DataGridView`, so turn185 it on through a subclass rather than assuming the public toggle exists.186- **Populate a grid through `DataSource`, not row-by-row `Rows.Add`** - unbound population is187 dramatically slower for large sets.188- For large datasets set `VirtualMode = true` on `DataGridView` / `ListView` and serve cells on189 demand, so only visible rows materialize. General perf and type-design guidance is the190 `dotnet-performance` skill's.191192## High-DPI: PerMonitorV2 is the target193194Target **Per-Monitor V2** DPI awareness - it enables dynamic DPI-change handling and automatic195non-client scaling. *How* you declare it differs by runtime (app.config plus a manifest on 4.8, a196build property on modern .NET) - see the references.197198- Every container must use the **same `AutoScaleMode`**; mixing modes is unsupported. The default199 `AutoScaleMode.Font` scales by the system font, which is why the default-font change across200 runtimes ripples into designer layout (covered in **references/modern-net.md**).201- Test on a genuinely mixed-DPI multi-monitor setup - a window opened on a secondary monitor can202 briefly scale at the primary monitor's DPI.203204## Testing: unit the presenters, automate the critical path205206- The whole point of the architecture is that presenters, ViewModels, and services carry the logic207 and have no WinForms dependency, so they unit-test with a mocked `IView` and injected fakes - fast,208 deterministic, no UI thread. This is the return on keeping code-behind thin.209- UI end-to-end automation rides Windows UI Automation; **FlaUI** is the modern choice and keeps to210 smoke and critical-path coverage only. Do not adopt WinAppDriver fresh (see211 **references/net-framework-48.md** for why). Test framework and structure are the `dotnet-testing`212 skill's.213214## Designer and resource hygiene215216- Never hand-edit a `*.Designer.cs` file in a way the designer will fight - one control per meaningful217 change, and expect DPI / default-font re-serialization churn on modern .NET (mitigation in218 **references/modern-net.md**).219- Every user-facing string comes from a `resx` file with satellite assemblies (`Localizable = true`220 plus the form `Language` property to generate per-culture resx). No hard-coded UI sentences; build221 them with composite format strings, never concatenation.222223## Forbidden in a presenter or ViewModel224225- Any reference to a `Form`, `UserControl`, `Control`, or other view type - the moment one appears,226 the line has been crossed and the logic is no longer testable without a UI host.227- `MessageBox.Show` - go through an injected dialog abstraction.