I come from the SaaS world. Over the last half year I worked with client, and I introduced Inngest. It was super helpful there, and I think Inngest has big potential in the .NET world.
So I built an SDK for it second time to opensource latest feature to .net eco.
inngest-dotnet is a community Inngest SDK for .NET. It speaks the same protocol as Inngest's official SDKs, supports Inngest as it is, and follows .NET conventions: dependency injection, IOptions, ILogger and CancellationToken.
A function is a class
using Inngest;
public sealed record HelloRequested(string Name) : IInngestEventData
{
public static string EventName => "app/hello.requested";
}
[InngestFunction("say-hello")]
[EventTrigger<HelloRequested>]
public sealed class SayHello : InngestFunction<HelloRequested>
{
public override async Task<object?> ExecuteAsync(
FunctionContext<HelloRequested> context,
CancellationToken cancellationToken)
{
var greeting = await context.Step.RunAsync("compose-greeting",
() => $"Hello, {context.Event.Data.Name}!");
return new { greeting };
}
}
Register it, and map the endpoint Inngest calls:
var inngest = builder.Services
.AddInngest(options => options.AppId = "shop")
.AddFunction<SayHello>();
app.MapInngest("/api/inngest");
The snippets below go inside ExecuteAsync, or next to this registration, where inngest is the builder that AddInngest returns.
Write workflows
Steps that run once. A step's result is saved, so a later call gets it back instead of running the code again.
var paymentId = await context.Step.RunAsync("charge",
ct => payments.ChargeAsync(order.CustomerId, order.Total, ct));
Sleep and wait for events. Pause for minutes or days, or until an event arrives, without holding a thread or a worker.
await context.Step.SleepAsync("cool-off", TimeSpan.FromMinutes(30));
var activated = await context.Step.WaitForEventAsync<UserActivated>(
"activation", TimeSpan.FromDays(3));
Parallel steps. Start steps together and await them with Task.WhenAll; Inngest runs each in its own call.
await Task.WhenAll(userIds.Select(id =>
context.Step.RunAsync($"load-{id}", ct => profiles.LoadAsync(id, ct))));
Fan-out over a list. A step per item, with a limit on how many run at once and the results back in order.
await context.Step.MapAsync("charge", due,
(invoice, ct) => billing.ChargeAsync(invoice.Id, ct),
new FanOutOptions { MaxParallelism = 5 });
Invoke other functions. Run another function as a step and get its typed result back.
var score = await context.Step.InvokeAsync<ScoreAccount, AccountScore>(
"score-acme", new ScoreRequest("acme"));
Scoped steps. A step with a dependency injection scope of its own, so a DbContext carries no state from one step to the next.
await context.Step.RunScopedAsync<IInventory>("reserve",
(inventory, ct) => inventory.ReserveAsync(orderId, ct));
Event and cron triggers. Start a function from a typed event, a filtered event or a cron schedule.
[EventTrigger<OrderPlaced>(If = "event.data.total >= 500")]
[CronTrigger("0 6 * * 1-5", Timezone = "UTC")]
Flow control. Concurrency, throttling, rate limits, debounce, batching, priority and singletons are attributes on the function.
[Throttle(10, "1m")]
[Concurrency(1, Key = "event.data.accountId")]
[Debounce("30s", Key = "event.data.accountId")]
Errors and retries. Every step retries on its own. NonRetriableException stops the retries, and RetryAfterException sets the delay.
throw new RetryAfterException("Rate limited.", TimeSpan.FromMinutes(1));
Realtime and AI
Realtime. Publish updates and streams of text from a run or from any code, and relay them to browsers or receive them in .NET.
var status = new RealtimeTopic<string>($"order:{orderId}", "status");
await realtime.PublishAsync(status, "Charged");
AI chat clients. The model calls and tool calls of a Microsoft.Extensions.AI IChatClient run as steps, so a retry or a replay never repeats them.
using Microsoft.Extensions.AI;
builder.Services.AddChatClient(model)
.UseInngestFunctionInvocation()
.UseInngestSteps();
Blazor. Show realtime messages in Blazor Server, WebAssembly and interactive Auto with the RealtimeView component.
builder.Services.AddInngestRealtimeServerSource();
SignalR. Forward realtime messages to the clients of a SignalR hub, and decide who may join which channel.
builder.Services.AddInngestSignalR(options => options.Authorize = authorize);
app.MapInngestRealtimeHub("/hubs/inngest");
Run it anywhere
Workers without an endpoint. With Connect, a worker holds an outbound WebSocket to Inngest and needs no public URL.
inngest.AddConnect(connect => connect.MaxWorkerConcurrency = 20);
Durable endpoints (experimental). Run steps in an ASP.NET Core endpoint, answer the caller directly, and hand the run to Inngest when a step has to wait or fails. Inngest calls the feature beta, so the API may still change.
app.UseInngestDurableEndpoints();
app.MapPost("/pay", (IStepTools step) =>
step.RunAsync("charge", () => "paid"))
.WithDurableEndpoint();
Azure Functions. Serve the endpoint from an HTTP-triggered function in the isolated worker model.
using Microsoft.Azure.Functions.Worker.Http;
return await request.ServeInngestAsync();
AWS Lambda. Serve it from a Lambda function behind API Gateway or a function URL, as a managed function or a Native AOT executable.
var handler = new InngestLambdaHandler(services);
return await handler.HandleAsync(request, context);
Native AOT. Publish trimmed or as a Native AOT binary by giving the SDK source-generated JSON metadata for your types.
using System.Text.Json.Serialization;
[JsonSerializable(typeof(OrderPlaced))]
partial class ShopEventJson : JsonSerializerContext;
Integrations
Send events with IInngestClient, from application code or from a function as a step, with ids for deduplication and delayed starts.
await client.SendAsync(new OrderPlaced("o-1001", "c-7", 49.90m));
Webhooks. Verify the signature of a webhook from Stripe, GitHub, Shopify, Clerk or Resend, and send it as an event whose id is the delivery id.
app.MapInngestWebhook("/webhooks/stripe", WebhookProvider.Stripe(secret));
Transactional outbox. Save an event in the same Entity Framework Core transaction as your data, and a hosted dispatcher sends it to Inngest afterwards.
using Microsoft.EntityFrameworkCore;
db.AddInngestEvent(placed);
await db.SaveChangesAsync(cancellationToken);
User context. Carry the signed-in user who caused a run into it, as ICurrentUser or a ClaimsPrincipal, protected with ASP.NET Core Data Protection.
inngest.AddUserContext(options => options.Claims = ["sub", "role"]);
inngest.AddUserContextFromHttpContext();
Middleware. Observe and wrap function runs, steps and event sends, for logging, metrics, tracing and error mapping.
inngest.AddMiddleware<TimingMiddleware>();
Encryption. Encrypt event data, step results and function output, in the format of the TypeScript SDK's encryption middleware.
inngest.AddEncryption(options => options.Key = encryptionKey);
Sentry. With Sentry set up as usual, report the errors of functions and steps, tagged with the app, the function, the run and the event.
inngest.AddSentryReporting();
OpenTelemetry. Trace every call and step, and measure runs, retries and durations.
using OpenTelemetry.Trace;
inngest.AddInstrumentation();
tracing.AddInngestInstrumentation();
Test and ship
Testing. Run a function in memory on the real execution engine, with mocked steps.
using Inngest.Testing;
var mocks = new StepMocks().Run("charge", "ok");
var run = await host.RunAsync("checkout", orderPlaced, mocks);
Testing with containers. Start the Inngest dev server or a self-hosted server in Docker from your tests, and run your app against it.
using Inngest.Testing.Containers;
var server = new InngestServerBuilder("inngest/inngest:v1.49.0").Build();
await server.StartAsync();
Aspire. Run the Inngest dev server as a container of your AppHost, with its UI linked in the Aspire dashboard.
var devServer = builder.AddInngest("inngest");
builder.AddProject<Projects.Shop>("shop")
.WithReference(devServer).WaitFor(devServer);
Source generator and analyzers. Turn on the generator, and one call registers every function class of the project. An analyzer finds the types a Native AOT app has not listed.
inngest.AddInngestFunctions();
Try it
dotnet new install Inngest.Templates
dotnet new inngest-web -n Shop --http-port 5280
cd Shop && dotnet run
In a second terminal in the same folder, start the Inngest dev server:
dotnet tool install --global dotnet-inngest
dotnet inngest dev
Then send an event with curl -X POST http://localhost:5280/hello/Ada and watch the run at http://localhost:8288.
Links
- 📖 Docs: https://damikun.github.io/inngest-dotnet/
- 💻 GitHub: https://github.com/damikun/inngest-dotnet
- 📦 NuGet: https://www.nuget.org/packages/Inngest
damikun
/
inngest-dotnet
Inngest SDK for .NET. Write Inngest functions as plain C# classes in your ASP.NET Core app, with realtime updates and the .NET ecosystem you already use. .NET 8 and 10, Native AOT.
Feedback is very welcome, from .NET developers and from the Inngest team: open an issue on GitHub or leave a comment here. If you've wanted Inngest in .NET, a ⭐ on GitHub helps others find it.
What would you build with it?
Top comments (0)