Publish with Native AOT

Publish a BackWave worker as a trimmed, Native AOT binary: what the source generator guarantees, how to enable PublishAot, and where your own dependencies are the real risk.


Publish a BackWave worker as a trimmed, Native AOT binary: what the source generator guarantees, how to enable PublishAot, and where your own dependencies are the real risk.

Native AOT compiles your worker ahead of time into a single self-contained native binary, with no JIT and no runtime code generation. For a dedicated worker service that is exactly the profile you want: fast cold start and a small image for serverless, scale-to-zero, or Kubernetes. BackWave suits this because it does its payload serialization and its Handler dispatch through compile-time generated code rather than reflection, so the library compiles and links under Native AOT. This guide covers why that holds, how to turn AOT on, how to read the trim warnings you will see, and which parts of BackWave belong outside the AOT worker.

If you have not read Jobs & Handlers yet, skim it first. It defines the source generator and the generated module this guide leans on.

Why BackWave is AOT-friendly#

The source generator reads every [Job] in your assembly at build time and emits straight-line serialization code — the explicit reader-and-writer JSON code you would otherwise hand-write — plus a generated module that wires each Job to its Handler. Dispatch resolves the Handler from dependency injection by closed generic type, not by reflecting over your Job types, and nothing in the core path builds an expression tree or activates a type by name at runtime. That is what makes the library trim- and AOT-clean: the wiring the trimmer needs to keep is already spelled out in generated code, so the code path a Job travels is fully visible to the compiler. The mechanics of that generated code live in Inside the Source Generator; for AOT purposes the point is only that BackWaveJobs.Module is ordinary compiled code by the time the trimmer sees it.

One consequence worth noting: because a Job routes by its Wire Name and the generated serializer, not by CLR type name, publishing under AOT does not change the wire format. A native worker and a JIT-compiled enqueue side read and write the same bytes.

If you use Job Output, you supply a small System.Text.Json source-generation context for your output shapes. That is not an AOT workaround — it is the AOT-safe pattern, and it keeps output serialization reflection-free the same way Job payloads are.

Enable it#

Start from a dedicated worker project (Microsoft.NET.Sdk.Worker) that references BackWave, the hosting package, one Storage Adapter, and the source generator — nothing web-facing. Turn AOT on with a single csproj property.

Worker.csproj
<Project Sdk="Microsoft.NET.Sdk.Worker">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <PublishAot>true</PublishAot>
  </PropertyGroup>
</Project>

Then publish against a runtime identifier:

dotnet publish -c Release -r <rid>

The publish runs native code generation and links a self-contained executable. On a verified osx-arm64 build the output was a single native binary around 11 MB with no separate runtime to ship. It is not, however, a silent build — see the next section for the trim warnings you should expect and how to read them.

Watch the globalization switch#

AOT size-trimming guides often suggest setting InvariantGlobalization=true. Be deliberate about it if you use Recurring Schedules with a time zone. A Recurring Schedule can opt into an IANA time zone, and BackWave resolves that id against the runtime's time zone data. Under invariant globalization — and on Windows, where IANA ids depend on ICU — that lookup can fail to find the id.

The failure mode is quiet: the schedule does not crash the Worker Group. It is surfaced as errored and its ticks are skipped, so a schedule that worked in development simply stops minting occurrences in production. Default UTC schedules are unaffected and need nothing. So the guidance is narrow: if any of your Recurring Schedules pin an IANA zone, do not blindly enable invariant globalization, and verify time zone resolution in the published binary rather than trusting the deploy to fail visibly.

Reading trim warnings#

The publish completes, but it is not warning-free. Expect a few IL2072 trim warnings pointing at BackWave's own worker registration — the dependency-injection wiring that registers your Handlers and their declaring classes. They are known, they do not stop the publish, and in a normal consumer app they stay warnings rather than errors. Do not read them as a sign the build failed.

The warnings that matter are the ones in your own handler dependencies. Triage in this order:

  • Your handler dependencies first. Anything reflection-based is the real risk, and EF Core is the common offender (covered below).
  • Serializers you added outside the generated path. A reflection-mode JsonSerializer call or a third-party serializer reintroduces exactly what the generator avoids.
  • Transitive packages last. Trace warnings back to the package that pulls in reflection or dynamic code.

Storage Adapters under AOT#

BackWave's relational Storage Adapters are ADO-based and reflection-free, with no EF and no reflective serialization in BackWave's own code. Postgres is the one that has been published and proven under AOT; the others share the same design but are worth verifying in your own build.

Storage AdapterAOT status
Postgres (Networked Adapter)Verified. Published under AOT with no trim warnings from the adapter or its driver stack. Wire it with PostgresJobStore and PostgresStoreOptions.
SQL Server (Networked Adapter)Same reflection-free ADO design. Its client library historically carries its own AOT caveats, so treat it as verify-in-your-build rather than proven.
SQLite (Embedded Adapter)Same ADO design over a pinned native SQLite. Architecturally equivalent to Postgres; verify in your build.
EF Core integrationNot for the AOT worker — see below.

Choosing an Adapter compares the stores on their own terms.

Keep EF Core out of the AOT worker#

The EF Core integration — Transactional Enqueue through a DbContext, exposed as BackWaveDbContextExtensions.EnqueueAsync — is realistically not AOT-friendly. EF Core requires dynamic code by design, and the package is not marked AOT-compatible. This is the one thing to keep out of your AOT worker. If you need Transactional Enqueue, route it through the plain ADO transactional path against a co-resident relational store, or keep that code in a non-AOT deployment. Transactional Enqueue with EF Core covers that path in full — read it as the boundary of what stays outside the native worker, not as an AOT-supported path.

What you give up#

Native AOT trades some capabilities for the smaller, faster binary. The honest costs, and where each moves to:

What you give upWhere it belongs instead
In-process Dashboard hostingYour non-AOT web app. The Dashboard renders Razor components and streams live updates over Server-Sent Events, neither of which is AOT-clean.
Transactional Enqueue through a DbContext (EF Core)Your non-AOT app, or the plain ADO transactional path — EF Core requires dynamic code by design.
Runtime reflection, dynamic code generation, and assembly loading in your own handlersNot available under AOT at all. This is the standard Native AOT constraint, not a BackWave one.

You keep the enqueue side, the store, and the ability to run Jobs; what you give up is the pieces that depend on dynamic code, and those have a natural home elsewhere.

Worker-only topology#

The split those constraints imply is already idiomatic BackWave. Publish a slim, dedicated worker binary as Native AOT — BackWave core, the hosting layer, one ADO Storage Adapter, and the generator — running one or more Worker Groups, each with its own Pumps. Keep the enqueue side and the Dashboard in your normal, non-AOT web app. Both processes share one store, so the web app enqueues and the native worker drains, with no shared code that would drag Razor or EF into the AOT graph. The AOT worker references neither the Dashboard nor EF, which is exactly why it stays clean.

This is also where BackWave diverges from tools like Hangfire, which dispatch through reflection and expression trees built at runtime: BackWave's compile-time generated registry is what lets the worker publish as native code at all. Migrating from Hangfire walks the rest of that move.

Where to go next#