This document is ReactiveUI's hot-path engineering rule book: the
allocation, async, type-design, and API-shape standards that production
code under src/ReactiveUI/ (and the platform extension libraries) is
held to. It is the performance-focused companion to
agent.md and the website contribution guide linked from
CONTRIBUTING.md.
When guidance overlaps:
agent.mdwins for build/test commands, repository layout, the SLNX/MTP/TUnit toolchain, and the AOT guidance (do not duplicate the AOT section here — seeagent.md§ AOT Guidance).- This document wins for hot-path detail: allocation discipline, the System.Reactive boundary, async rules, type design, and the perf-driven API-shape rules.
- The website guide (
reactiveui.net/contribute) remains the narrative onboarding doc.
We are tightening performance standards going forward. New code is expected to meet every rule below. Existing code is migrated opportunistically — when you touch a file, bring the lines you change up to standard.
ReactiveUI is a widely consumed framework with a large, long-lived public surface. The rules below apply at two different strictness levels:
- Internal / private code (strict). Anything not part of the
published API —
private/internalmembers, sink and operator implementations, helpers, the expression-rewriting machinery. Apply every rule in full. Internal contracts are not sacred: if a perf-driven change breaks an internal contract, change the contract. Internal[InternalsVisibleTo]consumers (tests, sibling platform assemblies) move with it. - Public / user-facing API (careful). Anything a consumer can
compile against — public types, public/protected members, public
return and parameter types, default parameter values already shipped.
New public API follows the rules. Existing public signatures change
only through proper deprecation and versioning (
[Obsolete]→ removal across a major version), never silently. When a rule below would break a shipped public signature, treat the rule as a guide for new surface and a migration target for old surface — not a license to break consumers.
When a rule has a different public-vs-internal answer, it says so explicitly.
ReactiveUI is built on the Rx contract and that does not change. What is
changing is who implements the operators: we are moving toward our
own low-allocation operator and observer sinks rather than routing hot
paths through System.Reactive.Linq.Observable.
- Keep the BCL/Rx contract types — never reinvent them.
IObservable<T>,IObserver<T>,ISchedulerandUnitare the ecosystem interop contract every consumer relies on. Depend on them directly and pass them through. Authoring parallel substitutes would fragment the contract for no gain. This is the deliberate exception to the "build our own" rule. (IObservable<T>/IObserver<T>live in the BCLSystemnamespace, notSystem.Reactive, so they stay regardless;ISchedulerandUnitare the onlySystem.Reactivetypes kept.) - Disposables are our own internal implementations — not
System.Reactive.Disposables. We do not depend onSystem.Reactive.Disposables. Use the owned internal primitives, named to avoid colliding with theSystem.Reactivetypes a consumer might also have in scope:DisposableBag— composite of child disposables (replacesCompositeDisposable).MutableDisposable— single reassignable slot that does not dispose the previous value on swap (replacesMultipleAssignmentDisposable).SwapDisposable— single reassignable slot that disposes the previous value on swap (replacesSerialDisposable).OnceDisposable— write-once slot (replacesSingleAssignmentDisposable).EmptyDisposable— shared no-op singleton (replacesDisposable.Empty).ActionDisposable— runs an action on dispose (replacesDisposable.Create(Action)). These are tailored, low-allocation, and only as thread-aware as the call site needs — not 1:1 rebadges of theSystem.Reactiveshapes.
CompositeDisposablesurvives only at the public API edge. Where ReactiveUI's shipped public surface exposesCompositeDisposable(parameter or return type a consumer compiles against), keep it — that signature is the contract and changing it breaks consumers. Internally, and in all new non-public code, useDisposableBag. Never introduceSystem.Reactive.Disposablestypes into internal code paths.- Prefer our own operator/observer sinks on hot paths. Where a
binding,
WhenAny*, or command pipeline emits per-value, prefer a purpose-built sink with the allocation profile we want over chainingSystem.Reactive.Linqoperators (Select,Where,CombineLatest,Merge,Throttle, …). A hand-written sink can besealed, avoid the closure-per-operator overhead, and fuse adjacent stages. - Audit new dependencies on
System.Reactive.Linqbefore adding them. If a hot path "needs" anObservable.Foo, the preferred answer is usually our own sink with the right allocation profile. Reach intoSystem.Reactive.Linqfreely in cold paths (one-time setup, configuration, sample/doc code) and at the public API edge where we deliberately return composableIObservable<T>. IScheduleris the scheduling abstraction — pass it through. PreferRxSchedulers(AOT-friendly) overRxAppperagent.md; never bake a scheduler default into a hot path that should accept one.
The core of the tightened standard. These apply to production code; test projects relax the allocation rules (see Tests & benchmarks).
- Zero-LINQ in production code. No
System.Linqin production hot paths. LINQ pulls in lambdas, iterators, and boxed enumerators on every call. Use plain indexedforloops. (LINQ overIObservable<T>— the Rx operators — is governed by the System.Reactive boundary above, not by this rule; this rule is aboutSystem.LinqoverIEnumerable<T>.) foroverforeach. Indexedforover arrays /Span<T>/ReadOnlySpan<T>/List<T>/IReadOnlyList<T>.foreachonly when the type genuinely lacks an indexer (HashSet<T>,IAsyncEnumerable<T>, dictionary enumeration).staticlambdas everywhere there is no capture.static x => …/static (state, x) => …lets the JIT skip the closure allocation. This matters most in theWhenAnyValue/ binding selectors that allocate per subscription — pass captured state through a tuple/state argument rather than closing over locals.- Arrays over
List<T>when the final length is known up front; pre-size and write by index. WhenList<T>is unavoidable, always pass acapacity(new List<T>(expectedCount)) — never capacity-less. - Pre-size
Dictionary/HashSetwith a capacity hint reflecting the expected size. - Avoid
ImmutableArray<T>/ImmutableList<T>on hot paths. The wrapping struct adds an indirection per read and the builder churns intermediate arrays. Reach for an immutable collection only when the API is genuinely public and consumers must not mutate. Otherwise exposeIReadOnlyList<T>/T[]and treat it as immutable by convention. - Collection expressions
[..]first.[a, b, ..tail],[],[..source]for final materialization. Never.ToArray()when a collection expression does the job. - Pool transient buffers.
ArrayPool<T>.Shared.Rentpaired with atry/finallyReturnfor transient buffers in pipelines that allocate per emission. Interlocked.Increment/Interlocked.Decrementfor simple counters under contention. Reservelockfor genuine multi-field invariants.System.Threading.Lock(net9+) is the default monitor primitive for new code that needs a private gate around shared mutable state:private readonly Lock _gate = new();andlock (_gate). Onnet8.0/net462/net472/net481fall back toprivate readonly object _gate = new();; hide the multi-TFM split behind a helper where call-site readability matters.- No locks on arbitrary objects (
this,typeof(X), public fields). Always a dedicated_gate-style field.
ReactiveUI is legitimately string-shaped: property names drive
RaiseAndSetIfChanged / nameof, binding paths and expression chains
resolve to member names, and the public API exchanges string freely.
We are not adopting a no-string / UTF-8-bytes policy. string
stays a first-class type, public and internal.
What still applies:
- Don't allocate strings needlessly on hot paths. No string
interpolation or
string.Formatinside a per-emission path to build a value that is then discarded or only used on a failure branch — build the message lazily, on the throw path. nameof(...)over string literals for member references (already required byagent.md).StringComparer.Ordinal/StringComparison.Ordinalfor identifier, type-name, and property-name comparisons and for the dictionaries/sets keyed on them. Culture-aware comparison is both wrong here and several times slower.- Spans for cheap parsing/slicing. Prefer
ReadOnlySpan<char>+ range expressions (path[..i],name[^1]) overSubstringwhen picking apart a member path, rather than allocating temporaries.
Where ReactiveUI is async — ReactiveCommand.CreateFromTask,
interaction handlers, async bindings — the pipeline is async end to end.
- No sync-over-async. Never
.GetAwaiter().GetResult(),.Result, or.Wait()inside an async path. If the contract hands you aCancellationToken, youawait. ConfigureAwait(false)on every libraryawaitin production code. Tests don't need it.- Cancellation flows through. Async operators accept a
CancellationTokenwhere the contract supports it and pass it down — never swallow it, never default toCancellationToken.Nonewhen a real token is in scope. Create aCancellationTokenSource.CreateLinkedTokenSourceonce at subscribe time, not per emission. ValueTaskfirst when zero-alloc is proven;Taskotherwise. UseValueTaskwhen most implementations complete synchronously and the call site multiplies (per-emission paths). UseTaskwhen the path is genuinely async-dominant (I/O) or cold (one call per setup). Obey the consume-once rule forValueTask: neverawaitthe same instance twice, never store it in a field.- Sync impls return cached completed tasks —
=> ValueTask.CompletedTask/Task.CompletedTask; no state machine, no allocation.
- Invert
ifs to flatten the happy path. Guard clauses + earlyreturn/continuefirst; main logic stays unindented. Noelseon a guarded branch. - Switch expressions over
if/elsechains — property patterns ({ HasValue: true }), positional patterns, list patterns. Order of preference: switch expression → switch statement →if/else ifchain. Reach for the next form only when the prior cannot express the dispatch (mutatingref/out, side-effects, fall-through). - List patterns for emptiness / cardinality.
is [_, ..]over.Count > 0;is []for empty;is [var single]to bind a single-element collection. is/is notover==/!=for null and type checks; combine type test + property check in one line where it reads well.- Avoid
while (true). Express the termination condition in the loop header. The only exception is a genuinely unbounded pump exiting via a token, which should bewhile (!cancellationToken.IsCancellationRequested).
- No default parameter values on new APIs. Default values bake the
constant into every caller's IL — bumping it later needs a recompile of
every consumer. Provide explicit overloads instead, each delegating to
the most-specific overload that takes everything explicitly.
(Public two-tier note: ReactiveUI's shipped public API already uses
defaults in places —
WhenAnyValue,ToProperty, scheduler args. Leave those as-is; do not break consumers. Apply this rule to new public surface and to all internal surface.) - Concrete collection types in new production APIs where practical —
IReadOnlyList<T>/T[]/Dictionary<K,V>/HashSet<T>overIEnumerable<T>for new parameters and return values.IEnumerable<T>is fine only when a streaming yield genuinely avoids materializing the sequence. (The Rx observer contract is already streaming one value at a time; this rule is aboutIEnumerable<T>-style collection params, notIObservable<T>.) Existing public signatures that return interface types stay. - Pin the latest non-beta version when adding to
src/Directory.Packages.props. Checkhttps://api.nuget.org/v3-flatcontainer/<lower-cased-id>/index.jsonfor the highest stable release; never-preview/-rc/-alpha/-beta. Same rule for bumps.
sealedevery class that isn't designed for inheritance — the default for new internal types. Helps inlining and removes accidental override surface. (Public base types intended for derivation —ReactiveObject, view base classes — stay open by design.)readonly record structfor immutable value-shaped data: small (≤ 4–5 fields) or holding only references. Free equality/hashing, no GC pressure.sealed record(class) when the record participates in an inheritance hierarchy or holds many fields.- Most methods static. A method that doesn't touch
thisshould bestatic— fewer hidden allocations, clearer call sites, free devirtualization. If a class ends up with only static methods, mark the classstatictoo. internal statichelpers for stateless cross-type utilities. Group by responsibility; keep the public surface narrow.- Singleton comparers (
private sealed class XComparer : IComparer<T>withpublic static readonly XComparer Instance) instead of allocating a fresh comparer/lambda perArray.Sort/ dictionary. - Bundle long parameter lists into a
readonly record structorref structrather than splitting the method. The state type documents the relationship between values and lets the JIT keep them in registers.
- C#
fieldkeyword by default. When a property needs backing logic (lazy init, validation, change-tracking), usefieldinside the accessors rather than a separate_namefield. Keep an explicit backing field only for:ref-passing APIs —Interlocked.Increment(ref _counter),Volatile.Read(ref _state),Unsafe.As<T>(ref _slot). Document with a one-line comment.- Constructor assignment that must bypass setter logic.
- Storage referenced from a method outside the property (rare).
RaiseAndSetIfChangedstays the canonical reactive setter. Thefield-keyword guidance is for non-reactive backing logic; reactive properties continue to usethis.RaiseAndSetIfChanged(ref field, value)with an explicit backing field (it is aref-passing API — the first exception above).
- Exception helpers compose their own messages. Prefer
ArgumentNullException.ThrowIfNull(x)and[CallerArgumentExpression]-based helpers over hand-writtenif (x is null) throw …; call sites pass only the value, nevernameof(x). (Matches theagent.mdzero-pragmaThrowIfNullexample.) SearchValues<T>for repeated multi-character searches. Cache asprivate static readonly SearchValues<char>and pass toIndexOfAny/IndexOfAnyExcept— faster thanIndexOfAny([...])anywhere hit more than once.TryFormat/TryParseoverToString/Parsewhen writing into a span buffer.FrozenDictionary<K,V>/FrozenSet<T>only when all four hold: built once → queried many times → read-only after construction → genuinely hot or broadly shared. The freeze pass is expensive; do not useFrozen*for per-instance, short-lived, or per-subscription tables — a plainDictionary/HashSetwith the right comparer wins there.
ReactiveUI runs StyleCop, Roslynator, the .NET CA analyzers, and now
SonarAnalyzer.CSharp and Blazor.Common.Analyzers. These catch real
perf and correctness issues.
- Fix the code, don't silence the rule. Almost every analyzer hit has a structural fix — pull out a helper, invert a guard, change a return type, restructure a throw. That is preferable to suppression.
- Zero-pragma policy (see
agent.md). No#pragma warning disablein production code. StyleCop (SA****) warnings must be fixed, never suppressed. [SuppressMessage]is a last resort and requires justification. Use a per-symbol[SuppressMessage("Category", "RuleId", Justification = "…")]naming a concrete reason. CA-rule suppression is allowed only when a fix would genuinely harm the design; a second hit on the same rule usually means the design is wrong — fix that instead.- Zero
<NoWarn>policy in production projects. Project-wide<NoWarn>in.csproj/.props/.targetsneeds explicit consultation and is unlikely to be approved.
We follow Conventional Commits 1.0.0
so git log is mechanically scannable and release-notes tooling can
group by intent.
<type>(<optional scope>): <subject>
<body>
<footers>
Types: feat, fix, perf, refactor, docs, test, build,
ci, chore, revert — standard meanings.
Scope is the affected subsystem, lowercase, no ReactiveUI. prefix.
Examples: binding, whenany, command, activation, routing,
interactions, builder, scheduler, plus platform scopes (wpf,
winui, maui, androidx, blazor, winforms). Omit the scope when a
change spans many areas evenly.
Subject: ~70 chars, imperative mood, lowercase initial, no trailing period.
Body: explains the why. For perf commits, include benchmark
numbers (before/after, scenario, allocation delta) so the win is
verifiable.
Footers: BREAKING CHANGE: <text> (or ! after the type, e.g.
feat(binding)!:) for any public-API change; reference issues
(Closes #123, Refs #456).
Example:
perf(whenany): cut per-subscription alloc by hoisting the selector to a static lambda
Replace the captured-closure selector in the two-property WhenAnyValue
overload with a static lambda that threads the source through a value
tuple. The closure object is gone from the subscription path.
Verified on WhenAnyValueBenchmarks: 92 ns -> 61 ns, alloc 64 B -> 0 B.
Refs #1234
- TUnit + Microsoft Testing Platform under
src/tests/(seeagent.mdfor commands). Treat tests as documentation — names and asserts communicate the contract. Prefer real implementations over mocks in integration tests. - Test allocation rules are relaxed.
foreach,System.Linq, and capacity-lessList<T>are fine in tests where readability beats micro-optimization. The style, pattern-matching, and suppression rules still apply. - BenchmarkDotNet under
Benchmarks/for hot-path work. Always include[MemoryDiagnoser]and track allocations alongside throughput. Add a benchmark when you add or change a hot-path feature; cite its numbers in theperfcommit body.