# Functions, event triggers and cron in C#

> Define Inngest functions as classes or delegates, with their options as attributes or in code, type their events, and start them from events, filtered events or cron schedules.

Web page: https://damikun.github.io/inngest-dotnet/guides/functions-and-triggers/

A function is code that Inngest runs for you, plus options that tell Inngest when and how to run it. The code is
an ordinary .NET method. The options are `FunctionOptions`, which you write in code when you register the function,
or as attributes on its class. This page covers the code side and the triggers. Flow control, retries and the other
options have pages of their own.

## Functions as classes

Derive from `InngestFunction<TEventData>`, where `TEventData` is the type of the triggering event's data:

```csharp title="ChargeOrder.cs"
using Inngest;

public sealed record OrderPlaced(string OrderId, string CustomerId, decimal Total) : IInngestEventData
{
    public static string EventName => "shop/order.placed";
}

public sealed class ChargeOrder(IOrderStore orders, IPaymentGateway payments) : InngestFunction<OrderPlaced>
{
    public override async Task<object?> ExecuteAsync(FunctionContext<OrderPlaced> context, CancellationToken cancellationToken)
    {
        var order = context.Event.Data;

        var paymentId = await context.Step.RunAsync("charge",
            ct => payments.ChargeAsync(order.CustomerId, order.Total, ct));

        await context.Step.RunAsync("mark-paid",
            ct => orders.MarkPaidAsync(order.OrderId, paymentId, ct));

        return new { order.OrderId, paymentId };
    }
}

public interface IOrderStore
{
    Task MarkPaidAsync(string orderId, string paymentId, CancellationToken cancellationToken);
}

public interface IPaymentGateway
{
    Task<string> ChargeAsync(string customerId, decimal amount, CancellationToken cancellationToken);
}
```

Put the options on the class as attributes and register it with `AddFunction<TFunction>`, or pass the options to
`AddFunction` in code:

### Attributes

```csharp
[InngestFunction("charge-order", Name = "Charge an order")]
[EventTrigger<OrderPlaced>]
```

```csharp title="Program.cs"
builder.Services
    .AddInngest(options => options.AppId = "shop")
    .AddFunction<ChargeOrder>();
```

`[InngestFunction]` on the class gives the function its id, and optionally a name, retries and an idempotency key.
The other attributes add triggers and flow control. `AddFunction` reads them once, when the app starts. With many
classes, the [source generator](https://damikun.github.io/inngest-dotnet/guides/source-generator.md) can write `AddInngestFunctions()`, which
registers every class that has `[InngestFunction]`, once the project turns it on.

### Code first

```csharp title="Program.cs"
builder.Services
    .AddInngest(options => options.AppId = "shop")
    .AddFunction<ChargeOrder>(new FunctionOptions
    {
        Id = "charge-order",
        Name = "Charge an order",
        Triggers = [Trigger.Event<OrderPlaced>()],
    });
```

`Id` and `Triggers` are required. The name, retries, flow control and the other options are optional.

The SDK resolves a new instance from a dependency injection scope for every call Inngest makes, and disposes the
scope when the call ends. Scoped services such as an Entity Framework `DbContext` work as they do in a controller.
The scope is the call's, not a step's; [Scoped steps](https://damikun.github.io/inngest-dotnet/guides/scoped-steps.md) gives a step a scope of its
own. You do not need to register the class yourself; `AddFunction` adds it as a transient service if it is not there.

`ExecuteAsync` returns the function's output, which Inngest stores and shows with the run. Return `null` when there
is nothing to return. The output is serialized to JSON, like everything a function hands to Inngest. To give the
output a type, derive from `InngestFunction<TEventData, TResult>` instead; a function that
[invokes](https://damikun.github.io/inngest-dotnet/guides/invoking-functions.md) this one then receives a `TResult`.

## Code first or attributes

Both forms say the same things. Each attribute sets the `FunctionOptions` member of the same name, and
[function options](https://damikun.github.io/inngest-dotnet/explainers/function-options.md) maps one to the other. As attributes, the options
sit on the class, next to the code they describe, and take durations as time strings such as `"30s"`. In code,
they sit with the registration, can use values the app reads at startup and take durations as `TimeSpan`s. These
docs show attributes first; choosing **Code first** in one of the tabs switches the tabs on every page to that form.

A function's options come from one place: options passed in code replace the class's attributes. To start from the
attributes and change some of the options in code, for example a limit read from configuration, pass a callback:

```csharp
var limit = builder.Configuration.GetValue("Billing:ChargeConcurrency", 10);

inngest.AddFunction<ChargeOrder>(options => options with { Concurrency = [new ConcurrencyLimit(limit)] });
```

The callback is also where function middleware and checkpointing go for a class with attributes, since they have
no attributes.

### Attributes of your own

Every attribute but `[InngestFunction]` derives from `FunctionOptionsAttribute`, and an attribute of your own can
too. It overrides `Configure` and returns the options with what it sets, which packages options that several
functions share, such as the limits of an API they all call:

```csharp title="PaymentProviderAttribute.cs"
using Inngest;

// One charge per customer at a time, and more retries, for every function that calls the payment provider.
public sealed class PaymentProviderAttribute : FunctionOptionsAttribute
{
    protected override FunctionOptions Configure(FunctionOptions options) =>
        options with
        {
            Retries = 10,
            Concurrency = [.. options.Concurrency ?? [], new ConcurrencyLimit(1) { Key = "event.data.customerId" }],
        };
}
```

```csharp
[InngestFunction("charge-order", Name = "Charge an order")]
[EventTrigger<OrderPlaced>]
[PaymentProvider]
```

`AddFunction` starts from `[InngestFunction]` and applies the other attributes in ascending `Order`, which is 0
unless an attribute sets it, and those with the same `Order` in the order they are declared. Each one sees what the
earlier ones set and replaces it where it sets the same member, so `[PaymentProvider]` replaces any `Retries` given
on `[InngestFunction]`.

## Typed events

`OrderPlaced` implements `IInngestEventData`, which puts the event's name on the record that carries its data. Code
then names the type instead of repeating the string: `Trigger.Event<OrderPlaced>()` and `[EventTrigger<OrderPlaced>]`
trigger on it, `SendAsync(new OrderPlaced(...))` [sends it](https://damikun.github.io/inngest-dotnet/guides/sending-events.md), and
[waits](https://damikun.github.io/inngest-dotnet/guides/sleep-and-wait-for-events.md) and
[cancellations](https://damikun.github.io/inngest-dotnet/guides/flow-control.md#cancel-on-events) can name it too. A mistyped name becomes a
compile error, and nothing can send `UserDeleted` data under the name of `UserSignedUp`.

`EventName` and the optional `EventVersion` are static members, which the SDK reads without reflection, so typed
events work under Native AOT. Every method that takes a type also has a form that takes the name as a string.

## Functions as delegates

Small functions can be lambdas, with their options in code. Without a type argument the event data is a
`JsonElement`:

```csharp
inngest.AddFunction(new FunctionOptions { Id = "hello", Triggers = [Trigger.Event("demo/hello")] },
    async (context, cancellationToken) =>
    {
        var name = context.Event.Data.GetProperty("name").GetString();
        return await context.Step.RunAsync("greet", () => $"Hello, {name}!");
    });
```

With a type argument it is deserialized as it is for a class:

```csharp
inngest.AddFunction<OrderPlaced>(new FunctionOptions { Id = "log-order", Triggers = [Trigger.Event<OrderPlaced>()] },
    async (context, cancellationToken) =>
    {
        context.Logger.LogInformation("Order {OrderId} for {Total}", context.Event.Data.OrderId, context.Event.Data.Total);
        return null;
    });
```

A delegate is created once, so it cannot take constructor dependencies. Resolve services from `context.Services`,
which is the scope of the current call, or use a class.

## The function context

`FunctionContext<TEventData>` carries everything about the current call:

| Property | What it holds |
| - | - |
| `Event` | The triggering event, with `Data` deserialized into `TEventData`, plus `Name`, `Id`, `Timestamp`, `User` and `Version`. |
| `Events` | Every event of a batch when the function uses `BatchEvents`; otherwise a list with `Event` only. |
| `Step` | The step tools: `RunAsync`, `SleepAsync`, `SleepUntilAsync`, `WaitForEventAsync`, `InvokeAsync` and `SendEventAsync`. |
| `RunId` | The id of the run. It stays the same across every call and retry of one run. |
| `Attempt` | Zero on the first attempt of the current step or function, one more on every retry. |
| `MaxAttempts` | The number of attempts Inngest allows, when it says so. |
| `Logger` | An `ILogger` that stays quiet while memoized steps replay, so each message is written once per run. |
| `Services` | The dependency injection scope of this call. |
| `FunctionId` | The function's id as registered, without the app id. |
| `CancellationToken` | Canceled when Inngest abandons the call or the app shuts down. Functions also receive it as a parameter. |

## Event data

The SDK deserializes `data` with `InngestOptions.SerializerOptions`, which default to
`JsonSerializerDefaults.Web`: property names are camel case and matched without regard to case. A missing or `null`
`data` is read as an empty object. Use records with the properties you need; properties the event has and the
record does not are ignored.

Use `JsonElement` when the shape varies or when you only need a field or two. To change how JSON is read, for
example to add converters, change `SerializerOptions` when you call `AddInngest`.
[Native AOT and trimming](https://damikun.github.io/inngest-dotnet/guides/native-aot.md) shows how to register source-generated types.

## Event triggers

`[EventTrigger<T>]` runs the function for every event of type `T`, and `[EventTrigger("name")]` for every event
with that name; in code they are `Trigger.Event<T>()` and `Trigger.Event("name")`. The optional `If` is an
[expression](https://www.inngest.com/docs/guides/writing-expressions) that Inngest evaluates against the event
before it starts a run:

### Attributes

```csharp
[EventTrigger<OrderPlaced>(If = "event.data.total >= 500")]
[EventTrigger("shop/order.flagged")]
```

### Code first

```csharp
Triggers =
[
    Trigger.Event<OrderPlaced>(@if: "event.data.total >= 500"),
    Trigger.Event("shop/order.flagged"),
],
```

A function can have up to ten triggers and runs for any of them. Inngest refuses a sync with the same trigger twice,
or the same cron schedule twice even with different jitter, so the SDK refuses both at startup. Event names are free
text, but Inngest suggests a
`domain/noun.verb` style in the past tense, such as `billing/invoice.paid`. Names that start with `inngest/` belong
to Inngest; the SDK refuses to send them. The
[event and trigger concepts](https://www.inngest.com/docs/durable-execution/guides-and-advanced/events-and-triggers/event-and-trigger-concepts)
page of the Inngest docs has more, including wildcard triggers.

## Cron triggers

`[CronTrigger]`, or `Trigger.Cron` in code, runs the function on a schedule. Schedules have five fields and run in UTC
unless you give a time zone:

### Attributes

```csharp
[CronTrigger("0 6 * * 1-5", Timezone = "Europe/Prague")]
[CronTrigger("TZ=America/New_York 30 17 * * 5", Jitter = "2m")]
```

### Code first

```csharp
Triggers =
[
    Trigger.Cron("0 6 * * 1-5", timezone: "Europe/Prague"),
    Trigger.Cron("TZ=America/New_York 30 17 * * 5", TimeSpan.FromMinutes(2)),
],
```

The SDK sends the time zone as a `TZ=` prefix, which is how Inngest reads it, so both forms above work the same way.
The jitter of the second schedule starts each scheduled run up to two minutes later, so that functions on the same
schedule do not all start at once; it can be from 1 second to 5 minutes.

A scheduled run has an event too, named `inngest/scheduled.timer`, so read its data as `JsonElement`. See
[schedules and delayed starts](https://www.inngest.com/docs/durable-execution/guides-and-advanced/events-and-triggers/schedules-and-delayed-starts)
in the Inngest docs for how schedules behave around time zone changes.

## Functions without triggers

A function registered with an empty trigger list, or a class with `[InngestFunction]` and no trigger attribute,
never starts on its own. Other functions run it with [`InvokeAsync`](https://damikun.github.io/inngest-dotnet/guides/invoking-functions.md), by
its class:

```csharp title="ScoreAccount.cs"
using System.Text.Json;
using Inngest;

public sealed class ScoreAccount : InngestFunction<JsonElement, int>
{
    public override Task<int> ExecuteAsync(FunctionContext<JsonElement> context, CancellationToken cancellationToken) =>
        Task.FromResult(context.Event.Data.GetProperty("accountId").GetString()!.Length * 10);
}
```

### Attributes

```csharp
[InngestFunction("score-account")]
```

The attribute goes on `ScoreAccount`, and `inngest.AddFunction<ScoreAccount>()` registers it.

### Code first

```csharp
inngest.AddFunction<ScoreAccount>(new FunctionOptions { Id = "score-account", Triggers = [] });
```

## Ids and names

The id is required and must be unique within the app. Inngest knows the function as `<app id>-<id>`, so changing the
id, or the app id, makes a new function and leaves the old one without code. The name is only for display and
defaults to the id.

The SDK checks every function's options when the app starts, whether they come from code or attributes. If
something is invalid, such as a duplicate id or an option out of range, it throws one `InvalidOperationException`
that lists every problem it found, each naming its function. It does not wait for Inngest to reject the sync.
Attribute durations are time strings, such as `"30s"`, `"5m"` or `"1h30m"`, because attribute arguments must be
constants; one that is not a time string fails when `AddFunction` reads it.

To change a function while runs are in flight, keep its id and the ids of the steps those runs have already
reached. Inngest's [versioning guide](https://www.inngest.com/docs/durable-execution/guides-and-advanced/versioning)
explains what is safe to change.

## Related examples

[Nightly billing](https://damikun.github.io/inngest-dotnet/examples/nightly-billing.md) runs on a cron trigger, and [fan-out with invoke](https://damikun.github.io/inngest-dotnet/examples/fan-out-with-invoke.md) has a function without triggers.
