For .NET teams tied to Microsoft SQL Server who want the developer velocity of document databases and Wolverine messaging, Polecat on SQL Server 2025 is a massive leap forward. Here is what we learned from migrating a production spike.
If your team is committed to PostgreSQL, Marten remains the gold standard of .NET document databases and event sourcing. But what about enterprise environments where SQL Server is mandatory? Until SQL Server 2025 introduced native binary JSON (JSON_TYPE, JSON indexes), running a high-performance document store on SQL Server felt like an uphill battle.
Polecat brings the Critter Stack document model directly to SQL Server 2025. In our production spike migration, it delivered where it counts. But moving between the two engines reveals distinct architectural refinements.
Spike Migration Matrix
| Subsystem | Status | Spike Finding & Architecture Impact |
|---|---|---|
| SQL Server 2025 Aspire Host | ✅ OK | Smooth local orchestration out of the box via .NET Aspire container images |
| WolverineFx.Polecat Outbox | ✅ OK | Transactional outbox pattern and message dispatch swapped with zero friction |
| LINQ Query Suite | ⚠️ Refactor | Predicates require concrete members; interface-constrained filters throw BadLinqExpressionException |
| DI Scopes (FastEndpoints) | ⚠️ Refactor | Sessions implement only IAsyncDisposable; synchronous CreateScope() fails on Dispose |
Batch Queries (IBatchedQueryable) | ⚠️ Refactor | Does not inherit from IQueryable; Skip, Take, and OrderBy must run as standalone queries |
While the matrix above highlights high-level audit results, the real insights emerged in the implementation details. Moving our production spike across database boundaries forced us to confront strict runtime constraints, query compilation subtleties, and architectural refinements between the two engines.
Here is the deep-dive into each technical finding and how we resolved it.
1. Strict Asynchrony & DI Synchronous Scope Trap
One of the most critical runtime differences between Marten and Polecat lies in lifecycle disposal and asynchrony.
- Marten: Supported synchronous execution (
.ToList()) and hybrid disposable sessions implementing bothIDisposableandIAsyncDisposable. - Polecat:
System.NotSupportedException— Polecat strictly does not support synchronous LINQ enumeration. You must useToListAsync(...). Furthermore,IQuerySessionandIDocumentSessionimplement only `IAsyncDisposable`.
// ❌ The Real-World Trap (FastEndpoints & ASP.NET DI): // Synchronous scope creation fails on Dispose: using var scope = app.Services.CreateScope(); var session = scope.ServiceProvider.GetRequiredService<IQuerySession>(); // Throws on scope exit: // System.InvalidOperationException: 'QuerySession' type only implements IAsyncDisposable. // ✓ Workaround / Fix: // In hosted services and startup paths, transition to CreateAsyncScope: await using var asyncScope = app.Services.CreateAsyncScope(); var asyncSession = asyncScope.ServiceProvider.GetRequiredService<IQuerySession>(); 💡 Legacy disposal note: If you have legacy infrastructure or libraries calling synchronous
Dispose(), wrap the session in aDispatchProxyforwardingDispose()toDisposeAsync().GetAwaiter().GetResult().
2. Narrower LINQ Expression Parser (BadLinqExpressionException)
Marten’s LINQ provider has enjoyed over 10 years of edge-case normalization. Polecat’s WhereClauseParser is leaner and stricter: it requires predicates to directly compare a concrete document member against a constant or value.
// ❌ Generic filter extensions constrained by an interface: public static IQueryable<T> ApplyScopeFilters<T>(this IQueryable<T> query, string tenant) where T : IScopeFilterable { return query.Where(x => x.TenantId == tenant); } // Throws in Polecat: // Polecat.Linq.BadLinqExpressionException: neither side resolves to a document member compared against a constant. // ✓ Fix: Constrain to concrete base class or concrete overloads public static IQueryable<T> ApplyScopeFilters<T>(this IQueryable<T> query, string tenant) where T : DocumentBase { return query.Where(x => x.TenantId == tenant); } 3. Batch Querying & IBatchedQueryable<T>
Both libraries provide session.CreateBatchQuery(), combining multiple independent queries into a single database roundtrip.
- Marten (Mature):
IBatchedQueryable<T>inherits from `IQueryable(Where,OrderBy,Skip,Take,Select,Stats`). - Polecat (Native-AOT Lean):
IBatchedQueryable<T>does NOT inherit from `IQueryable`. It exposes a lean interface tailored strictly for batch retrieval:
public interface IBatchedQueryable<T> { IBatchedQueryable<T> Where(Expression<Func<T, bool>> filter); Task<IReadOnlyList<T>> ToList(); Task<int> Count(); Task<bool> Any(); Task<T?> FirstOrDefault(); } Impact on Migration
Extension methods written on IQueryable<T> will not compile on batch.Query<T>(). Paged or projected queries requiring OrderBy, Skip, Take, or Select must run as standalone async queries outside the batch.
4. API Surface & Architecture Differences
Polecat modernizes several legacy patterns accumulated by Marten:
Namespace Separation
- Marten: Extension methods like
.ToListAsync()orCreateBatchQuery()lived in the monolithic root namespace. - Polecat: Modular namespace design:
- Async LINQ operators live in
using Polecat.Linq;(ToListAsync,FirstOrDefaultAsync). - Batching types live in
using Polecat.Batching;. - Enum serialization and DDL types live in
using Weasel.Core;.
Method Signatures & CancellationToken
- Marten: Legacy convention where
CancellationTokenwas frequently the first argument:
session.LoadManyAsync<T>(ct, id1, id2); - Polecat: Modern BCL style placing cancellation tokens at the end with default values:
session.LoadManyAsync<T>(ids, ct); System.Text.Json Configuration
- Marten: Configured via:
opts.UseSystemTextJsonForSerialization(EnumStorage.AsString, Casing.CamelCase, configure: ...); - Polecat: Configured via:
opts.ConfigureSerialization(EnumStorage.AsString, casing: Casing.CamelCase, configure: ...); Startup Seeding (IInitialData)
- Marten:
marten.InitializeWith<MartenInitialData>()(Marten.Schema.IInitialData). - Polecat:
opts.InitialData.Add(new PolecatInitialData())(Polecat.IInitialData). - Detail in Polecat:
AddPolecatunconditionally registersPolecatActivator(IHostedService), runningInitialDataseeders reliably on startup even without callingApplyAllDatabaseChangesOnStartup().
5. What Was Our Own Code & Migration Debt?
The migration spike acted as an invaluable mirror, surfacing hidden technical debt that Marten's permissive nature had masked:
- Transition to
CreateAsyncScope(): Hosted background services and startup bootstrapping paths were updated from synchronousCreateScope()toprovider.CreateAsyncScope()to properly dispose asynchronous database sessions. - Uncovered Hidden Synchronous LINQ Execution: Because Polecat throws
System.NotSupportedExceptionon sync enumeration, the spike immediately flagged legacy queries accidentally calling.ToList()instead of.ToListAsync(). - Closure Null-Checks Inside
WherePredicates: Polecat translates(key == null)into a SQL column comparison rather than evaluating it locally in C#:
// ❌ Fails: Polecat tries translating (key == null) into a SQL column comparison var run = await session.Query<T>() .FirstOrDefaultAsync(p => p.OrganizationId == orgId && (key == null || p.Key == key), ct); // ✓ Fix: Branch in C# before dispatching the query var query = session.Query<T>().Where(p => p.OrganizationId == orgId); if (!string.IsNullOrEmpty(key)) { query = query.Where(p => p.Key == key); } var run = await query.FirstOrDefaultAsync(ct); Spike Verdict: Polecat is Production-Ready
Polecat is a real step forward for .NET teams tied to SQL Server 2025: native binary JSON, JSON indexes, and the Critter Stack (documents, Wolverine, schema generation) on an engine many enterprises already run.
In our production migration it delivered where it counts — indexing, async LINQ, and a straightforward Wolverine swap. If PostgreSQL is off the table, Polecat is the first credible document story on SQL Server without feeling like a hack.
