Source-generated partial state and typed changes for C#.
SparseFragments generates typed APIs around ordinary C# models for partial values, edits, and before to after changes.
Add [SparseFragmentModel] to a partial model. The application keeps using the same model; the source generator adds the supporting types around it.
It helps when:
- a settings layer must omit a property instead of assigning its default value;
- an editor must preserve which properties the user actually changed;
- a client must send an edit without overwriting unrelated server changes;
- a UI needs notifications and must distinguish a touched field from a value that is still changed;
- a collection needs to track edits by item identity rather than by array position.
Try these cases live in the browser: SparseFragments Playground.
dotnet add package SparseFragmentsThe package contains the source generator, so no additional generation step is required.
With the .NET 10 SDK or later, the whole example fits in a single file. It layers an environment override over defaults, applies one user edit, and prints the effective values with the typed transition.
#:package SparseFragments@*
using SparseFragments;
var defaults = Settings.Fragment.From(new Settings
{
Label = "default",
Database = new() { Host = "db.local", Port = 5432 },
});
var environment = new Settings.Fragment
{
Database = new DatabaseSettings.Fragment { Port = 6432 },
};
var effective = defaults.Merge(environment);
var patch = new Settings.Patch { Label = "production" };
patch.Database.Port = 7432;
var updated = effective.Apply(patch);
var changes = Settings.ChangeSet.Between(effective, updated);
Console.WriteLine(updated.ToModel().Label); // production
Console.WriteLine(updated.ToModel().Database!.Host); // db.local, preserved by the patch
Console.WriteLine(changes.Label.IsChanged); // True
Console.WriteLine(changes.Database.Port.Before.Value); // 6432
Console.WriteLine(changes.Database.Port.After.Value); // 7432
[SparseFragmentModel]
public partial class Settings
{
public string? Label { get; set; }
public DatabaseSettings? Database { get; set; }
}
public partial class DatabaseSettings
{
public string Host { get; set; } = "localhost";
public int Port { get; set; } = 5432;
}Save it as quickstart.cs and run:
dotnet run --file quickstart.csA Fragment records which values are provided, so the Port-only override leaves Host to fall through from defaults. A Patch carries the requested operations, so the edit sets Label and the nested port while Host stays untouched. A ChangeSet records the before to after transition, so the output shows Label changed and Port moved from 6432 to 7432. When the same transition must survive concurrent edits, convert it with ToPayload and reconcile with RebaseOnto (see ChangeSet rebase).
The generated types answer different questions:
| Type | Question | Typical use |
|---|---|---|
Fragment |
Which values are provided? | Layers, overrides, partially supplied values |
Patch |
What should change? | Baseline-free commands and local edits |
ChangeSet |
What changed from before to after? | Baseline-aware diff, undo, compose, conflict-aware rebase |
ChangePayload |
How does the change travel? | Transport-only typed versioned JSON |
EditSession |
What is still unsaved? | Synchronous editing against a retained baseline |
The Quick Start uses the first three rows: defaults carries Label default with the full database value, environment carries only Port 6432, and effective merges to Label default, Host db.local, Port 6432. The patch sets Label to production and Port to 7432 while Host stays db.local. The change set reports Label default to production and Port 6432 to 7432, with Host unchanged. The transport row serializes that same change set; see the ChangePayload wire reference. The editing row tracks unsaved work in a running UI; see UI frameworks.
For task guidance, see Fragments and patches for construction, diffing, and merge rules, Merge strategies for layering, ChangeSet rebase for reconciling concurrent edits, Keyed collections for identity-based collection edits, and UI frameworks for edit sessions in Blazor, WPF, WinForms, .NET MAUI, WinUI, and Avalonia.
The sections below expand the Quick Start proof by scenario. Each one links to the guide that defines the behavior.
A settings layer often needs to distinguish "not specified" from "explicitly set to null". A normal nullable property cannot represent both meanings at once.
A generated Fragment keeps that distinction:
var user = new Settings.Fragment { Label = (string?)null };
var effective = defaults.Merge(user);This is useful for defaults, environment settings, tenant settings, user overrides, and other partially supplied values.
See Fragments and patches and Merge strategies for construction, diffing, and merge rules.
A full edited object does not say which values the user intended to change. Treating every property as an update can overwrite values the editor never touched.
A generated Patch contains only the requested operations:
var patch = new Settings.Patch();
patch.Database.Port = 6432;
var updated = current.Apply(patch);This is useful for local commands, partial-update APIs, and edits created in another process. EF Core already tracks edits made directly to tracked entities; SparseFragments is useful when the edit arrives from elsewhere.
See Fragments and patches for patch operations and application.
Undo, audit output, conflict detection, and synchronization need more than the final value. They need to know what changed.
A generated ChangeSet records that transition:
var changes = Settings.ChangeSet.Between(before, after);
changes.Database.Port.IsChanged;
changes.Database.Port.Before;
changes.Database.Port.After;See Fragments and patches for typed transitions, inversion, composition, and conversion back to a patch.
A ChangeSet crosses a process boundary through its generated payload, using the transport the application already owns:
var json = JsonSerializer.Serialize(changes.ToPayload());
var incoming = JsonSerializer.Deserialize<Settings.ChangePayload>(json)!.ToChangeSet();
var rebased = incoming.RebaseOnto(current);Rebasing preserves unrelated newer changes instead of replacing current state with a stale object. Conflicting edits are reported separately.
See ChangeSet rebase for the client and server flow and conflict handling, and the ChangePayload wire reference for the exact JSON contract.
Change notification and "there is still something to save" are different questions. A field can be touched and then restored to its original value.
An edit session (the per-model EditSession in SparseFragments.Generated, reached through CreateEditSession()) compares a retained baseline with the live model. It is synchronous and provides no transport or conflict framework:
var session = order.CreateEditSession();
session.Observable.Name = "Updated";
var submitted = session.CreateChangeSet();
var response = await SendChangesAsync(submitted.ToPayload());
if (response.IsSuccess)
{
// Advances the baseline only. The live model is untouched,
// so edits made after CreateChangeSet stay pending.
session.AcceptChanges(submitted);
}Bind controls to session.Observable and read display state from session.Current. The proxy edits the live model with notifications; the read-only view exposes the same state without setters. A raw session.Model reference edits the same instance without notifications and disables the session's observable-change cache, so prefer the proxy while the session tracks edits. Group one user action with BatchEdit, and undo unsaved edits with RevertChanges():
session.Observable.Name = "Updated";
string shown = session.Current.Name;
session.BatchEdit(() =>
{
session.Observable.Name = "Batched";
});
session.RevertChanges();
// session.HasChanges == falseThe recommended workflow disables editing in the UI while a save is in flight, then starts a fresh session from the returned server state (persisted.CreateEditSession()). This naturally picks up server-assigned keys, timestamps, and normalization.
For forms that keep editing enabled during submission, session.AcceptChanges(submitted) advances only the baseline so edits made after CreateChangeSet stay pending. This approach requires that the server makes no schema changes, key assignments, or normalization. When the destination object is already bound to the UI, prefer the conflict-checked ChangeSet.TryApplyInPlace: it rebases onto the bound model's current state, preserves unrelated concurrent edits, and reports conflicting or immutable-member edits as structured conflicts instead of overwriting silently.
var pending = baseline.CreateChangeSet(edited);
if (!pending.TryApplyInPlace(boundModel, out var conflicts))
{
ShowConflicts(conflicts);
return;
}
// boundModel now carries the change; unrelated concurrent edits are preserved.The explicit blind form changes.ToPatch().ApplyInPlace(model) skips the before-state check and can no longer rebase or report conflicts. See ChangeSet rebase for the safe and blind in-place options.
ChangeSet.EnumerateChanges() (flattened rows for logs and lists) is an advanced seam. Ordinary editing uses Observable, Current, and the typed transitions. See UI frameworks for sessions, EditContext handling, validation, and Observable wrappers for Blazor, WPF, WinForms, .NET MAUI, WinUI, and Avalonia integration.
Collection edits need stable identity. Array positions are not enough when items can be inserted, removed, or reordered.
With a [SparseKey] on the element model, changes are exposed by key:
var changes = Roster.ChangeSet.Between(before, after);
changes.Quests.Added;
changes.Quests.Removed;
changes.Quests.Edited;
changes.Quests.OrderChanged;See Keyed collections for key rules, per-item transitions, and the temporary-key save workflow. The Playground shows the behavior interactively.
Optional<T> represents the three states that a normal property cannot distinguish: missing, present null, and present value.
Optional<string?> missing = Optional<string?>.Missing;
Optional<string?> value = "hello";
Optional<string?> explicitNull = Optional<string?>.Present(null);Generated fragments use this distinction while exposing model-shaped members, so application code normally works through the generated types instead of maintaining presence flags by hand. A Patch Remove() drops one member contribution back to missing; on the next merge that member falls through to the lower layer. It never assigns the C# default or runs a constructor. This removal behavior is independent of the Quick Start values above: it describes what happens to any single member contribution when it becomes Missing.
[SparseFragmentModel] generates code shaped like the following schematic (names simplified; it does not compile as written). The state and
operation families stay nested in the model; per-model UI and editing types
live in a stable SparseFragments.Generated container:
partial class Settings
{
public Settings DeepClone();
public sealed class Fragment;
public sealed class Patch;
public sealed class ChangeSet;
public sealed class ChangePayload;
public sealed class FragmentBuilder;
}
// In namespace SparseFragments.Generated, one container per model:
// <Container>.Observable, <Container>.ReadOnlyView, <Container>.EditSession.Previously these UI and editing types were nested in the model
(Settings.EditSession, Settings.Observable, Settings.ReadOnlyView).
Update explicit references to the container paths, or use var with
CreateEditSession() and ToObservable() instead of naming the types.
There are no backwards-compatibility aliases. See
Relocated generated types
for the full old-to-new mapping.
Reachable eligible partial nested types receive the corresponding generated APIs as well. See Model shapes for the supported shapes and constructor rules.
The code is generated at compile time, uses no reflection for these generated operations, and works with NativeAOT.
Start with the first sparse edit to learn the basics by doing. Each how-to below solves one named task; the rest are API references to look things up.
| Task | Guide |
|---|---|
| Learn the basics end to end | First sparse edit |
| Layer overrides and remove them | Layered settings |
| Send commands or baseline-aware changes | Partial updates |
| Save a Blazor form without losing edits | Blazor edit form |
| Construct fragments, patches, and transitions | Fragments and patches |
| Layer defaults and overrides | Merge strategies |
| Observe and rebase before to after changes | ChangeSet rebase |
| Send a change across a process boundary | ChangePayload wire reference |
| Add, remove, edit, and reorder collection items | Keyed collections |
| Track edits in Blazor, WPF, MAUI, WinUI, or Avalonia | UI frameworks |
| Control copying and reference sharing | Clone & ownership |
| Check supported model shapes and constructors | Model shapes |
| Resolve generator errors | Diagnostics |
- Released as
netstandard2.0.- .NET Framework 4.6.1 or later
- .NET Core 2.0 or later, .NET 5 or later
- .NET MAUI and other platforms that support
netstandard2.0(see Select .NET Standard version).
- Generated code requires C# 9.0 or later.
- Set
<LangVersion>9.0</LangVersion>(or later) in the consuming project. - The
netstandard2.0and .NET Framework targets default to C# 7.3, so those consumers must opt in explicitly.
- Set
- Source generation runs in any build that uses Roslyn 4.3.1 or later (SDK and compiler requirement). The versions below add design-time IDE support:
- VisualStudio 2022: 17.3 or later
- JetBrains Rider: 2023.1 or later
- Unity: 6 or later
Requires net8.0 or later.
Licensed under the Apache-2.0 License.
