DEV Community

Cover image for Polecat on SQL Server 2025: Production Spike Migration
ONDŘEJ PODHORNÝ
ONDŘEJ PODHORNÝ

Posted on Edited on Originally published at podhorny.dev

Polecat on SQL Server 2025: Production Spike Migration

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 both IDisposable and IAsyncDisposable.
  • Polecat: System.NotSupportedException — Polecat strictly does not support synchronous LINQ enumeration. You must use ToListAsync(...). Furthermore, IQuerySession and IDocumentSession implement 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>();

Enter fullscreen mode Exit fullscreen mode

💡 Legacy disposal note: If you have legacy infrastructure or libraries calling synchronous Dispose(), wrap the session in a DispatchProxy forwarding Dispose() to DisposeAsync().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);
}

Enter fullscreen mode Exit fullscreen mode

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();
}

Enter fullscreen mode Exit fullscreen mode

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() or CreateBatchQuery() 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 CancellationToken was frequently the first argument:
session.LoadManyAsync<T>(ct, id1, id2);

Enter fullscreen mode Exit fullscreen mode
  • Polecat: Modern BCL style placing cancellation tokens at the end with default values:
session.LoadManyAsync<T>(ids, ct);

Enter fullscreen mode Exit fullscreen mode

System.Text.Json Configuration

  • Marten: Configured via:
opts.UseSystemTextJsonForSerialization(EnumStorage.AsString, Casing.CamelCase, configure: ...);

Enter fullscreen mode Exit fullscreen mode
  • Polecat: Configured via:
opts.ConfigureSerialization(EnumStorage.AsString, casing: Casing.CamelCase, configure: ...);

Enter fullscreen mode Exit fullscreen mode

Startup Seeding (IInitialData)

  • Marten: marten.InitializeWith<MartenInitialData>() (Marten.Schema.IInitialData).
  • Polecat: opts.InitialData.Add(new PolecatInitialData()) (Polecat.IInitialData).
  • Detail in Polecat: AddPolecat unconditionally registers PolecatActivator (IHostedService), running InitialData seeders reliably on startup even without calling ApplyAllDatabaseChangesOnStartup().

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:

  1. Transition to CreateAsyncScope(): Hosted background services and startup bootstrapping paths were updated from synchronous CreateScope() to provider.CreateAsyncScope() to properly dispose asynchronous database sessions.
  2. Uncovered Hidden Synchronous LINQ Execution: Because Polecat throws System.NotSupportedException on sync enumeration, the spike immediately flagged legacy queries accidentally calling .ToList() instead of .ToListAsync().
  3. Closure Null-Checks Inside Where Predicates: 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);

Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)