# Backend: resolvers, fetchers, and jobs

An app’s backend is a module of classes that the platform loads into a container of its own at startup. Three decorators turn it into behavior: `@WidgetResolver`, `@Fetcher`, and `@JobHandler`. Everything the backend needs from the platform it obtains through tokens. This page covers the contracts, rules, and limits.

> **Type:** reference
> **For:** Developers who supply a widget with data, fetch external sources, or write a recurring job.
> **Prerequisite:** the `@szenera/apps-sdk` SDK from the platform’s source tree (not publicly released). A widget with `noResolver: false`.

## What the backend is

Only data apps export a backend. Apps without behavior omit it instead of maintaining an empty module. The platform does not import the module but moves its classes into a separate container for each app, in which the tokens are bound. The backend’s own routes live under `/v1/apps/<id>/…`. Two apps can never inject classes into each other, and a backend sees only the surface of the SDK.

All three decorators only leave a marker on the class. The platform reads them at startup, registers resolvers, creates workers for fetchers and jobs, and wires them into the snapshot build. Each class gets exactly one marker per kind. The class additionally carries the injection markers of the platform container (`@Injectable` on the class, `@Inject(Token)` on every constructor parameter) — without them the container cannot create the class.

## @WidgetResolver

An app registers exactly one resolver per widget type. During the snapshot build, the platform calls `resolve(ctx)` with the already validated configuration and receives a render-ready payload — or `null`.

The weather app’s resolver, abridged: error handling and time-window calculation are left out. It never calls the source itself: it determines the place (a fixed choice or the display’s location from `ctx.location`), reads through the fetcher cache (`APP_FETCHER_CACHE`), and passes the fetcher in only as a loader for the cache miss.

```ts
// abridged — without error logging and time-window calculation
@WidgetResolver({ widgetType: 'weather' })
@Injectable()
export class WeatherResolver implements IWidgetResolver<ResolvedWeatherData> {
  readonly widgetType = 'weather';

  constructor(
    @Inject(APP_FETCHER_CACHE) private readonly cache: AppFetcherCache,
    @Inject(APP_TENANT_DATA) private readonly tenant: AppTenantData,
    @Inject(APP_CLOCK) private readonly clock: AppClock,
    @Inject(APP_LOGGER) private readonly log: AppLogger,
    private readonly fetcher: WeatherFetcherService,
  ) {}

  async resolve(ctx: WidgetResolverContext): Promise<ResolvedWidgetPayload<ResolvedWeatherData> | null> {
    const parsed = WeatherConfigSchema.safeParse(ctx.config);
    if (!parsed.success) {
      // Authoring state — not an operational error: the widget shows its notice.
      this.log.debug({ widgetInstanceId: ctx.widgetInstanceId }, 'weather.resolve.invalid-config');
      return null;
    }
    const config = parsed.data;
    // Fixed place, the display's location, or fallback place; without a place, silently `null`.
    const place = resolveWeatherPlace(config.location, ctx.location);
    if (!place) return null;

    const tenant = await this.tenant.profile(ctx.tenantId);
    const locale = tenant?.defaultLocale ?? 'de-DE';
    const citySlug = computeCitySlug(place.latitude, place.longitude, place.countryCode);
    const ttlMs = WEATHER_VALIDITY_MS;
    const staleWindowMs = 3 * ttlMs;

    const { data: snapshot, cached, cachedAt } = await this.cache.getOrFetch<WeatherSnapshot>(
      WeatherFetcherService.fetchKey(citySlug),
      async () => {
        const result = await this.fetcher.fetchWeather({
          citySlug,
          latitude: place.latitude,
          longitude: place.longitude,
          locale,
        });
        return result.payload;
      },
      { ttl: ttlMs, staleWindow: staleWindowMs },
    );

    const anchor = cached && cachedAt ? cachedAt : this.clock.now();
    return {
      payload: mapToResolved(snapshot, place, config.units),
      source: cached ? `cache:api:${snapshot.provider}` : `api:${snapshot.provider}`,
      resolvedAt: this.clock.now(),
      ...zeitzusagen(anchor, ttlMs, staleWindowMs, this.clock.now()),
    };
  }
}
```

Two details from the unabridged version: an error from the provider or the infrastructure is logged as `weather.resolve.failed` and rethrown so that the snapshot build reports it as a resolver error rather than as “no result.” And on a cache hit, the time windows are anchored to the original fetch time (`cachedAt`), not to “now” — `zeitzusagen` from `@szenera/apps-sdk/backend` computes `validUntil` and `cacheUntil` from it.

**The context (`WidgetResolverContext`):**

| Field | Type | Meaning |
|---|---|---|
| `tenantId` | string | The organization being resolved for. |
| `playerId` | string or `null` | The player. `null` for the on-demand preview (in the editor, for example) as long as it does not resolve for a specific display. |
| `widgetInstanceId` | string | The instance in the scene. |
| `widgetType` | string | The widget type. |
| `appVersionPin` | string | The app version pinned in the scene version, e.g., `1.2.0`. |
| `config` | unknown | Already validated against the configuration schema. |
| `traceId` | string | Identifier for tracing. |
| `mapTiles` | `{ light, dark }` or absent | Address templates of the base map in Leaflet notation (`{z}/{x}/{y}{r}`). The tiles come through the platform’s API, which appends the map provider’s key. A resolver whose widget draws a map passes the matching template into its payload, because only that way does the display software find the same address in the editor, in the browser, and on the device. If the field is missing, it also stays out of the payload. |
| `location` | Location, `null`, or absent | The display’s location with `name`, `city`, `countryCode`, `latitude`, `longitude`, and `timeZone`. Absent if the app does not name `player-location` under `dataAccess.reads` or the organization has not consented to it. `null` without a display, without a location on the display, or without coordinates on the location. See [The location of the display](/apps-sdk/data-access#the-location-of-the-display-player-location). |

**The response (`ResolvedWidgetPayload`):** `payload`, `source`, `resolvedAt`, `validUntil`, `cacheUntil`. The two points in time travel into the snapshot and control when the platform recomputes. The snapshot is valid until the earliest `validUntil` of its widgets, and exactly then the platform rebuilds it. A resolver whose widget changes what it shows at a known point in time (a duty window ends, sold out holds until 5 a.m., a menu card from a cut-off date onward) sets `validUntil` to that point. If it lies less than 30 seconds ahead, the sweep that runs every five minutes catches it.

**The rules:**

| Rule | Why |
|---|---|
| Idempotent | The same context yields the same payload, and the snapshot build may repeat. |
| No outbound fetch | Data comes from the fetcher cache. External latency belongs in the fetcher. |
| No permission check | The snapshot build is a system context without an acting user. |
| No events | The snapshot build is free of side effects. |
| `null` for a missing or deleted reference | The widget renders its placeholder instead of breaking the build. An invalid configuration also yields `null`. |
| Handle `playerId: null` | The same resolver serves the preview without a player. |
| p99 under 50 ms | The time budget of the snapshot build. |

At startup, the platform checks that every registered widget type appears in the manifest, that no two resolvers claim the same type, and that every widget with `noResolver: false` has a resolver.

## @Fetcher

`@Fetcher` marks a method that fetches an external source. The platform creates a worker of its own for it, which executes only one run at a time so that limited external sources are spared.

| Option | Format | Meaning |
|---|---|---|
| `key` | Pattern with app prefix, e.g., `weather:{citySlug}` | Key in the cache. The first segment before the colon is the app identifier. |
| `defaultValidity` | ISO 8601 duration, e.g., `PT30M` | How long the data counts as fresh. |
| `defaultCacheUntil` | ISO 8601 duration, e.g., `PT2H` | How long the cache keeps serving it as a fallback. |

The method returns `FetcherResult<TPayload>`: `payload`, `validUntil`, `cacheUntil`, and `source` (origin, e.g., `api:openweather`).

```ts
@Fetcher({ key: 'weather:{citySlug}', defaultValidity: 'PT30M', defaultCacheUntil: 'PT2H' })
async fetchWeather(args: FetchArgs): Promise<FetcherResult<WeatherSnapshot>> {
  const snapshot = await this.router.fetch(args);
  const now = this.clock.now();
  return {
    payload: snapshot,
    validUntil: new Date(now.getTime() + 30 * 60 * 1000),
    cacheUntil: new Date(now.getTime() + 2 * 60 * 60 * 1000),
    source: `api:${snapshot.provider}`,
  };
}
```

Scheduled runs and refreshing through the API call a fetcher method **without arguments**. If it declares required parameters, the platform treats it as a pure cache loader: scheduled runs and refreshing reject it, and only the app itself calls it, from `getOrFetch` in the resolver or from a job that knows the arguments. Periodic fetching is therefore a matter for a `@JobHandler`.

`getOrFetch` checks on every path, even on a cache hit, whether the organization has the app actively installed, and otherwise the platform answers with `App.NotInstalled` (HTTP 409). The two time windows result in three situations:

| Situation | What happens |
|---|---|
| now ≤ `validUntil` | serve, nothing else |
| `validUntil` < now ≤ `cacheUntil` | serve immediately and refresh in the background |
| now > `cacheUntil` | the caller waits synchronously for the source |

If the refresh fails, the stored entry remains untouched until `cacheUntil`, so a disrupted source does not take the picture away from the display as well.

## @JobHandler

`@JobHandler` marks a method as a recurring, platform-wide job. The platform creates a schedule that a cron works through and calls the method without an organization in the context. The handler fans out across the installed organizations itself.

| Option | Format | Meaning |
|---|---|---|
| `key` | string, e.g., `pharmacies-on-duty-crawl-all-locations` | Unique within the app, without app prefix. |
| `cron` | 5 fields (`min hour dom month dow`), e.g., `*/30 * * * *` | The schedule. |
| `namespace` | string, default app identifier | The platform derives the app from `namespace`, and the bundled apps set it equal to the app identifier. |
| `timeout` | ISO 8601 duration, e.g., `PT5M` | Hard limit per run. |
| `concurrency` | number, default `1` | Simultaneous runs. |

`listTenantsForApp()` on the job queue token supplies the organizations, only those with an active installation of **this** app, and another app cannot be enumerated. A `key` that is already registered as a fetcher is skipped with a warning.

## The tokens

The backend obtains every cross-cutting capability through a token, and behind it the platform binds an implementation that automatically scopes everything with the app identifier. Three tokens carry context, and the rest provide services.

| Token | What you get | Scoping by the platform |
|---|---|---|
| `APP_ID` | The app identifier | — |
| `APP_MANIFEST` | The parsed manifest | — |
| `APP_VERSION` | The version from the manifest | — |
| Token of the app database (named after the database tool in the SDK) | The app database: `models`, `$transaction(fn)` | Only its own tables in the schema `apps_<id>`. Without a schema of its own, access fails. |
| `APP_PERMISSION_GUARD` | `assert(actor, action, resource?)`, `canAct(…)` | Permission keys prefixed with the app identifier. |
| `APP_REQUEST_SCOPE` | `traceId`, `correlationId`, `actorId`, `tenantId`, `isPlatformContext` | Read-only. |
| `APP_ERROR_FACTORY` | `businessException`, `notFound`, `validationFailed`, `conflict`, `integrationFailed` | Supplies the error that you throw. |
| `APP_EVENT_BUS` | `emit(tx, eventName, payload)`, `contentChanged(tx, { tenantId, locationIds? })` | Event name `app.<id>.<eventName>`. You pass the transaction explicitly. `contentChanged` is the fixed event `app.<id>.content.changed`, see [Reporting changed data to the displays](#reporting-changed-data-to-the-displays). |
| `APP_LOGGER` | `info`, `warn`, `error`, `debug`, `child` | Namespace `app.<id>`. |
| `APP_CLOCK` | `now()`, `nowMs()` | Time source, replaceable in tests. |
| `APP_CACHE` | `get`, `set`, `invalidate` | Keys `app:<id>:…`. |
| `APP_FETCHER_CACHE` | `getOrFetch(key, fetcher, { ttl, staleWindow? })`, `invalidate` | Read-through cache. The organization comes from the call context. |
| `APP_FILE_STORAGE` | `put`, `get`, `delete`, `signedUrl` — each with `tenantId` | Storage under `tenants/<tenantId>/apps/<id>/`. `put` accepts a condition (`ifMatch` with the `etag` from `get`, `ifNoneMatch: '*'`), and if it is violated, it throws a conflict (`Conflict.OptimisticLock`): read again and write once more. |
| `APP_JOB_QUEUE` | `enqueue`, `enqueueBulk`, `listTenantsForApp()` | Queues with app prefix. |
| `APP_TENANT_DATA` | `profile`, `branding`, `userDisplayName`, `asset`, `assetVariantUrl` — each with `tenantId` | Bound only if `dataAccess.reads` names a data set. See [Data access](/apps-sdk/data-access). |
| `APP_PLATFORM_CONFIG` | `get`, `getSecret`, `has`, `getAllowlist` | Settings and secrets maintained by the operator, read-only. |
| `APP_GUARDED_FETCH` | `fetchText(url, options?)` | Guarded fetch of addresses that a user entered. |

### AppGuardedFetch is required for user addresses

As soon as a backend fetches an address that someone entered in the portal (a feed, a calendar, a news source from a widget configuration), it must use `fetchText`, never the global `fetch`. The guard lets only `http:` and `https:` through, checks all resolved addresses against the blocklist of private and reserved ranges, repeats the check on every redirect, truncates the response in the stream, and enforces a timeout. Defaults: `timeoutMs: 10000`, `maxBytes: 2097152` (2 MiB), `maxRedirects: 3`.

Every failure carries a machine-readable `reason`: `invalid-url`, `blocked-scheme`, `blocked-address`, `dns-failure`, `too-many-redirects`, `too-large`, `timeout`, `http-error`, `network-error`. `isGuardedFetchError` recognizes it structurally, so a resolver can put the reason into its payload as plain text. The guard is not intended for a provider hard-wired in the code (the weather API, for example), because no user input chooses the target there.

## Reporting changed data to the displays

If something changes that a widget already shows, the backend reports it with `contentChanged`. The platform then rebuilds the organization’s displays whose program contains the app and notifies those whose content has actually changed. The displays show the change after a few seconds. Without the report, the change arrives only when the snapshot is rebuilt for another reason, in the worst case after hours.

```ts
await this.prisma.$transaction(async (tx) => {
  await (tx as MyPrismaClient).dish.update({ where: { id }, data: { soldOut: true } });
  await this.events.contentChanged(tx, { tenantId });
});
```

| Rule | Why |
|---|---|
| In the same transaction as the change | The report goes through the outbox: it arrives at least once, but never for a change that was rolled back. |
| Without a database of its own, `null` instead of the transaction | An app that keeps its data in file storage reports right after writing with `contentChanged(null, { tenantId })`. The report then goes into the outbox alone. If it fails, the change remains, and the displays see it with the next expiry of their payload. |
| After changing and deleting, not after creating | A resolver reads data through the identifier that a widget has chosen. No widget points to a new entry yet, and whoever puts it into a scene publishes the scene, which triggers the rebuild anyway. |
| Always provide `tenantId` | Jobs run without an organization in the context. In the context of an organization, the platform rejects a different one. |
| `locationIds` only if the change becomes visible exclusively at these locations | The identifiers are locations of the organization, not records of the app. If a widget can also show the data on a display elsewhere (a fixed location in its setting), omit the field. An empty list counts as none. |
| A fetch reports once at the end | A job that writes in many transactions reports once as soon as it has written or cleared something. One report per entry would only bring additional rebuild attempts. |
| Time-controlled changes need no report | What changes at a known point in time is stated in the resolver’s `validUntil`. |

A rebuild with unchanged content notifies no display, so one report too many costs little. A missing one costs stale content on the displays. `emit(tx, 'content.changed', …)` takes the same path as `contentChanged`. In tests, `createMockAppContainer` records the report in `emittedEvents`.

## Installing and uninstalling: the lifecycle hooks

A backend class marked with `@AppLifecycle()` may carry `onInstall(tenantId)` and `onUninstall(tenantId)`: at most one class per app, and both hooks must be idempotent because a reinstallation finds its existing data again. If `onInstall` throws, the installation counts as failed and the organization sees the error. If `onUninstall` throws, the uninstallation remains in effect and the error lands in the log.

## Testing without a running platform

`createMockAppContainer` from `@szenera/apps-sdk/testing` supplies an in-memory implementation with recording for each token. `mockWidgetResolverContext` builds a synthetic context, initially without `location`. An app with `player-location` sets it with `mockWidgetLocation()` or `null`.

```ts
import { createMockAppContainer, mockWidgetResolverContext } from '@szenera/apps-sdk/testing';
import { APP_CLOCK, APP_FETCHER_CACHE, APP_LOGGER, APP_TENANT_DATA } from '@szenera/apps-sdk/backend';

const c = createMockAppContainer({
  appId: 'weather',
  clockNow: new Date('2026-06-23T00:00:00Z'),
  tenantMock: { id: 'tnt_a', slug: 'apo', displayName: 'Apo', defaultLocale: 'en-US', defaultTimezone: 'Europe/Berlin' },
});

// The app's own classes (the fetcher here) come in as hand-written stubs.
const resolver = new WeatherResolver(
  c.get(APP_FETCHER_CACHE),
  c.get(APP_TENANT_DATA),
  c.get(APP_CLOCK),
  c.get(APP_LOGGER),
  fetcherStub,
);
const result = await resolver.resolve(mockWidgetResolverContext({ widgetType: 'weather' }));
expect(c.emittedEvents).toEqual([]);
```

## Related

- [Data access, permissions, and terms](/apps-sdk/data-access) — what `dataAccess.reads` binds to `APP_TENANT_DATA`.
- [Widget, configuration, and preview](/apps-sdk/widget-and-preview) — the widget that the resolver serves.
- [The example apps](/apps-sdk/example-apps) — which app carries which building blocks.
