Skip to content

Blog

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 and written back to the object
var user = new User { Name = "Alice", Age = 25 };
await store.Set(user);
// user.Id is now populated
// 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 { Name = "Alice", Age = 25 };
await store.Set(user); // user.Id is auto-generated
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.Set(order); // order.Id must be set
await tx.Set(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.

What's New in Shiny Mediator 6

Shiny Mediator v6 has landed and it’s packed with some exciting new features and improvements. This release is a big step forward for the library, focusing on better source generation, AOT readiness, ASP.NET interop, and some slick new middleware. Let’s dive in.

The biggest (and breaking) change is that Shiny Mediator v6 now targets .NET 8 and .NET 10 as minimum versions. This allows us to take advantage of the latest runtime features for performance, trimming, and AOT compilation. If you’re still on older versions, you’ll need to stay on v5.

The HTTP client source generation has been completely overhauled. In v5, the OpenAPI generation would produce contracts, but you’d still need to wire up handlers and serialization yourself. In v6, the source generator now produces everything — handlers, contracts, JSON converters, and dependency injection registration.

This is a breaking change because you’ll need to call a new registration method, but the setup through MediatorHttpItem in your csproj remains the same:

<ItemGroup>
<MediatorHttp Include="OpenApiRemote"
Uri="https://yourapi.com/openapi.json"
Namespace="My.Namespace"
ContractPostfix="HttpRequest"
GenerateJsonConverters="true"
Visible="false" />
</ItemGroup>

And then in your startup:

builder.Services.AddShinyMediator(x => x.AddGeneratedOpenApiClient());

That’s it. Full HTTP client with AOT-safe serialization, middleware support, and zero boilerplate.

Another nice improvement: if your OpenAPI spec doesn’t have an OperationId, the source generator now infers a name using the HTTP verb and path. For example, GET /user/list becomes GetUserList. No more missing contracts because someone forgot to set an operation ID.

The DirectHttpRequest type has been removed. You can still create your own HTTP objects with the [Http] attribute which will source generate a handler for you. This was cleaned up to reduce confusion between the direct approach and the more powerful generated approach.

One of the more subtle but impactful additions is PublishToBackground on IMediator. If you’ve ever tried to fire-and-forget an event in ASP.NET, you’ve probably run into scoping issues where the HttpContext or scoped services get disposed before your event handlers finish.

PublishToBackground solves this by spawning a new child scope, so you can fire events without worrying about disposal:

public class MyController(IMediator mediator) : ControllerBase
{
[HttpPost]
public async Task<IActionResult> DoSomething()
{
// this returns immediately and the event handlers
// run in a separate scope
await mediator.PublishToBackground(new SomethingHappenedEvent());
return Ok();
}
}

You can still use the standard Publish to await all event handlers in the same scope.

Async Enumerable Responses & Server-Sent Events

Section titled “Async Enumerable Responses & Server-Sent Events”

Stream requests now support IAsyncEnumerable<T> responses that can be wired directly to Server-Sent Events endpoints. Just mark your contract with IServerSentEventsStream:

[Get("/api/sse")]
public class ServerSentEventsRequest : IStreamRequest<EventItem>, IServerSentEventsStream
{
}

If your endpoint does not support server-sent events, you can still use IStreamRequest<T> — just drop the IServerSentEventsStream interface and you get a standard async enumerable response.

The new [Throttle] attribute implements a debounce pattern for event handlers. This is huge for scenarios like search-as-you-type, sensor data processing, or any rapid-fire event where you only care about the latest value:

services.AddShinyMediator(cfg => cfg.AddMediatorRegistry().AddThrottleEventMiddleware());
[MediatorSingleton]
public partial class SearchHandler : IEventHandler<SearchChangedEvent>
{
[Throttle(300)] // wait 300ms after last event
public async Task Handle(
SearchChangedEvent @event,
IMediatorContext context,
CancellationToken ct)
{
// only fires once the user stops typing for 300ms
var results = await searchService.Search(@event.Query, ct);
}
}

How it works:

  1. When an event is published, a timer starts
  2. If the same event fires again before the timer expires, the timer resets and the previous event is discarded
  3. Only after the full delay with no new events does the handler execute with the latest data

Your handler class must be partial for source generation to work.

Previously, middleware executed in DI registration order. That’s fine for simple scenarios, but as your pipeline grows you need more control. The new [MiddlewareOrder] attribute gives you explicit ordering:

[MiddlewareOrder(-100)] // runs first (outermost)
public class ValidationMiddleware<TRequest, TResult>
: IRequestMiddleware<TRequest, TResult> { ... }
[MiddlewareOrder(0)] // default
public class LoggingMiddleware<TRequest, TResult>
: IRequestMiddleware<TRequest, TResult> { ... }
[MiddlewareOrder(100)] // runs last (closest to handler)
public class CachingMiddleware<TRequest, TResult>
: IRequestMiddleware<TRequest, TResult> { ... }

This produces: Validation → Logging → Caching → Handler → Caching → Logging → Validation

Lower values run first. If you don’t use the attribute, everything works exactly as before — it’s fully opt-in.

A new built-in middleware that caches HTTP responses based on CacheControl MaxAge headers. If your API sends proper cache headers, Shiny Mediator will respect them automatically without any extra configuration on your part.

Beyond the headline features, v6 includes a steady stream of refinements:

  • EventStream redesignedIMediator.EventStream was dropping events under load. It’s been completely redesigned for speed and reliability.
  • RuntimeEventRegister is now thread-safe — dynamic event handler registration no longer has race conditions.
  • BaseHttpRequestHandler now handles commands — not just requests.
  • OpenAPI source generation fixes — proper URI path generation for PUT/POST, fixed nullable support, TimeSpan handling, and duplicate type issues.
  • Prism RegionNavigationCommand — contributed by codelisk for region-based navigation in Prism.

If you’re coming from v5, here’s the key things to watch for:

  1. Update your target framework to .NET 8 or .NET 10
  2. HTTP client registration — call the new AddGeneratedOpenApiClient() method
  3. Remove DirectHttpRequest usages — switch to [Http] attribute-based contracts
  4. OpenAPI contracts may have slightly different names due to the automatic HttpRequest postfix

Shiny Mediator v6 continues the push toward zero-boilerplate, AOT-ready mediation for .NET. The revamped HTTP source generation alone is worth the upgrade — and features like event throttling and middleware ordering bring capabilities that would take significant effort to build yourself.

Check out the full documentation and the complete release notes for all the details.

Shiny Mediator - Getting Started

Mediator patterns have been getting a lot of attention lately, and for good reason. They help to decouple components in an application, making it easier to manage complexity and improve maintainability. Mediator patterns also go by the names “Vertical slice architecture” and CQRS (Command Query Responsibility Segregation). The big guy in .NET mediation is obviously MediatR by Jimmy Bogard. It’s an amazing library that has been used by an absolute ton of applications. It recently went to a paid model, which is understandable given the amount of work that goes into maintaining a library of that size.

The reason for building Shiny Mediator was to create a mediation library, but make sure it works with apps built on platforms like .NET MAUI and Blazor WebAssembly while also including some more “batteries included” features. This doesn’t mean I’ve neglected things like ASPNET support, but we’ll get to that in a future article.

Many engineers will call mediators “over engineering” or “an anti-pattern”. I disagree. In fact, I think mediators are one of the best patterns for building complex applications without going full bat stuff crazy with microservices out of the gate. You can run in a monolith while still keeping things decoupled and manageable thereby making it easy to “SLICE” a piece out and move it to a microservice as your application traffic grows. Another advantage of a mediation pattern is can remove the “dependency injection hell” of adding a services for things like logging, caching, and other cross cutting concerns. Instead of having to add these services to every handler, you can just add them to the mediator pipeline, configure them or stick an attribute on a handler.

public class MyViewModel(IDataService data, ICacheManager cache, IConfiguration configuration, ILogger<MyViewModel> logger) {
public async Task LoadData() {
this.Data = cache.TryGet("MyKey", ct => await data.GetData());
}
}

The above code doesn’t look too bad, but

  • Imagine you have 20 view models that all need these services.
  • How do I make sure I’m grabbing from the same cache everytime or clearing it for that matter?
  • Building cache keys can be a pain
[ContractKey("{UserId}-{IsActive}")]
public record GetDataRequest(bool IsActive, string UserId) : IRequest<GetDataResponse>;
public class MyViewModel(IMediator mediator) {
[Cache(300)]
public async Task LoadData() {
var response = await mediator.Send(new GetDataRequest());
this.Data = response.Result;
}
}

Homegrown/built in mediation patterns are often added, but require you do the growing. Middleware management is also not a simple process. Creating keys for caching requests, another whamo of complexity. These are all things solved by Shiny Mediator. AI generated code solutions is another one I hear. “Just use AI to generate the code for you”. AI is great at boilerplate or when you give it a very specific input & output, but architecture… it will throw “slop” at you.

As with all patterns, there are some trade offs. Mediators map objects to handlers (methods or controllers). It is currently not tool friendly to find a corresponding handler for a contract, so you want to make sure you structure your solution and projects well to find handlers without too much effort. Another problem that can occur is middleware executes in a pipeline, so if you have a lot of middleware, you can end up with performance issues if you’re not careful about managing the middleware to ensure quick execution.

I’ve seen some large .NET MAUI applications that had so many engineers working on them, there was constant “fire drills” (the running joke that came to be). Team A would change something that would break Team B’s work. Team C would have to make changes to Team A’s code to update navigation or data retrieval.
Quite often, these breaking changes wouldn’t even be picked up until a regression test.

In this post, we’ll explore how to get started with Shiny Mediator, a library that implements the Mediator pattern with a focus on apps written with .NET.

What are some of the “batteries included”?

Section titled “What are some of the “batteries included”?”

Most mediator libraries hand you a pipe and say “good luck”. Shiny Mediator hands you a pipe, a toolbox, a hard hat, and a coffee. Here’s what’s in the box:

Your users don’t always have internet. Shocking, I know. But building offline support from scratch is the kind of soul-crushing work that makes devs question their career choices. With Shiny Mediator, slap an attribute on your handler and you’re done:

public partial class GetOrdersHandler : IRequestHandler<GetOrdersRequest, IReadOnlyList<Order>>
{
[OfflineAvailable]
public async Task<IReadOnlyList<Order>> Handle(
GetOrdersRequest request,
IMediatorContext context,
CancellationToken ct)
{
// When online: calls your API, stores the result
// When offline: returns the last stored result
// You did nothing. You're welcome.
return await api.GetOrders(ct);
}
}

You can also configure it via appsettings.json if attributes aren’t your thing:

{
"Mediator": {
"Offline": {
"MyNamespace.GetOrdersRequest": true
}
}
}

Want to know if the data came from the offline store? The context tells you:

var response = await mediator.Request(new GetOrdersRequest());
var offline = response.Context.Offline();
if (offline != null)
{
// data is from offline store
// offline.Timestamp tells you WHEN it was stored
// maybe show a "stale data" indicator to the user
}

Caching is one of the two hardest problems in computer science (the other being naming things and off-by-one errors). Shiny Mediator makes it embarrassingly easy:

public partial class GetProductsHandler : IRequestHandler<GetProductsRequest, List<Product>>
{
[Cache(AbsoluteExpirationSeconds = 300, SlidingExpirationSeconds = 60)]
public async Task<List<Product>> Handle(
GetProductsRequest request,
IMediatorContext context,
CancellationToken ct)
{
// This only runs when cache misses.
// No cache key management. No IMemoryCache injection. No tears.
return await db.GetProducts(ct);
}
}

Setup is one line:

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

Need a cache that survives app restarts? The MAUI and Uno extensions have a persistent cache that writes to disk. Same attribute, same config — it just doesn’t evaporate when the user kills your app:

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

You can also force a cache refresh when you need fresh data (pull-to-refresh, anyone?):

var response = await mediator.Request(
new GetProductsRequest(),
CancellationToken.None,
ctx => ctx.ForceCacheRefresh()
);
// response.Result is guaranteed fresh

Validation: where developers go to argue about whether to throw exceptions or return error objects. We support both. Pick your poison.

Data Annotations (built-in, no extra package):

services.AddShinyMediator(cfg => cfg.AddDataAnnotations());
[Validate]
public class CreateUserCommand : ICommand
{
[Required]
public string Name { get; set; }
[Range(1, 150)] // optimistic about human lifespans
public int Age { get; set; }
}

FluentValidation (for the overachievers):

services.AddShinyMediator(cfg => cfg.AddFluentValidation());
[Validate]
public class CreateUserCommand : ICommand
{
public string? Name { get; set; }
}
public class CreateUserValidator : AbstractValidator<CreateUserCommand>
{
public CreateUserValidator()
{
RuleFor(x => x.Name).NotEmpty().WithMessage("A user needs a name. Even 'Bob' will do.");
}
}

Validation runs before your handler ever sees the request. Invalid data never touches your business logic. It’s like a bouncer for your code.

Want to know which handler is being a lazy bum? Performance logging middleware tracks execution time through built-in diagnostics via Microsoft.Extensions.Diagnostics. No extra setup, no third-party APM required. Your handlers are already emitting telemetry — you just need to listen.

This one deserves its own blog post (coming soon), but the short version: point Shiny Mediator at your OpenAPI spec and it generates contracts, handlers, JSON converters, and DI registration. No HttpClientFactory plumbing. No System.Text.Json serialization contexts. No Polly setup. Just:

<ItemGroup>
<MediatorHttp Include="MyApi"
Uri="https://myapi.com/openapi.json"
Namespace="MyApp.Api"
ContractPostfix="HttpRequest"
GenerateJsonConverters="true"
Visible="false" />
</ItemGroup>
services.AddShinyMediator(x => x.AddGeneratedOpenApiClient());
// Now just use it like any other mediator request
var users = await mediator.Request(new GetUsersHttpRequest());

All your middleware (caching, offline, validation, resilience) works with HTTP requests too. One attribute to cache API calls. One attribute for offline fallback. Your API client just became the most resilient thing in your entire codebase — and you wrote zero infrastructure code.

Enough talk. Let’s build something. I’ll walk you through a .NET MAUI setup since that’s where Shiny Mediator really flexes, but this works just as well with Blazor, ASP.NET, or plain old console apps (we don’t judge).

Terminal window
dotnet add package Shiny.Mediator
dotnet add package Shiny.Mediator.Maui

For Blazor, swap Shiny.Mediator.Maui for Shiny.Mediator.Blazor. For ASP.NET or console apps, just Shiny.Mediator on its own is fine.

Contracts are just plain C# records or classes that implement one of the mediator interfaces. Think of them as the “what” — what data goes in, what data comes out.

using Shiny.Mediator;
// A command - fire and forget, no return value
public record CreateTodoCommand(string Title, string Description) : ICommand;
// A request - send something in, get something back
public record GetTodosRequest(bool IncludeCompleted) : IRequest<List<TodoItem>>;
// An event - broadcast to anyone who's listening
public record TodoCreatedEvent(TodoItem Item) : IEvent;

Handlers are where the actual work happens. One handler per command/request, but you can have multiple event handlers.

using Shiny.Mediator;
[MediatorSingleton] // source generator handles DI registration for you
public class CreateTodoHandler : ICommandHandler<CreateTodoCommand>
{
readonly IMyDatabase db;
readonly IMediator mediator;
public CreateTodoHandler(IMyDatabase db, IMediator mediator)
{
this.db = db;
this.mediator = mediator;
}
public async Task Handle(
CreateTodoCommand command,
IMediatorContext context,
CancellationToken ct)
{
var item = new TodoItem(command.Title, command.Description);
await db.Insert(item, ct);
// broadcast that a todo was created
await mediator.Publish(new TodoCreatedEvent(item));
}
}
[MediatorSingleton]
public partial class GetTodosHandler : IRequestHandler<GetTodosRequest, List<TodoItem>>
{
readonly IMyDatabase db;
public GetTodosHandler(IMyDatabase db) => this.db = db;
[Cache(AbsoluteExpirationSeconds = 120)]
[OfflineAvailable]
public async Task<List<TodoItem>> Handle(
GetTodosRequest request,
IMediatorContext context,
CancellationToken ct)
{
// cached for 2 minutes AND available offline
// two attributes, zero infrastructure code, infinite smugness
return await db.GetTodos(request.IncludeCompleted, ct);
}
}
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp
.CreateBuilder()
.UseMauiApp<App>();
builder.Services.AddShinyMediator(x =>
{
x.UseMaui();
x.AddMemoryCaching();
x.AddDataAnnotations();
});
// let the source generator register everything
builder.Services.AddMediatorRegistry();
return builder.Build();
}
}
public class TodoListViewModel : BaseViewModel, IEventHandler<TodoCreatedEvent>
{
readonly IMediator mediator;
public TodoListViewModel(IMediator mediator)
{
this.mediator = mediator;
}
public async Task LoadTodos()
{
// one line. cached. offline-available. validated.
var response = await mediator.Request(new GetTodosRequest(IncludeCompleted: false));
this.Todos = response.Result;
}
public async Task CreateTodo(string title, string description)
{
await mediator.Send(new CreateTodoCommand(title, description));
// don't reload manually - the event handler below will fire
}
// this fires automatically when TodoCreatedEvent is published
// no subscription. no unsubscription. no memory leaks. no drama.
public async Task Handle(
TodoCreatedEvent @event,
IMediatorContext context,
CancellationToken ct)
{
await LoadTodos();
}
}

Notice that the ViewModel implements IEventHandler<TodoCreatedEvent> directly. With the MAUI extension, your ViewModels and Pages automatically participate in event broadcasting without being registered in DI. When the page is popped from navigation, it stops receiving events. No MessagingCenter.Unsubscribe nightmares. No WeakReferenceMessenger gymnastics. It just works.

Shiny Mediator has come a long way from its v1 days. We’re now on v6 with full AOT and trimming support baked into the source generators. So what’s next?

  • Deeper ASP.NET integration — we’ve already got minimal API endpoint generation from handlers, server-sent events from stream requests, and HTTP response caching middleware. Expect more first-class server scenarios.
  • Event throttling & middleware ordering — both landed in v6. [Throttle(300)] on your event handlers gives you debounce for free (goodbye, search-as-you-type headaches). [MiddlewareOrder] gives you explicit pipeline control.
  • Better tooling — finding the handler for a contract is still a navigation exercise. We’re exploring IDE integrations to make this seamless.
  • More source generation — the goal is zero reflection, zero runtime surprises. If a handler is missing or misconfigured, you should know at compile time, not when your app blows up in production at 2 AM on a Saturday.
  • Community contributions — shout out to codelisk for Prism region navigation and JeremyBP for MAUI modal stack iteration. Keep ‘em coming.

Shiny Mediator isn’t trying to replace MediatR. It’s trying to be the mediator that gives app developers — especially those on MAUI, Blazor, and Uno — a batteries-included experience that “just works” out of the box. Caching, offline, validation, HTTP clients, event broadcasting, source generated registration… all of it designed so you can focus on your actual business logic instead of plumbing.

Give it a spin. Check out the full docs, play with the sample apps, and come yell at me on BlueSky if something doesn’t work.

Happy coding. May your caches always be warm and your pipelines always be clean.

Source generation and full blown AOT. AOT is becoming such an important part of .NET, especially with .NET MAUI and Blazor. We’re working hard on a version 5 release that will be fully AOT compliant out of the box!