Skip to content

Source generator and analyzers

The Inngest package carries a source generator, two analyzers and a code fix. The compiler loads them from the package, so there is nothing more to install: every project that references Inngest, directly or through Inngest.AspNetCore or Inngest.Connect, has them. They are built against Roslyn 4.8, the compiler of the first .NET 8 SDK and of Visual Studio 17.8, and run in that compiler and in every newer one.

  • The generator writes AddInngestFunctions(), which registers the function classes of the project. A project turns it on; until then it writes nothing, and the project builds as it did before.
  • An analyzer, with the generator, says why a class with [InngestFunction] is not registered.
  • The other analyzer, in an app that turns reflection-based JSON off, finds the types that no JsonSerializerContext lists. A code fix lists them.

Turn the generator on in the project file:

Shop.csproj
<PropertyGroup>
<InngestGenerateFunctionRegistration>true</InngestGenerateFunctionRegistration>
</PropertyGroup>

It is off by default so that a project which registers its classes by hand, or has a method of its own named AddInngestFunctions, is not changed by an upgrade. Then give each function class [InngestFunction], and register all of them with one call:

Functions.cs
using Inngest;
public sealed record OrderPlaced(string OrderId) : IInngestEventData
{
public static string EventName => "shop/order.placed";
}
[InngestFunction("charge-order", Retries = 3)]
[EventTrigger<OrderPlaced>]
public sealed class ChargeOrder : InngestFunction<OrderPlaced>
{
public override async Task<object?> ExecuteAsync(FunctionContext<OrderPlaced> context, CancellationToken cancellationToken)
{
await context.Step.RunAsync("charge", () => context.Logger.LogInformation("Charging {OrderId}", context.Event.Data.OrderId));
return null;
}
}
[InngestFunction("send-receipt")]
[EventTrigger<OrderPlaced>]
public sealed class SendReceipt : InngestFunction<OrderPlaced>
{
public override Task<object?> ExecuteAsync(FunctionContext<OrderPlaced> context, CancellationToken cancellationToken) =>
Task.FromResult<object?>(null);
}
Program.cs
using Inngest;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddInngest(options => options.AppId = "shop")
.AddInngestFunctions();
var app = builder.Build();
app.MapInngest();
app.Run();

AddInngestFunctions() is the same as writing AddFunction<ChargeOrder>() and AddFunction<SendReceipt>(), for every class. The compiler finds the classes while it builds the project, from the attribute, so there is no assembly scanning at run time and nothing for the trimmer to keep. What the generator writes for the project above, without the comments and the GeneratedCode attribute:

#pragma warning disable CS0612, CS0618
namespace Inngest
{
internal static class InngestFunctionRegistration_Shop
{
internal static global::Inngest.IInngestBuilder AddInngestFunctions(this global::Inngest.IInngestBuilder builder)
{
global::System.ArgumentNullException.ThrowIfNull(builder);
global::Inngest.InngestBuilderExtensions.AddFunction<global::ChargeOrder>(builder);
global::Inngest.InngestBuilderExtensions.AddFunction<global::SendReceipt>(builder);
return builder;
}
}
}

The order of the calls is the order of the classes’ names, so adding a file never reorders them. The method is in the Inngest namespace, with AddFunction, and it returns the builder, so more registrations can follow it. The file disables the warnings for obsolete code, so that a function class marked [Obsolete] can be registered without a warning in a file you cannot edit.

AddFunction<T>() still reads the attributes of each class when the app starts, as it does when you call it yourself. The generator decides which classes to register, not what their options are.

The method registers a class that has [InngestFunction], derives from InngestFunction<TEventData> or InngestFunction<TEventData, TResult>, and that generated code in the same project can create and name. A class that does not fit is left out, and the project builds with a warning on it that says why. These warnings are about the generated method, so a project that has not turned generation on gets none of them: a generic class it closes by hand, or a private class it registers from inside the class that holds it, is not a mistake there.

Diagnostic The class
INNGEST001 has the attribute but is not a function: it does not derive from InngestFunction<TEventData> or InngestFunction<TEventData, TResult>.
INNGEST002 is abstract.
INNGEST003 is generic, or inside a generic class.
INNGEST004 is private, protected or private protected, or file-local, or inside a type that is. A protected internal class is registered.
INNGEST005 has no public constructor.

Public and internal classes are registered, nested ones too, in any namespace or none. A class registered in code with FunctionOptions needs no attribute, and so is not in the generated method.

Each project that turns generation on gets a method of its own, for its own classes, in the namespace Inngest. The method is internal unless the project says otherwise, which keeps it from meeting the one in the project that references it, but two cases make a call ambiguous (CS0121), and the compiler says so:

  • A project that already has a method of its own called AddInngestFunctions(this IInngestBuilder), such as a helper that registers its functions, meets the generated one as soon as it has a class with [InngestFunction].
  • A project that another project sees the internals of, through InternalsVisibleTo, such as a library and the test project of the app, shares the library’s internal method with it. The test project’s own method is then ambiguous with the library’s.

InngestFunctionsMethodName renames the generated method, in either project. To register a library’s functions in the app, give the library a name of its own and make the method public:

Shop.Billing.csproj
<PropertyGroup>
<InngestGenerateFunctionRegistration>true</InngestGenerateFunctionRegistration>
<InngestFunctionsMethodName>AddBillingFunctions</InngestFunctionsMethodName>
<InngestFunctionsAccessibility>public</InngestFunctionsAccessibility>
</PropertyGroup>
Program.cs
builder.Services
.AddInngest(options => options.AppId = "shop")
.AddInngestFunctions()
.AddBillingFunctions();

Two public methods with the same name in the same namespace are ambiguous in the same way, so a public method needs a name of its own. A library that keeps the default internal method can instead register its functions in a public method of its own: public static IInngestBuilder AddBillingFunctions(this IInngestBuilder builder) => builder.AddInngestFunctions();.

The class that holds the method is named after the assembly. Characters an identifier cannot hold become underscores, and a name that had any also gets a short hash of the assembly’s name, so that Shop-Billing and Shop.Billing do not become the same class.

The MSBuild properties:

Property Default Meaning
InngestGenerateFunctionRegistration false true writes the method and reports the classes it leaves out.
InngestFunctionsMethodName AddInngestFunctions The name of the generated method. It must be a C# identifier.
InngestFunctionsAccessibility internal public or internal, for the class and the method.
DisableInngestAnalyzers false true takes everything the package runs in the compiler out of the build: the generator and both analyzers.

A value the generator cannot use is reported as INNGEST006, and the default is used.

  • It registers the classes of the project it compiles, not those of the projects it references. Each of those has its own method, or none.
  • It leaves no class out on request. A class that has the attribute is registered by the method, and a second registration by hand fails when the app starts, because the function id is used twice. A project with a function that needs AddFunction<T>(configure) does not turn generation on, and registers all its functions by hand.
  • It does not compute the options at compile time. They are read from the attributes when the app starts.

An app that is published with Native AOT or trimmed has no reflection-based JSON, so every type that Inngest reads or writes for your functions must be listed by a source-generated JsonSerializerContext. A type that is not fails when the function runs, with a NotSupportedException. The Native AOT guide explains the context. The analyzer finds the types that are missing while the project builds.

It runs when the project sets PublishAot or PublishTrimmed to true, or JsonSerializerIsReflectionEnabledByDefault to false. A project that sets JsonSerializerIsReflectionEnabledByDefault to true is not checked. In any other project the analyzer does nothing. It reads the properties of the project it compiles, and a library does not publish: the function classes of a library that sets none of them are not checked, even when the app that uses the library is published with Native AOT. Set JsonSerializerIsReflectionEnabledByDefault to false in the library to have it checked. The property only matters to an app, so it changes nothing else in a library.

It looks at:

  • the event data and the result type of every function class, that is, the TEventData and TResult of its base class;
  • the event data type of a function registered with AddFunction<TEventData>(options, handler);
  • the type that a step saves with the run and reads back: the T of RunAsync<T>, the input and result of AIWrapAsync, the data that WaitForEventAsync<T> and WaitForSignalAsync<T> wait for, the result of InvokeAsync, the response of InferAsync<T> and the data PublishAsync<T> publishes.

A type counts as listed when a context lists it with [JsonSerializable], or when a type that a context lists reaches it the way the generator of System.Text.Json does when it writes the metadata of that type:

  • through a public property or field, or one that is not public but has [JsonInclude], unless it has [JsonIgnore]; a private, internal or protected member without [JsonInclude], a static member and an explicit interface implementation are not part of the metadata;
  • through a parameter of the constructor it would call: the one marked [JsonConstructor], or the only public one when there is no public constructor without parameters. The parameters of any other constructor are not reached;
  • as the element type of a collection, or a type argument of a framework generic such as Dictionary<K, V>. A class that is a collection, such as class Items : List<Line>, is written as its elements, and its own members and constructors are not part of the metadata;
  • as a derived type named by [JsonDerivedType].

A type with a [JsonConverter] of its own is written by the converter, and nothing is reached through its members. The context can be in the project or in a project it references. The context that the SDK adds to the serializer options of every app is counted too, so FunctionFailure, SendEventsResult, JsonElement, object and what those hold, such as string, are never asked for. An anonymous type cannot be listed at all, so it gets a message of its own.

The analyzer’s idea of what is listed is checked against the generator of System.Text.Json, of .NET 8 and .NET 10, by the tests of this package.

Functions.cs
using System.Text.Json.Serialization;
using Inngest;
public sealed record Refund(string OrderId);
public sealed record Receipt(string Id);
[InngestFunction("refund")]
public sealed class OnRefund : InngestFunction<Refund, Receipt>
{
public override Task<Receipt> ExecuteAsync(FunctionContext<Refund> context, CancellationToken cancellationToken) =>
Task.FromResult(new Receipt(context.Event.Data.OrderId));
}
[JsonSerializable(typeof(Refund))]
internal sealed partial class ShopJson : JsonSerializerContext;

With PublishAot on, the project above builds with a warning on Receipt:

warning INNGEST010: 'Receipt' is the result of function 'OnRefund', but no JsonSerializerContext lists it with
[JsonSerializable], and reflection-based JSON is turned off

The code fix, in the IDE, adds [JsonSerializable(typeof(Receipt))] to a context of the project, and the using directive the type needs if the file lacks it. It offers one fix for each context the project has. Fixing all occurrences in a document, a project or a solution adds every missing type to the chosen context in one edit, each type once. It offers nothing when the project has no context: declare one first, as the Native AOT guide shows.

Why the generator does not list the types itself

Section titled “Why the generator does not list the types itself”

The generator knows these types, so it could write the [JsonSerializable] attributes. It cannot: the generator of System.Text.Json reads the attributes of your source, and a source generator’s output is not input to another generator. Code that Inngest’s generator added to the context would not be seen. The code fix writes the attribute into your own file instead.

  • Values whose type is only known at run time, such as an object that holds a record. That includes the data that InvokeAsync sends, the events your code sends and the body of an InferAsync request. List those types yourself, as the Native AOT guide says.
  • Typed realtime topics, and publishing or subscribing through IInngestRealtime, which read their type through the same options. The data that a step publishes is checked.
  • Contexts that are built in code, and options whose resolver chain does not contain the context. Test the paths that matter after you publish.

Every diagnostic has an id that .editorconfig can configure, such as dotnet_diagnostic.INNGEST010.severity = none, and #pragma warning disable works on the class or the call the diagnostic is on. DisableInngestAnalyzers removes them all, with the generator, for the case that one of them gets in the way of a build.

Warning. A class has [InngestFunction] but does not derive from InngestFunction<TEventData> or InngestFunction<TEventData, TResult>. The attribute does nothing on any other class: the class cannot be registered, by AddFunction<T>() or by the generated method, and Inngest never runs it. Add the base class, or remove the attribute. Reported in a project that has turned generation on.

Warning. The class is abstract. The service provider cannot create it, and [InngestFunction] is not inherited, so the classes that derive from it are not registered either. Put the attribute on each concrete class.

Warning. The class is generic, or inside a generic class. The generator does not know which type arguments the app uses. Register the closed types with AddFunction<T>(), or suppress the warning on a class that is only a template.

Warning. The class, or a type that contains it, is private, protected, private protected or file-local. The generated method is outside it. Make the class public or internal, with every type that contains it, or register it with AddFunction<T>() from inside the containing type.

Warning. The class has no public constructor. The service provider calls public constructors only, so every call of the function would fail. Add one.

Warning. InngestGenerateFunctionRegistration is neither true nor false, InngestFunctionsMethodName is not a C# identifier, or InngestFunctionsAccessibility is neither public nor internal. The default is used.

Warning. A type that Inngest serializes for a function is missing from the JsonSerializerContext, in a project that turns reflection-based JSON off. Add [JsonSerializable(typeof(...))] for it to a context, which the code fix does.

Warning. The type is anonymous, which no context can list. Use a record, a class or a struct, and list it.

inngest-dotnet is a community Inngest SDK for .NET. It is not affiliated with or endorsed by Inngest Inc.