Skip to content

Backend: resolvers, fetchers, and jobs

The three decorators @WidgetResolver, @Fetcher, and @JobHandler, their rules, and the capabilities that an app’s backend obtains through tokens.

12 minute readApps SDKUpdated: 10/25/2026

On this page

Before you start

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.

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.

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.

// 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.

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).

@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.
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.
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.

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.

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([]);