Skip to content

Blog

Introducing Shiny.Music — Cross-Platform Music Library Access for .NET MAUI

Here’s something that shouldn’t be hard but is: accessing the music library on a user’s device from .NET MAUI.

On Android, you need MediaStore.Audio.Media, a ContentResolver, cursor iteration, and different permission models depending on whether you’re targeting API 33+ or older. On iOS, you need MPMediaQuery, MPMediaItem, AVAudioPlayer, and an NSAppleMusicUsageDescription entry or the app crashes on launch. There’s no MAUI abstraction. No popular NuGet package. You write platform-specific code from scratch every time.

So I built Shiny.Music — a clean, DI-first API that gives you permission management, metadata queries, playback controls, and file export across both platforms.


Two interfaces, one registration call:

  • IMediaLibrary — request permissions, query all tracks, search by title/artist/album, copy track files to app storage
  • IMusicPlayer — play, pause, resume, stop, seek, with state tracking and completion events
MauiProgram.cs
builder.Services.AddShinyMusic();

That registers both interfaces as singletons. Inject them anywhere.


public class MusicPage
{
readonly IMediaLibrary library;
readonly IMusicPlayer player;
public MusicPage(IMediaLibrary library, IMusicPlayer player)
{
this.library = library;
this.player = player;
}
async Task LoadAndPlay()
{
// 1. Request permission
var status = await library.RequestPermissionAsync();
if (status != PermissionStatus.Granted)
return;
// 2. Browse the library
var allTracks = await library.GetAllTracksAsync();
// 3. Or search
var results = await library.SearchTracksAsync("Bohemian");
// 4. Play a track
await player.PlayAsync(results[0]);
// 5. Control playback
player.Pause();
player.Resume();
player.Seek(TimeSpan.FromSeconds(30));
player.Stop();
}
}

Permissions differ between platforms:

PlatformPermissionNotes
Android 13+ (API 33)READ_MEDIA_AUDIONew granular media permission
Android 12 and belowREAD_EXTERNAL_STORAGELegacy broad storage permission
iOSApple Music usage descriptionMust be in Info.plist or app crashes

The library handles this automatically. RequestPermissionAsync() prompts the user with the correct platform permission. CheckPermissionAsync() checks the current status without prompting.

Android AndroidManifest.xml:

<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />

iOS Info.plist:

<key>NSAppleMusicUsageDescription</key>
<string>This app needs access to your music library to browse and play your music.</string>

Every track comes back as a MusicMetadata record:

public record MusicMetadata(
string Id, // Platform-specific unique ID
string Title,
string Artist,
string Album,
string? Genre,
TimeSpan Duration,
string? AlbumArtUri, // Android only — null on iOS
string ContentUri // content:// (Android) or ipod-library:// (iOS)
);
// Get everything
var tracks = await library.GetAllTracksAsync();
// Search across title, artist, and album
var results = await library.SearchTracksAsync("Beatles");

On Android, queries go through MediaStore.Audio.Media with ContentResolver. On iOS, they use MPMediaQuery with MPMediaPropertyPredicate.

Need to copy a track to your app’s storage?

var destination = Path.Combine(FileSystem.AppDataDirectory, "exported.m4a");
bool success = await library.CopyTrackAsync(track, destination);

Returns false if the copy isn’t possible — which brings us to the DRM caveat.


await player.PlayAsync(track); // Load and play
player.Pause(); // Pause at current position
player.Resume(); // Resume from paused position
player.Seek(TimeSpan.FromMinutes(1)); // Jump to position
player.Stop(); // Stop and release
PlaybackState state = player.State; // Stopped, Playing, or Paused
MusicMetadata? current = player.CurrentTrack;
TimeSpan position = player.Position;
TimeSpan duration = player.Duration;
player.StateChanged += (sender, args) =>
{
// React to state transitions
};
player.PlaybackCompleted += (sender, args) =>
{
// Track finished — load next?
};

On Android, playback uses Android.Media.MediaPlayer. On iOS, it uses AVAudioPlayer via AVFoundation. Both are properly managed — resources are released on stop and disposal.


This is important to know upfront.

On iOS, Apple Music subscription tracks (DRM-protected) cannot be played or copied through this API. For these tracks:

  • ContentUri will be an empty string
  • CopyTrackAsync returns false
  • PlayAsync throws InvalidOperationException

Only locally synced or purchased (non-DRM) tracks work. The metadata (title, artist, album, duration) is still available for all tracks — you just can’t access the audio data for DRM-protected ones.

On Android, all locally stored music files work without restrictions via ContentResolver.

This is an OS-level limitation, not a library limitation. iOS simply does not expose the audio asset URL for DRM-protected media items.


PlatformMinimum VersionAudio QueryPlayback Engine
AndroidAPI 24 (Android 7.0)MediaStore.Audio.MediaAndroid.Media.MediaPlayer
iOS15.0MPMediaQueryAVAudioPlayer

Both platforms require a physical device for meaningful testing — simulators and emulators don’t have music content.


Good fit:

  • Music player apps that browse the device library
  • Apps that need to search or display the user’s music collection
  • Exporting audio files for processing (transcription, analysis, sharing)
  • Any app that integrates with locally stored music

Not the best fit:

  • Streaming music from web sources (use MediaElement or a streaming library)
  • Audio recording (use Plugin.Maui.Audio or platform audio APIs)
  • Background audio playback with lock screen controls (this is foreground playback)

Terminal window
dotnet add package Shiny.Music

Full source and sample app at the GitHub repository. Documentation coming soon at shinylib.net/client/music.

Introducing Shiny.Spatial — A Dependency-Free Spatial Database and GPS Geofencing for .NET

If you’ve ever tried to do geospatial work on .NET MAUI, you know the options aren’t great. SpatiaLite requires native binaries per platform. NetTopologySuite is a full-featured geometry library — great on the server, but heavy on mobile and hostile to AOT. And the built-in platform geofencing? iOS gives you 20 circular regions. Android gives you 60. That’s it.

I needed something different — a spatial database that works everywhere .NET runs, with zero native dependencies, and a geofencing system that can monitor thousands of polygon regions from a real geographic database. So I built Shiny.Spatial.


Two NuGet packages:

  • Shiny.Spatial — a spatial database engine that stores geometry in SQLite using R*Tree virtual tables, with all spatial algorithms implemented in pure C#. No SpatiaLite, no NetTopologySuite, no native binaries.
  • Shiny.Spatial.Geofencing — a GPS-driven geofence monitor that watches spatial database tables for region entry/exit instead of registering individual fences with the OS.

The spatial database targets netstandard2.0 and net10.0, so it runs on MAUI, Blazor, console apps, servers — anywhere SQLite runs. The geofencing package targets iOS and Android via MAUI.


Every spatial query follows the same pattern:

  1. Pass 1 — R*Tree bounding box filter (SQL, O(log n)). SQLite’s R*Tree virtual table eliminates most candidates based on bounding box overlap. This runs entirely in SQL and is extremely fast.

  2. Pass 2 — C# geometry refinement. Surviving candidates are tested with exact geometric predicates — point-in-polygon (ray-casting), segment intersection (cross-product), Haversine distance — in pure C#.

This two-pass approach is the same strategy PostGIS and SpatiaLite use internally. The difference is that both passes here require zero native extensions.

// What happens under the hood for an Intersecting query:
// Pass 1: SELECT * FROM cities_rtree WHERE min_x <= @maxX AND max_x >= @minX ...
// Pass 2: SpatialPredicates.Intersects(candidate.Geometry, queryGeometry)
using var db = new SpatialDatabase("locations.db");
var table = db.CreateTable("cities", CoordinateSystem.Wgs84,
new PropertyDefinition("name", PropertyType.Text),
new PropertyDefinition("population", PropertyType.Integer)
);

Each table gets a single R*Tree virtual table with auxiliary columns for WKB-encoded geometry and user-defined properties. No separate tables, no JOINs.

All geometry classes are immutable and sealed:

  • Point, LineString, Polygon (with holes)
  • MultiPoint, MultiLineString, MultiPolygon
  • GeometryCollection

Coordinates use double X (longitude) and double Y (latitude). Polygon supports interior rings (holes) — useful for real geographic boundaries like city limits with excluded areas.

// A simple polygon — Colorado's approximate boundary
var colorado = new Polygon(new[]
{
new Coordinate(-109.05, 37.0),
new Coordinate(-109.05, 41.0),
new Coordinate(-102.05, 41.0),
new Coordinate(-102.05, 37.0),
new Coordinate(-109.05, 37.0) // closed ring
});
// Single insert
var feature = new SpatialFeature(
new Point(-104.99, 39.74),
new Dictionary<string, object?>
{
["name"] = "Denver",
["population"] = 715522
}
);
table.Insert(feature);
// Bulk insert — transaction-wrapped
table.BulkInsert(features);

table.Query() returns a fluent builder that chains spatial filters, property filters, distance ordering, and paging:

// Find all cities within 50km of Denver, ordered by distance
var nearby = table.Query()
.WithinDistance(new Coordinate(-104.99, 39.74), 50_000)
.OrderByDistance(new Coordinate(-104.99, 39.74))
.Limit(10)
.ToList();
// Find features inside a polygon
var inColorado = table.Query()
.Intersecting(coloradoBoundary)
.ToList();
// Combine spatial + property filters
var largeCities = table.Query()
.WithinDistance(center, 100_000)
.WhereProperty("population", ">", 100000)
.OrderByDistance(center)
.Limit(20)
.ToList();
// Paging
var page2 = table.Query()
.WhereProperty("state", "=", "Colorado")
.Limit(10)
.Offset(10)
.ToList();

Property filters run in SQL (Pass 1), spatial filters run as C# refinement (Pass 2), and distance ordering plus limit/offset are applied after refinement. The query builder composes them automatically.

For simple cases, skip the builder:

var feature = table.GetById(42);
var inBox = table.FindInEnvelope(envelope);
var intersecting = table.FindIntersecting(polygon);
var contained = table.FindContainedBy(polygon);
var nearby = table.FindWithinDistance(center, 10_000); // meters

All benchmarks on Apple M2, .NET 10, in-memory SQLite, 100K point features:

QueryMean
FindIntersecting (polygon)1.15 ms
FindWithinDistance183 us
FindContainedBy987 us
GetById9.4 us
Fluent: spatial + property filter1.44 ms
Fluent: distance + order + limit254 us
InsertCountMean
BulkInsert1,0009.8 ms
BulkInsert10,00093 ms
BulkInsert100,000964 ms

The pure algorithms are fast too — Haversine at 28ns, point-in-polygon (5 vertices) at 24ns, segment intersection at 3ns.

Geometry is stored as WKB (Well-Known Binary), the same binary format used by PostGIS, SpatiaLite, and every major GIS tool. The built-in WkbReader and WkbWriter handle all seven geometry types.


This is where it gets interesting for MAUI developers.

Native geofencing on mobile has hard limits:

iOSAndroid
Max regions2060
ShapeCircle onlyCircle only
PrecisionOS-determinedOS-determined
TimingOS-determinedOS-determined
Handler time~4 secondsLimited
Emulator testingWorksUnreliable

If your app needs to know when a user enters Denver, you register a circular region around Denver’s center. But Denver isn’t a circle — it’s an irregular polygon. And if your app needs to track entry/exit for 200 cities, you’re out of luck. You’d need 200 regions, but the OS caps you at 20 or 60.

Instead of registering individual fences with the OS, Shiny.Spatial.Geofencing hooks into GPS updates and queries your spatial databases in real-time:

  1. GPS reading comes in
  2. For each monitored table, query table.Query().Intersecting(point).FirstOrDefault()
  3. Compare the current feature ID with the previously stored feature ID
  4. If they differ — fire exit for the old region and enter for the new one

No OS region limits. No circular approximations. Real polygon boundaries from real geographic data.

Install the package:

Terminal window
dotnet add package Shiny.Spatial.Geofencing

Create a delegate to handle region changes:

public class MyGeofenceDelegate(
ILogger<MyGeofenceDelegate> logger,
INotificationManager notifications
) : ISpatialGeofenceDelegate
{
public async Task OnRegionChanged(SpatialRegionChange change)
{
var name = change.Region.Properties.GetValueOrDefault("name") ?? "Unknown";
var action = change.Entered ? "Entered" : "Exited";
logger.LogInformation("{Action} {Region} (table: {Table})", action, name, change.TableName);
await notifications.Send("Geofence", $"{action}: {name}");
}
}

Register in MauiProgram.cs:

builder.Services.AddSpatialGps<MyGeofenceDelegate>(cfg => cfg
.Add(dbPath, "states")
.Add(dbPath, "cities")
);

That’s it. The library now monitors both the states and cities tables. When the user crosses a state boundary, you get an exit + enter event. When they enter a city, you get a separate enter event. The two layers are tracked independently.

public interface ISpatialGeofenceManager
{
bool IsStarted { get; }
Task<AccessState> RequestAccess();
Task Start();
Task Stop();
Task<IReadOnlyList<SpatialCurrentRegion>> GetCurrent(CancellationToken cancelToken = default);
}

GetCurrent() takes a one-shot GPS reading and tells you which region the device is currently in for each monitored table — useful for initial state on app launch.

builder.Services.AddSpatialGps<MyGeofenceDelegate>(cfg =>
{
cfg.MinimumDistance = Distance.FromMeters(300); // default
cfg.MinimumTime = TimeSpan.FromMinutes(1); // default
cfg.Add(statesDbPath, "states");
cfg.Add(citiesDbPath, "cities");
});

MinimumDistance and MinimumTime control how frequently GPS readings trigger spatial queries. The defaults balance battery life with responsiveness.

Because each table is tracked independently, you get precise layer-specific events:

  • Drive from Denver suburbs into Denver city limits → Entered: Denver (cities table)
  • Drive from Denver to Colorado Springs → Exited: Denver, Entered: Colorado Springs (cities table) — no state change event
  • Drive from Colorado into Kansas → Exited: Colorado, Entered: Kansas (states table) — plus whatever city events apply

The repo ships with ready-to-use databases:

DatabaseTableGeometryRecords
us-states.dbstatesPolygon51
us-cities.dbcitiesPoint100
ca-provinces.dbprovincesPolygon13
ca-cities.dbcitiesPoint50

Include them as MAUI assets and copy to app data on first launch. Or build your own from any GeoJSON source using the included DatabaseSeeder tool.


ConcernSpatiaLiteNetTopologySuiteShiny.Spatial
Native binariesYes — per platformNoNo
AOT compatiblePlatform-dependentReflection-heavyYes
TrimmableN/ANoYes
Bundle sizeLarge (native libs)ModerateSmall (single dep: Microsoft.Data.Sqlite)
Geometry algorithmsC/C++C#C#
R*Tree indexingBuilt-inSeparateBuilt-in (SQLite)
MAUI-readyRequires native binding per platformWorks but AOT issuesWorks everywhere

The tradeoff is coverage. SpatiaLite and NTS support hundreds of spatial operations — buffer, union, difference, convex hull, spatial joins. Shiny.Spatial covers the operations that matter for mobile apps: intersects, contains, within-distance, nearest-to. If you need computational geometry operations, use NTS on the server and ship the results as a spatial database to the device.


Good fit:

  • Geofencing beyond OS limits (more than 20/60 regions, polygon shapes)
  • Location-aware apps that need “which city/state/zone am I in?”
  • Spatial search (find nearby points of interest, stores, landmarks)
  • Offline spatial queries without a server round-trip
  • Shipping pre-built geographic databases with your app

Not the best fit:

  • Simple circular geofencing with a few regions (use Shiny.Locations IGeofenceManager instead)
  • Heavy computational geometry (buffer, union, difference) — use NTS on the server
  • Real-time map rendering — this is a query engine, not a rendering engine

Terminal window
dotnet add package Shiny.Spatial
dotnet add package Shiny.Spatial.Geofencing # for MAUI GPS geofencing

Full documentation at shinylib.net/spatial and the GitHub repository.

Introducing Shiny.SqliteDocumentDb — Schema-Free JSON Documents in SQLite

I’ve been building .NET apps long enough to know that SQLite is the workhorse of local storage. It’s everywhere — mobile apps, desktop apps, embedded systems, even server-side caches. But every time I reach for sqlite-net or raw ADO.NET, I end up in the same loop: design tables, write migrations, manage foreign keys, rehydrate object graphs from JOINs.

For a lot of use cases — settings stores, offline caches, app state, anything with nested data — that ceremony is overkill. What I actually want is to throw an object in and get it back out. So I built Shiny.SqliteDocumentDb.


It’s a lightweight document store that sits on top of SQLite. You give it a .NET object, it serializes it to JSON and stores it. You query it with a fluent LINQ query builder, and it translates those expressions to json_extract SQL under the hood. No schema, no migrations, no table design.

var store = new SqliteDocumentStore(new DocumentStoreOptions
{
ConnectionString = "Data Source=mydata.db"
});
// Store a document — Id is auto-generated for Guid/int/long types
var user = new User { Id = "alice-1", Name = "Alice", Age = 25 };
await store.Insert(user);
// Fluent query builder
var results = await store.Query<User>()
.Where(u => u.Name == "Alice")
.OrderBy(u => u.Age)
.ToList();

Every document type must have a public Id property of type Guid, int, long, or string. The Id is stored in both the SQLite column and the JSON blob, so query results always include it.

All JsonTypeInfo<T> parameters are optional — configure a JsonSerializerContext once and type info is auto-resolved on every call. No per-call ctx.User needed. Full AOT, fully trimmable, zero reflection.


sqlite-net is great for flat, single-table CRUD. But the moment your data has structure — an order with line items, a user with addresses, a config with nested sections — things get painful. You need multiple tables, foreign keys, multiple inserts per save, and multiple queries plus manual rehydration per read.

The document store approach collapses all of that into a single operation. One write, one read, one document.

The benchmarks tell the story:

Nested insert (Order + Address + OrderLines + Tags, 100 records):

MethodMean
DocumentStore5.69 ms
sqlite-net (3 tables)176.48 ms

Nested get by ID:

MethodMean
DocumentStore5.04 us
sqlite-net (3 queries)48.26 us

That’s 31x faster inserts and 10x faster reads for nested data. The document store wins because it does one write and one read instead of multiple table operations.

For flat data, sqlite-net can be faster on indexed column queries (it queries columns directly vs. json_extract). Use the right tool for the shape of your data.


The other question I get is “why not just use EF Core?” On a server, EF Core is a reasonable choice. On .NET MAUI — iOS, Android, Mac Catalyst — it becomes a liability.

AOT is not optional on Apple platforms. iOS, iPadOS, tvOS, and Mac Catalyst all prohibit JIT compilation at the OS level. EF Core relies heavily on runtime reflection and dynamic code generation for change tracking, query compilation, and model building. Its public API is decorated with [RequiresDynamicCode] and [RequiresUnreferencedCode] throughout. That’s a non-starter for fully native AOT deployments on Apple platforms.

Android doesn’t prohibit JIT, but AOT (PublishAot or AndroidEnableProfiledAot) delivers measurably faster startup and lower memory usage — both of which directly affect user experience on mobile.

Migrations solve a problem mobile apps don’t have. On a server, you run migrations against a shared database with a known lifecycle. On a mobile device, the database is created on first launch or ships inside the app bundle. EF Core’s migration pipeline (Add-Migration, Update-Database, __EFMigrationsHistory) adds complexity with no real benefit. A schema-free document store eliminates migrations entirely.

The dependency graph is heavy. EF Core pulls in Microsoft.EntityFrameworkCore, its SQLite provider, design-time packages, and their transitive dependencies. That increases app bundle size — a real concern when app stores enforce download limits and users expect fast installs.

Mobile data is document-shaped. User preferences, cached API responses, offline data queues, local state — this data naturally has nested structure. Forcing it into normalized tables with foreign keys and JOINs adds accidental complexity.

ConcernEF CoreShiny.SqliteDocumentDb
AOT / trimmingReflection-heavy; no AOT supportOptional JsonTypeInfo<T> on every API; auto-resolves from context
MigrationsRequired for every schema changeNot needed — schema-free JSON
Nested objectsNormalized tables, foreign keys, JOINsSingle document, single write, single read
App bundle sizeLarge dependency treeSingle dependency on Microsoft.Data.Sqlite
Startup timeDbContext model building, migration checksOpen connection and go

The .NET trimmer makes this worse. Libraries that depend on reflection break under trimming because the trimmer can’t statically determine which types and members are accessed at runtime. This forces you to either disable trimming (larger binaries) or maintain complex trimmer XML configuration. This library avoids both problems — source-generated JSON serialization means the trimmer can see every type, and there’s no Expression.Compile(), no Reflection.Emit, no dynamic delegates anywhere.


This is the heart of the API. store.Query<T>() returns a fluent builder where you chain .Where(), .OrderBy(), .Paginate(), .Select() — then terminate with .ToList(), .ToAsyncEnumerable(), .Count(), .Any(), .ExecuteDelete(), .ExecuteUpdate(), or any aggregate method.

// Filter + sort + paginate
var page = await store.Query<User>()
.Where(u => u.Age > 18)
.OrderBy(u => u.Name)
.Paginate(0, 20)
.ToList();
// Count matching documents
var count = await store.Query<Order>()
.Where(o => o.Status == "Pending")
.Count();
// Delete by predicate — returns count deleted
int deleted = await store.Query<User>()
.Where(u => u.Age < 18)
.ExecuteDelete();
// Bulk update a property — returns count updated
int updated = await store.Query<User>()
.Where(u => u.Age < 18)
.ExecuteUpdate(u => u.Age, 18);
// Scalar aggregates
var maxAge = await store.Query<User>().Max(u => u.Age);
var avgAge = await store.Query<User>()
.Where(u => u.IsActive)
.Average(u => u.Age);

The expression visitor translates C# LINQ expressions into SQLite json_extract SQL, resolving property names from JsonTypeInfo metadata so [JsonPropertyName] and camelCase policies work correctly.

// Nested properties
var portland = await store.Query<Order>()
.Where(o => o.ShippingAddress.City == "Portland")
.ToList();
// → json_extract(Data, '$.shippingAddress.city') = @p0
// Collection queries with Any()
var hasWidgets = await store.Query<Order>()
.Where(o => o.Lines.Any(l => l.ProductName == "Widget"))
.ToList();
// → EXISTS (SELECT 1 FROM json_each(Data, '$.lines') WHERE ...)
// Collection Count()
var bigOrders = await store.Query<Order>()
.Where(o => o.Lines.Count() > 5)
.ToList();
// → json_array_length(Data, '$.lines') > 5
// String methods
var matches = await store.Query<User>()
.Where(u => u.Name.Contains("li"))
.ToList();
// → LIKE '%' || @p0 || '%'

Equality, comparisons, logical operators (&&, ||, !), null checks, DateTime/DateTimeOffset, captured variables — they all work. The full expression reference is in the README.


Need just a few fields from a large document? Chain .Select() to extract only the selected properties at the database level using json_object — no full deserialization.

var summaries = await store.Query<Order>()
.Where(o => o.Status == "Shipped")
.OrderBy(o => o.CustomerName)
.Paginate(0, 50)
.Select(o => new OrderSummary
{
Customer = o.CustomerName,
City = o.ShippingAddress.City,
LineCount = o.Lines.Count()
})
.ToList();

That Lines.Count() becomes json_array_length(Data, '$.lines') in SQL. You can also use Any(), Any(predicate), Count(predicate), Sum(), Max(), Min(), and Average() inside selectors.


For GROUP BY queries, use the Sql marker class inside .Select():

var stats = await store.Query<Order>()
.Where(o => o.Status != "Cancelled")
.Select(o => new OrderStats
{
Status = o.Status, // GROUP BY column
OrderCount = Sql.Count(), // COUNT(*)
TotalRevenue = Sql.Sum(o.TotalAmount),
})
.ToList();

Non-aggregate columns are automatically grouped. Sql.Count(), Sql.Max(), Sql.Min(), Sql.Sum(), Sql.Avg() — all available.


Don’t always need to replace an entire document. SetProperty updates a single field in-place via json_set(), and RemoveProperty strips a field via json_remove() — both without deserializing the document.

// Update a single field
await store.SetProperty<User>("user-1", u => u.Age, 31);
// Nested paths work too
await store.SetProperty<Order>("order-1", o => o.ShippingAddress.City, "Portland");
// Strip a field entirely
await store.RemoveProperty<User>("user-1", u => u.Email);

For multi-field patches, Upsert does RFC 7396 JSON Merge Patch — deep-merging only the provided fields while preserving everything else:

await store.Upsert(new User { Id = "user-1", Name = "Alice", Age = 31 });
// Email and other fields are preserved

Use .ToAsyncEnumerable() instead of .ToList() to stream results one-at-a-time without buffering the entire set into memory.

await foreach (var order in store.Query<Order>()
.Where(o => o.Status == "Pending")
.OrderBy(o => o.CustomerName)
.ToAsyncEnumerable())
{
await ProcessOrder(order);
}

The benchmarks show streaming eliminates Gen1 GC collections entirely at 1,000+ documents while maintaining within ~2% of buffered throughput. If you’re processing results incrementally, streaming is free performance.


The default query performance is solid, but for hot paths you can create indexes on json_extract expressions:

await store.CreateIndexAsync<User>(u => u.Name, ctx.User);
// CREATE INDEX IF NOT EXISTS idx_json_User_name
// ON documents (json_extract(Data, '$.name'))
// WHERE TypeName = 'User';

Impact on a 1,000-record flat query:

MethodMean
Without index270 us
With index8.52 us

~32x faster. The index lets SQLite use a B-tree lookup instead of scanning every row with json_extract. Works with nested properties too:

await store.CreateIndexAsync<Order>(o => o.ShippingAddress.City, ctx.Order);

All JsonTypeInfo<T> parameters are optional with = null defaults. Configure a JsonSerializerContext once at setup and every method auto-resolves type info — no per-call parameters needed.

[JsonSerializable(typeof(User))]
[JsonSerializable(typeof(Order))]
public partial class AppJsonContext : JsonSerializerContext;
var ctx = new AppJsonContext(new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
var store = new SqliteDocumentStore(new DocumentStoreOptions
{
ConnectionString = "Data Source=mydata.db",
JsonSerializerOptions = ctx.Options,
UseReflectionFallback = false // recommended for AOT
});
// Now every call is AOT-safe without passing JsonTypeInfo explicitly
var user = new User { Id = "alice-1", Name = "Alice", Age = 25 };
await store.Insert(user);
var users = await store.Query<User>().Where(u => u.Age > 18).ToList();

Pass ctx.Options to DocumentStoreOptions.JsonSerializerOptions so the expression visitor and serializer share the same naming configuration. That’s the one thing people forget — and then their LINQ queries silently return zero results because property names don’t match.

Set UseReflectionFallback = false for AOT deployments. Instead of opaque runtime failures, you get a clear InvalidOperationException telling you exactly which type is missing from your JsonSerializerContext.


Atomic multi-document operations with automatic commit/rollback:

await store.RunInTransaction(async tx =>
{
await tx.Insert(order); // order.Id must be set
await tx.Insert(user); // user.Id must be set
// Exception → automatic rollback
});

The tx parameter is a full IDocumentStore, so you can use any operation inside the transaction — queries, counts, removes, everything.


One line:

services.AddSqliteDocumentStore("Data Source=mydata.db");

Registers IDocumentStore as a singleton. For full configuration:

services.AddSqliteDocumentStore(opts =>
{
opts.ConnectionString = "Data Source=mydata.db";
opts.TypeNameResolution = TypeNameResolution.FullName;
opts.JsonSerializerOptions = ctx.Options;
opts.UseReflectionFallback = false;
});

Good fit:

  • Offline caches and app state
  • Settings and configuration stores
  • Data with nested objects and child collections
  • Rapid prototyping without schema design
  • Any scenario where you want to store and query object graphs without table design

Not the best fit:

  • Bulk operations on millions of rows where raw SQL shines
  • Simple flat-table CRUD where sqlite-net is already working well

Terminal window
dotnet add package Shiny.SqliteDocumentDb

Full documentation at shinylib.net/sqlite-docdb and the GitHub repository has the complete README with benchmarks, expression reference tables, and examples.

Introducing Shiny.Maui.TableView — Settings-Style Pages for .NET MAUI, Without the Platform Pain

If you’ve built a .NET MAUI app, you’ve probably needed a settings page. A scrollable list of sections with toggles, text entries, pickers, radio buttons — the kind of UI you see in every iOS Settings screen or Android preferences panel. And if you’ve tried to build one, you know it’s surprisingly painful.

MAUI’s built-in TableView is limited. The community options either depend on native renderers that break across platform updates or haven’t kept up with modern .NET. I wanted something that just worked — pure MAUI, no platform-specific code, full MVVM support, and enough cell types to cover real app scenarios without writing custom templates for everything.

So I built Shiny.Maui.TableView.


It’s a settings-style TableView control built entirely on .NET MAUI layout primitives. No custom handlers, no native renderers, no platform-specific code. It ships with 15 cell types that cover the most common settings UI patterns, a three-level cascading style system, drag-and-drop reordering, dynamic section generation, and full MVVM data binding.

xmlns:tv="http://shiny.net/maui/tableview"
<tv:TableView CellAccentColor="#007AFF">
<tv:TableRoot>
<tv:TableSection Title="Network">
<tv:SwitchCell Title="Wi-Fi"
On="{Binding WifiEnabled, Mode=TwoWay}" />
<tv:SwitchCell Title="Bluetooth"
On="{Binding BluetoothEnabled, Mode=TwoWay}" />
</tv:TableSection>
</tv:TableRoot>
</tv:TableView>

That’s a fully functional settings section with two-way bound toggles. No custom renderers, no platform init code beyond one line in MauiProgram.cs.


The existing community solution — AiForms.Maui.SettingsView — is excellent, but it uses native platform renderers (UITableView on iOS, RecyclerView on Android). That means platform-specific bugs, maintenance burden across OS updates, and behavior differences between platforms.

Shiny.Maui.TableView takes a different approach: everything is built from ContentView, ScrollView, VerticalStackLayout, Grid, and Border. One codebase, identical behavior everywhere. It runs on iOS, Android, and Mac Catalyst with zero platform-specific code.

The tradeoff is no virtualization — this is a full-render model. For settings pages with dozens of items, that’s perfectly fine. This isn’t meant for scrolling through thousands of rows; it’s meant for building the kind of structured, section-based UI that settings and forms demand.


The library ships with cell types that cover the breadth of settings UI patterns:

  • LabelCell — Read-only title/value display
  • CommandCell — Tappable cell with optional disclosure arrow and command binding
  • ButtonCell — Full-width button-style action cell
  • SwitchCell — Toggle with customizable accent color
  • CheckboxCell — Native checkbox with accent color
  • SimpleCheckCell — Lightweight checkmark for selection lists
  • RadioCell — Radio selection within a section or across the entire TableView
  • EntryCell — Inline text entry with placeholder, keyboard type, password masking, and max length
  • DatePickerCell — Native date picker dialog
  • TimePickerCell — Native time picker dialog
  • TextPickerCell — Dropdown-style picker from a list of items
  • NumberPickerCell — Numeric input via dialog with min/max/unit
  • PickerCell — Full-page selection with single or multi-select, auto-generated display text, and configurable pick-to-close behavior
  • CustomCell — Host any MAUI View with optional full-width mode, command binding, and disclosure arrow

Every cell shares a common base with title, description, hint text, icon, background color, selection highlight, and border customization.


One of the features I’m most happy with is the three-level cascading style system. Set defaults at the TableView level, override at the section level, and fine-tune on individual cells.

Level 1 — TableView (global defaults):

<tv:TableView
CellTitleColor="#333333"
CellTitleFontSize="17"
CellDescriptionColor="#888888"
CellValueTextColor="#007AFF"
CellBackgroundColor="White"
CellAccentColor="#007AFF"
HeaderTextColor="#666666"
SeparatorColor="#C6C6C8">

This sets the look for every cell and header in the entire TableView.

Level 2 — TableSection (section overrides):

<tv:TableSection Title="Important"
HeaderBackgroundColor="#E3F2FD"
HeaderTextColor="#1565C0">

Level 3 — Individual cell (highest priority):

<tv:LabelCell Title="Warning" TitleColor="Red" />

The resolution logic is simple: cell property wins over section property, section wins over TableView, TableView wins over framework defaults. You set your theme once at the top and only override where you need to.

The style system covers everything: title, description, hint text, and value text fonts and colors; icon size and radius; accent color for interactive controls; cell backgrounds and selection highlights; header and footer appearance; separator color, height, and inset; section gap height and color; cell padding and borders.


Static XAML is great for fixed settings pages, but sometimes you need sections or cells generated from data. Both TableView and TableSection support ItemsSource with DataTemplate.

Dynamic cells within a section:

<tv:TableSection Title="Devices"
ItemsSource="{Binding Devices}">
<tv:TableSection.ItemTemplate>
<DataTemplate>
<tv:LabelCell Title="{Binding Name}"
ValueText="{Binding Status}" />
</DataTemplate>
</tv:TableSection.ItemTemplate>
</tv:TableSection>

You can mix static and templated cells in the same section using TemplateStartIndex to control where generated cells appear. The binding supports INotifyCollectionChanged, so adding or removing items from your collection updates the UI automatically.


Enable reordering with a single property:

<tv:TableView ItemDroppedCommand="{Binding ReorderCommand}">
<tv:TableRoot>
<tv:TableSection Title="Priority" UseDragSort="True">
<tv:LabelCell Title="High" />
<tv:LabelCell Title="Medium" />
<tv:LabelCell Title="Low" />
</tv:TableSection>
</tv:TableRoot>
</tv:TableView>

Each cell gets up/down arrow controls. The ItemDroppedCommand receives ItemDroppedEventArgs with the section, cell, and from/to indexes so you can update your backing data.


Radio selection is handled through an attached property that can scope to a section or the entire TableView:

<tv:TableSection Title="Theme"
tv:RadioCell.SelectedValue="{Binding SelectedTheme, Mode=TwoWay}">
<tv:RadioCell Title="Light" Value="Light" />
<tv:RadioCell Title="Dark" Value="Dark" />
<tv:RadioCell Title="System" Value="System" />
</tv:TableSection>

Bind RadioCell.SelectedValue to your ViewModel and you get two-way radio group selection with no code-behind.


The PickerCell navigates to a dedicated selection page — useful for long lists or multi-select scenarios:

<tv:PickerCell Title="Interests"
ItemsSource="{Binding AllInterests}"
SelectedItems="{Binding SelectedInterests, Mode=TwoWay}"
SelectionMode="Multiple"
MaxSelectedNumber="5"
UsePickToClose="True"
UseAutoValueText="True"
PageTitle="Select Interests" />

It auto-generates the value text from your selections, supports single and multi-select modes, and can auto-close when the max selection count is reached.


Install the NuGet package:

Terminal window
dotnet add package Shiny.Maui.TableView

Register in MauiProgram.cs:

var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.UseShinyTableView();

Add the XAML namespace and start building:

<ContentPage xmlns:tv="http://shiny.net/maui/tableview">
<tv:TableView>
<tv:TableRoot>
<tv:TableSection Title="General">
<tv:SwitchCell Title="Notifications"
On="{Binding NotificationsEnabled, Mode=TwoWay}" />
<tv:EntryCell Title="Name"
ValueText="{Binding UserName, Mode=TwoWay}"
Placeholder="Enter your name" />
<tv:CommandCell Title="About"
Command="{Binding AboutCommand}"
ShowArrow="True" />
</tv:TableSection>
</tv:TableRoot>
</tv:TableView>
</ContentPage>

Good fit:

  • Settings and preferences pages
  • Form-style data entry screens
  • Profile editing UIs
  • Any structured, section-based list with mixed control types
  • Apps targeting iOS, Android, and Mac Catalyst from a single codebase

Not the best fit:

  • Long scrolling lists with hundreds or thousands of items (no virtualization)
  • Data grids or spreadsheet-style layouts
  • Chat interfaces or feed-style UIs

Terminal window
dotnet add package Shiny.Maui.TableView

Full documentation at shinylib.net/tableview and the GitHub repository has the complete source, sample app, and issue tracker.

Shiny Mediator & AOT - Zero Reflection, Full Speed

Native AOT and trimming are no longer “nice to have” — they’re table stakes for modern .NET apps. iOS has never allowed JIT. Blazor WebAssembly ships every byte to the browser. ASP.NET minimal APIs are racing toward sub-10ms cold starts. If your library leans on reflection, you’re the bottleneck.

Shiny Mediator took this personally.

Starting in v5 and fully realized in v6, Shiny Mediator has waged a war on reflection. Every piece of runtime introspection has been replaced with compile-time source generation. The result? A mediator pipeline that is 100% AOT-safe, fully trimmable, and frankly… faster than it has any right to be.

Let’s walk through every source generator and design decision that makes this possible.


1. Handler & Middleware Registration — [MediatorSingleton] / [MediatorScoped]

Section titled “1. Handler & Middleware Registration — [MediatorSingleton] / [MediatorScoped]”

The old way of registering handlers meant scanning assemblies, resolving open generics, and hoping the DI container could figure it all out at runtime. That’s… not AOT-friendly.

Shiny Mediator replaces all of that with two attributes:

[MediatorSingleton]
public class GetUserHandler : IRequestHandler<GetUserRequest, UserResponse>
{
public async Task<UserResponse> Handle(
GetUserRequest request,
IMediatorContext context,
CancellationToken ct)
{
// your handler logic
}
}

At compile time, the source generator discovers every class decorated with [MediatorSingleton] or [MediatorScoped] and emits all the DI registration code for you. No assembly scanning. No typeof() gymnastics. No reflection.

The generated code feeds into a module initializer registry — a static registry that collects every handler and middleware registration across your entire solution. To wire it all up:

services.AddShinyMediator(x => x.AddMediatorRegistry());

One line. Every handler. Every middleware. All generated at compile time.

These attributes also handle middleware registration — so if you have a custom middleware class, slap [MediatorSingleton] on it and it joins the party automatically.


Here’s a fun .NET limitation: you can’t easily resolve a generic type at runtime without knowing the type parameters at compile time. Previous versions of Shiny Mediator used reflection to bridge that gap. It worked, but it was slow and it was the single biggest AOT blocker.

Starting in v5, source generators create typed executors for every request and stream request in your project. These executors know the exact types at compile time, so the mediator can dispatch directly without any MakeGenericType or Activator.CreateInstance shenanigans.

You don’t need to do anything extra — if you’re using [MediatorSingleton] / [MediatorScoped], the executor generation comes along for the ride.


AOT’s nemesis is System.Text.Json with reflection-based serialization. The standard fix is JsonSerializerContext and [JsonSerializable] — but here’s the problem: you can’t chain source generators. Shiny Mediator’s source generator runs first, and System.Text.Json’s source generator can’t see the types that were just generated.

So Shiny Mediator built its own JSON serialization source generator.

When you generate HTTP clients from OpenAPI specs, just flip one switch:

<ItemGroup>
<MediatorHttp Include="MyApi"
Uri="https://api.example.com/openapi.json"
Namespace="MyApp.Api"
GenerateJsonConverters="true"
Visible="false" />
</ItemGroup>

Setting GenerateJsonConverters="true" tells the source generator to emit high-performance, AOT-safe JSON converters for every contract and response type it produces. No reflection. No JsonSerializerContext registration. The [JsonConverter] attribute is placed directly on each type.

Got your own classes that need serialization — maybe for offline storage, caching, or custom contracts? Use the [SourceGenerateJsonConverter] attribute:

[SourceGenerateJsonConverter]
public partial class WeatherForecast
{
public string? City { get; set; }
public double Temperature { get; set; }
public DateTime Date { get; set; }
}

The class must be partial (the generator needs to attach code to it). That’s it — you get a compile-time JSON converter without ever touching System.Text.Json source generation configuration.


Middleware like caching, offline storage, and stream replay all need a key to identify unique requests. The “old school” way was implementing IContractKey on your contract:

// Before — manual, tedious, error-prone
public class SearchRequest : IRequest<SearchResult>, IContractKey
{
public string? Query { get; set; }
public int? Page { get; set; }
public DateTime? Since { get; set; }
public string GetKey()
{
var key = "SearchRequest";
if (Query != null) key += $"_{Query}";
if (Page != null) key += $"_{Page}";
if (Since != null) key += $"_{Since:yyyyMMdd}";
return key;
}
}

Writing null checks and format strings for every property on every contract gets old fast. And the default IContractKeyProvider used reflection to build keys when you didn’t implement the interface.

The source-generated version:

// After — one attribute, zero reflection
[ContractKey("SearchRequest_{Query}_{Page}_{Since:yyyyMMdd}")]
public partial class SearchRequest : IRequest<SearchResult>
{
public string? Query { get; set; }
public int? Page { get; set; }
public DateTime? Since { get; set; }
}

The source generator handles null checks, ToString() calls, format strings — everything. If a property is null, that portion of the key is replaced with an empty string. The class must be partial, and you can use the same format specifiers you’d use in string interpolation.

Leave the format string blank and it uses all public instance properties automatically.


This one is subtle but critical. Many of Shiny Mediator’s middleware components are driven by attributes on handler methods:

public partial class GetProductHandler : IRequestHandler<GetProductRequest, Product>
{
[Cache(MaxAgeSeconds = 300)]
[OfflineAvailable]
public async Task<Product> Handle(
GetProductRequest request,
IMediatorContext context,
CancellationToken ct)
{
// fetch product from API
}
}

Reading attributes from methods at runtime requires deep reflection — MethodInfo.GetCustomAttributes() and friends. In an AOT world, that can fail silently or crash spectacularly.

Shiny Mediator’s source generator scans handler methods at compile time, extracts every attribute that inherits from MediatorMiddlewareAttribute, and emits code that makes them available via context.GetHandlerAttribute<T>() — no reflection needed at runtime.

Your handler class must be partial for this to work. That’s the one rule.

Best of all, this works with your own attributes too. Just inherit from MediatorMiddlewareAttribute:

public class AuditLogAttribute : MediatorMiddlewareAttribute
{
public string Category { get; set; } = "General";
}
public partial class CreateOrderHandler : IRequestHandler<CreateOrderRequest, Order>
{
[AuditLog(Category = "Orders")]
public async Task<Order> Handle(
CreateOrderRequest request,
IMediatorContext context,
CancellationToken ct)
{
// ...
}
}
// In your middleware — zero reflection
public class AuditLogMiddleware<TRequest, TResult>
: IRequestMiddleware<TRequest, TResult> where TRequest : IRequest<TResult>
{
public async Task<TResult> Process(
IMediatorContext context,
RequestHandlerDelegate<TResult> next,
CancellationToken ct)
{
var attr = context.GetHandlerAttribute<AuditLogAttribute>();
if (attr != null)
{
// log with attr.Category
}
return await next();
}
}

The HTTP extension’s OpenAPI source generator is arguably the crown jewel of Shiny Mediator’s AOT story. From a single OpenAPI spec, it generates:

  • Request contracts with proper HTTP verb, route, query, header, and body annotations
  • Response types matching the API schema
  • A typed request handler that uses HttpClient under the hood
  • JSON converters (when GenerateJsonConverters="true") for every generated type
  • DI registration via .AddGeneratedOpenApiClient()

All from a csproj item:

<ItemGroup>
<MediatorHttp Include="PetStore"
Uri="https://petstore.swagger.io/v2/swagger.json"
Namespace="MyApp.PetStore"
ContractPostfix="HttpRequest"
GenerateJsonConverters="true"
Visible="false" />
</ItemGroup>
services.AddShinyMediator(x =>
{
x.AddMediatorRegistry();
x.AddGeneratedOpenApiClient();
});

Now every HTTP call flows through the mediator pipeline — which means caching, offline, validation, performance logging, and every other middleware you’ve configured automatically applies to your API calls. And it’s all AOT-safe because every type is known at compile time.


On the server side, Shiny Mediator can source-generate minimal API endpoints directly from your handlers:

[MediatorScoped]
public partial class CreateUserHandler : IRequestHandler<CreateUserRequest, UserResponse>
{
[Post("/api/users")]
public async Task<UserResponse> Handle(
CreateUserRequest request,
IMediatorContext context,
CancellationToken ct)
{
// create user
}
}

The source generator emits the app.MapPost("/api/users", ...) call and the DI wiring. No controller classes. No manual endpoint mapping. Just your handler with an HTTP attribute and the generator does the rest.


Here’s what Shiny Mediator source-generates at compile time — and what it doesn’t do at runtime:

FeatureCompile Time (Source Gen)Runtime (Reflection)
Handler DI Registration[MediatorSingleton] / [MediatorScoped]❌ No scanning
Request/Stream Executors✅ Typed dispatch❌ No MakeGenericType
JSON Serialization[SourceGenerateJsonConverter]❌ No reflection-based serializers
Contract Keys[ContractKey]❌ No property reflection
Middleware Attributes[Cache], [OfflineAvailable], custom❌ No GetCustomAttributes
HTTP ClientsMediatorHttp MSBuild item❌ No runtime proxy generation
ASP.NET Endpoints✅ Handler method attributes❌ No controller discovery

The result is a mediator library that:

  • Ships on iOS without fighting the linker
  • Runs in Blazor WASM without bloating the download
  • Cold-starts fast on ASP.NET because there’s nothing to scan or JIT
  • Trims clean because every code path is statically reachable

If you’re already using Shiny Mediator, the path to full AOT is straightforward:

  1. Make handler and contract classes partial — the source generators need this
  2. Add [MediatorSingleton] or [MediatorScoped] to your handlers and middleware
  3. Use [ContractKey] instead of implementing IContractKey manually
  4. Set GenerateJsonConverters="true" on your MediatorHttp items
  5. Use [SourceGenerateJsonConverter] on custom types that need serialization
  6. Call x.AddMediatorRegistry() in your startup instead of manual registration

That’s it. No reflection. No runtime surprises. Just compile-time confidence.


AOT and trimming aren’t just performance checkboxes — they’re the foundation of where .NET is heading. Shiny Mediator has gone all-in on source generation to make sure you can take your mediator pipeline anywhere .NET runs: mobile, browser, server, and beyond.

If reflection was the training wheels, source generation is the carbon fiber frame. Time to ride.

Check out the full source generation docs and the HTTP extension docs for all the details.