Scope:
Stellar/project only (core framework library) Goal: Reduce allocations, improve throughput, modernize patterns — minimal API surface changes Date: 2026-03-27
File: Stellar/Disposables/WeakCompositeDisposable.cs (lines 138–161, 230–256)
Both Clear() and Dispose() allocate a new List<IDisposable>() every call to capture disposables before releasing the lock. In hot paths (view navigation), this creates GC pressure.
- Use
Span<T>/stackallocfor small counts, rent fromArrayPool<IDisposable>for larger collections. A threshold of ~8 items can use stack allocation; above that, rent an array from the shared pool and return it after disposal. This eliminates theList<T>heap allocation entirely.
File: Stellar/Disposables/WeakCompositeDisposable.cs (line 14)
The _gate field is new object() — a heap allocation. The sibling classes ViewManager and ViewModelBase already use the .NET 9 Lock struct.
- Replace
private readonly object _gate = new object()withprivate readonly Lock _gate = new()across all three Weak disposable types*. TheLockstruct provides the same semantics with zero heap allocation and better JIT inlining.
Files: Stellar/Disposables/WeakSerialDisposable.cs (line 13), WeakSingleAssignmentDisposable.cs (line 13)
- Same fix as 1.2 — replace
object _gatewithLock _gatein both types.
File: Stellar/ViewManager.cs (lines 20–42)
Every property access (e.g., Initialized, Activated) rebuilds the full Where().SelectUnit().AsObservable() chain. While Rx defers execution, the intermediate objects are allocated each time the property getter runs. Subscribers typically access these once, but hot-path lifecycle code could hit them repeatedly.
- Cache each filtered observable in a
Lazy<IObservable<Unit>>field, initialized in the constructor (similar to how_lifecycleEventsis already lazy). ReturnObservable.Empty<Unit>()if disposed. This eliminates repeated intermediate operator allocations.
File: Stellar/ValidationResult.cs (line 15)
DefaultValidationResult is a static (non-readonly) field that holds a mutable List<ValidationInformation>. A consumer could inadvertently mutate the shared default. Additionally, using new List<>() for an always-empty collection wastes 56 bytes.
- Make it
static readonlyand useArray.Empty<ValidationInformation>()wrapped in a read-only collection. This ensures immutability and avoids the List allocation. Consider a pattern like:Pair this with changing thepublic static readonly ValidationResult DefaultValidationResult = new(Array.Empty<ValidationInformation>(), true);
ICollection<ValidationInformation>property toIReadOnlyCollection<ValidationInformation>(see section 3.2).
File: Stellar/ViewModel/ValidatingViewModelBase.cs (lines 112–119)
UpdateValidationState calls Clear() then Add() in a loop on an ObservableCollection. Each Add fires CollectionChanged, generating N events for N errors. Downstream subscribers (like MonitorValidationInformationFor) react to every single event.
- Batch the update: either suppress
CollectionChangedduring the loop and fire a singleResetevent, or replace the collection contents in one shot. A simple approach is to add a helper that temporarily suppresses notifications, or to use aBatchingObservableCollectionpattern.
File: Stellar/Extensions/IStellarViewExtensions.cs (line 28)
Attribute.GetCustomAttribute(stellarView.GetType(), typeof(ServiceRegistrationAttribute)) bypasses the AttributeCache that exists specifically for this purpose. This is called during every view initialization.
- Replace with
AttributeCache.GetAttribute<ServiceRegistrationAttribute>(stellarView.GetType())to use the cached lookup path, consistent withViewModelBase.
File: Stellar/Extensions/IServiceCollectionExtensions.cs (line 60)
interfaces.Any() enumerates the array to check if it's non-empty, then the foreach enumerates it again. GetInterfaces() returns an array, so .Length > 0 is O(1).
- Replace
if (interfaces.Any())withif (interfaces.Length > 0)(or just remove the check —foreachon an empty array is a no-op). Also remove the null coalescing?? Enumerable.Empty<Type>()sinceGetInterfaces()never returns null.
File: Stellar/Extensions/IObservableExtensions.cs (line 449)
Inside ObserveLatestOn, var gate = new object() is used for locking. This is in a hot scheduling path.
- Replace with
var gate = new Lock()for consistency and the performance benefit of the .NET 9 Lock struct.
File: Stellar/ViewManager.cs (lines 18, 97)
The _disposed field is a plain bool read without Volatile.Read in the observable property getters (lines 20–42) and in OnLifecycle/OnNavigating. Meanwhile, Dispose sets it outside the lock (line 97). This can lead to torn reads on ARM architectures.
- Use
Volatile.Write(ref _disposed, true)inDisposeandVolatile.Read(ref _disposed)in all reads (matching the pattern already used for_controlsBound). Alternatively, wrap in a consistent property likeViewModelBase.IsDisposed.
File: Stellar/ViewModel/ViewModelBase.cs (line 38)
IsDisposed => _isDisposed is a direct field read, yet _isDisposed is set in Dispose(bool) potentially from another thread. Initialized and BindingsRegistered correctly use Volatile.Read, but IsDisposed does not.
- Change to
public bool IsDisposed => Volatile.Read(ref _isDisposed);for consistency with the other volatile-read properties.
File: Stellar/ViewModel/ViewModelBase.cs (line 36)
Maintain is a simple auto-property read in Unregister() inside the lock but set from external code (e.g., InitializeInternal, IStellarViewExtensions) without synchronization. If Maintain is set from a different thread than Unregister, there's a data race.
- Consider making
Maintainuse a volatile backing field or read it only inside the existing lock. This is low-risk but worth being explicit about for correctness on weakly-ordered architectures.
File: Stellar/Disposables/WeakSingleAssignmentDisposable.cs (lines 170–176)
SetDisposable reads and writes _isAssigned non-atomically. While the outer _gate lock protects this today, the container class itself is not thread-safe if ever used outside the lock.
- Use
Interlocked.CompareExchangeon anintflag (matching theInterlocked.Exchangepattern used inWeakSerialDisposable.DisposableContainer.SwapDisposable). This makes the container inherently thread-safe and removes the reliance on external locking.
File: Stellar/ValidationResult.cs (lines 41–47)
The constructor ValidationInformation(string, string, object?) hard-codes IsError = false even though it receives an error message string. This appears to be a bug — a validation information with an error message should likely have IsError = true.
- Set
IsError = truein the 3-parameter constructor (and its 2-parameter forwarding constructor). Verify this against downstream consumer expectations before changing.
File: Stellar/ValidationResult.cs (line 7)
ICollection<ValidationInformation> allows mutation (Add/Remove/Clear) on a record type that should be immutable. This contradicts the record semantics and the DefaultValidationResult caching strategy.
- Change to
IReadOnlyCollection<ValidationInformation>(orIReadOnlyList<>). This is an API surface change, but a correctness-motivated one — records should expose immutable data. Callers constructing results would pass arrays or lists (both implementIReadOnlyCollection).
File: Stellar/Exceptions/RegisteredServiceNotFoundException.cs (line 3)
The using System.Runtime.Serialization is unused (no [Serializable] attribute, no serialization constructor).
- Remove the unused using directive.
File: Stellar/Extensions/StringExtensions.cs (lines 5–8)
string.Contains(string, StringComparison) has been a built-in BCL method since .NET Core 2.1. This extension method shadows the built-in and could cause confusion. Since the framework targets net9.0, it's purely redundant.
- Remove
StringExtensions.Contains— callers already get the BCL overload. If this method is used directly viaStringExtensions.Contains(...)anywhere, search and replace with the built-in call.
File: Stellar/ViewModel/ViewModelBase.cs (lines 100–119)
The class has GC.SuppressFinalize(this) in Dispose() but no finalizer. This is not harmful but is unnecessary overhead if no derived class adds a finalizer. More importantly, _isDisposed = true is set outside the if (disposing) block, which means a finalizer call (if one existed) would also set _isDisposed.
- Move
_isDisposed = trueinside theif (disposing)block since there are no unmanaged resources and no finalizer. This follows the modern simplified disposal pattern.
File: Stellar/Extensions/NotifyPropertyExtensions.cs (lines 8–94)
Each of the three methods (ObservePropertyChanged, ObservePropertyChanging, ObserveCollectionChanged) has near-identical code duplicated in an if (scheduler is not null) / else branch. The only difference is passing scheduler as the last argument.
- Refactor to use
nullfor the scheduler parameter —Observable.FromEventacceptsnullscheduler gracefully (uses synchronous notification). If not, extract a localAction<T>factory and call once. This removes ~50 lines of duplication.
File: Stellar/Extensions/IServiceCollectionExtensions.cs (lines 19–29, 39–49)
HasAttribute<ServiceRegistrationAttribute>(ti) is called in the Where filter, then GetAttribute<ServiceRegistrationAttribute>(ti) is called again in the loop body. While AttributeCache makes this O(1) after the first call, the pattern is wasteful — the first call already retrieves the attribute.
- Replace the Where + GetAttribute pattern with a Select + Where-not-null pattern, e.g.:
This performs a single cache lookup per type instead of two.
assembly.ExportedTypes .Select(t => (Type: t, Attr: AttributeCache.GetAttribute<ServiceRegistrationAttribute>(t))) .Where(x => x.Attr != null && x.Type.IsAssignableTo(registrationType) && !x.Type.IsAbstract)
File: Stellar/Extensions/IObservableExtensions.cs (lines 443–497)
Materialize() boxes every value into a Notification<T> object (heap allocation per item). For a method designed to handle backpressure efficiently, this is counterproductive.
- Replace with a custom observer that directly handles OnNext/OnError/OnCompleted without materialization. Store the latest value in a field with a
hasValueflag instead of aNotification<T>?. This eliminates one allocation per emitted item.
File: Stellar/Extensions/IObservableExtensions.cs (lines 499–540)
ThrottleFirst uses Publish + Take(1) + Concat + IgnoreElements + TakeUntil + Delay + Repeat + another TakeUntil. This creates a large operator graph with many intermediate subscriptions.
- Consider a custom
IObservable<T>implementation using a simple timer-based gate (similar to howThrottleis implemented internally). A singleIScheduler.Schedulecall with a boolean gate would be far more efficient and easier to reason about. The allocation overhead of the current chain is significant for high-frequency sources.
File: Stellar/Extensions/IObservableExtensions.cs (lines 126–271)
All SelectConcurrent and SelectSequential overloads wrap with Observable.Defer(() => Observable.Start(...)). The Defer is unnecessary when wrapping Observable.Start which already defers execution. The extra Defer adds an allocation per item.
- Remove the
Observable.Deferwrapper —Observable.Startalready creates a cold observable that defers until subscription.
File: Stellar/ViewModel/ValidatingViewModelBase.cs (line 176)
errors.FirstOrDefault(ni => ...) uses LINQ, which allocates an enumerator on ObservableCollection<T>. Validation collections are typically small, but this is called on every CollectionChanged event.
- Replace with a manual
forloop over the collection to avoid the enumerator allocation:for (int i = 0; i < errors.Count; i++) { if (errors[i].PropertyName.Equals(propertyName, StringComparison.Ordinal)) return errors[i]; } return defaultValue;
Files: Multiple (WeakCompositeDisposable, WeakSerialDisposable, WeakSingleAssignmentDisposable)
Manual if (x == null) throw new ArgumentNullException(nameof(x)) patterns can be replaced with the .NET 6+ ArgumentNullException.ThrowIfNull(x) one-liner.
- Replace all manual null-check-throw patterns with
ArgumentNullException.ThrowIfNull(). This is a minor modernization that improves readability and provides better stack traces (the method name is inlined by the JIT).
File: Stellar/ViewManager.cs (lines 100–106)
ThrowIfDisposed() manually checks _disposed and throws. .NET 7+ provides ObjectDisposedException.ThrowIf(condition, instance).
- Replace
ThrowIfDisposed()with inlineObjectDisposedException.ThrowIf(_disposed, this)calls. This removes the private helper method and is recognized by the JIT for better inlining.
Files: SelectionViewModel<T> and any other non-abstract, non-extended ViewModels
Classes that aren't designed for inheritance should be sealed to enable devirtualization optimizations by the JIT.
- Audit all concrete (non-abstract) classes and add
sealedwhere inheritance is not intended. The weak disposable types are alreadysealed(good).SelectionViewModel<T>is not.
File: Stellar/Extensions/AttributeCache.cs
ConcurrentDictionary is optimized for concurrent reads and writes. After app startup, the attribute cache is almost exclusively read. FrozenDictionary (or FrozenSet) from .NET 8+ is optimized for read-heavy scenarios with zero contention overhead.
- Consider a two-phase approach: use
ConcurrentDictionaryduring startup/registration, then "freeze" the cache into aFrozenDictionaryvia aFreeze()method called after DI configuration. This is a design consideration — only implement if profiling showsConcurrentDictionaryoverhead in read paths.
Files: DisposableCollection in WeakCompositeDisposable, DisposableContainer in WeakSerialDisposable / WeakSingleAssignmentDisposable
These nested private classes could use the file access modifier (C# 11+) if extracted to file scope, which gives slightly cleaner code. However, since they're already private, this is purely stylistic.
- Optional / Low Priority: Consider whether extracting to
file-scoped types improves readability. No performance impact.
| Priority | Item | Impact | Risk |
|---|---|---|---|
| 🔴 High | 1.7 — IStellarViewExtensions uncached reflection | Perf (every view init) | Very Low |
| 🔴 High | 2.1 — ViewManager _disposed volatile |
Correctness (ARM) | Low |
| 🔴 High | 2.2 — ViewModelBase _isDisposed volatile |
Correctness (ARM) | Low |
| 🔴 High | 3.1 — ValidationInformation IsError bug |
Correctness | Medium |
| 🟡 Medium | 1.1 — WeakCompositeDisposable List alloc | Alloc reduction | Low |
| 🟡 Medium | 1.2/1.3 — Lock struct across Weak* types | Alloc reduction | Very Low |
| 🟡 Medium | 1.4 — ViewManager cached observables | Alloc reduction | Low |
| 🟡 Medium | 1.5 — DefaultValidationResult immutability | Correctness + Alloc | Low |
| 🟡 Medium | 1.6 — Batched ValidationErrors update | Perf (N events → 1) | Medium |
| 🟡 Medium | 4.3 — Remove redundant Defer wrapping | Alloc (per Rx item) | Low |
| 🟡 Medium | 4.4 — Manual loop vs LINQ FirstOrDefault | Alloc reduction | Very Low |
| 🟡 Medium | 5.2 — ObjectDisposedException.ThrowIf | Modernization | Very Low |
| 🟡 Medium | 3.8 — Double attribute lookup | Perf (startup) | Very Low |
| 🟢 Low | 1.8 — interfaces.Any() optimization | Minor perf | Very Low |
| 🟢 Low | 1.9 — ObserveLatestOn Lock struct | Alloc reduction | Very Low |
| 🟢 Low | 3.2 — IReadOnlyCollection for ValidationResult | API correctness | Medium (API) |
| 🟢 Low | 3.3 — Unused using directive | Cleanup | None |
| 🟢 Low | 3.4 — Redundant StringExtensions.Contains | Cleanup | Low |
| 🟢 Low | 3.7 — NotifyProperty code duplication | Maintainability | Low |
| 🟢 Low | 4.1 — ObserveLatestOn de-materialize | Alloc (advanced) | Medium |
| 🟢 Low | 4.2 — ThrottleFirst custom impl | Alloc (advanced) | Medium |
| 🟢 Low | 5.1 — ArgumentNullException.ThrowIfNull | Modernization | Very Low |
| 🟢 Low | 5.3 — Collection expressions | Modernization | Very Low |
| 🟢 Low | 5.4 — Seal leaf classes | JIT optimization | Very Low |
| ⚪ Future | 5.5 — FrozenDictionary for AttributeCache | Read perf | Medium |
| ⚪ Future | 5.6 — File-scoped types | Style | None |
| ⚪ Future | 2.3 — Maintain volatile backing | Correctness edge | Low |
| ⚪ Future | 2.4 — Interlocked in DisposableContainer | Defensive coding | Very Low |
| ⚪ Future | 3.5 — ValueIsTrue/ValueIsFalse naming | API clarity | Low (API) |
| ⚪ Future | 3.6 — Simplified disposal pattern | Correctness | Very Low |