The backend runtime
A Wildo application does not write a server. It writes declarations (resources, roles, custom
operations, batches, email templates, charts, agents) and hands them to the backend runtime,
@wildo-ai/saas-backend-lib, which boots a complete service around them: configuration, database
connections, an HTTP API with security headers and rate limits, authorisation, background jobs,
health probes. The hard, repetitive parts of a business backend are carried by the runtime once,
and every application inherits them by starting it.
The idea
An application supplies one structured object, ApplicationInitializationConfig, assembled from
its modules, and calls StartApplicationBackendService.initialize(). The runtime then runs a fixed,
ordered boot: it loads and validates configuration, builds the resource registry from the engine’s
own resources plus the application’s, derives repositories, services and controllers per resource,
mounts the HTTP server, connects the queue workers and syncs the schedules. Every request then
travels one path, controller to service to repository, carrying an immutable ExecutionContext
that says who is acting, under which credential, in which tenant scope. What the application
declares is the domain; what the runtime derives is everything a request needs to be safe.
What you get for free
- A REST API per resource: routes, request and response DTOs, pagination clamps and body validation, all generated from the resource declaration at boot.
- Authorisation on every operation (
authorizeInController, fail closed) and tenant isolation on every read and write, applied by the repository’s contextual filter. - One transaction boundary per operation, with bounded conflict retry, on both persistence adapters, and a post-commit queue for effects that must not run inside it.
- Security headers, CORS, compression, rate limiting, body-size limits and a sanitised error handler on every route.
- A correlation identifier on every log line and audit row of a request, including nested system-initiated work.
- Liveness and readiness probes, with a contributor seam for process-specific dependencies.
- A queue runtime with retries and a dead-letter path, schedule publication to the platform, idempotent data seeds, and graceful shutdown with job drain.
- Boot reports that name a tenant nobody can administer, or a resource that has not said whether it holds personal data, before a user finds out.
Where you plug in
BackendDomainModule: the unit of contribution, one perbackend-api/src/modules/<module>/.createResourceCustomServiceImplementationin a*.operation.tsfile: hooks on a core or custom operation, in any of the six phases.AbstractControllerBackendServicewithCustomControllerMountPhase: a manual controller with a declared place in the middleware order, passed throughcustomControllers.AppConfiguration_BackendAuthoredinsaas-config.backend.ts: the app-managed configuration.setupModuleBackendInjection(container): bind the application’s services on the sharedInversifyContainerbefore the lifecycle starts.*.batch.backend.service.tsand theCRON_JOBoperation variant: scheduled work.seedsanddataSeedScopeResolvers: declarative reference data.HealthReadinessContributorBackendService: a process-specific readiness dependency.minionsandworkersinwildo.saas.config.tswithdefineMinionInit/defineWorkerInit.organizationUnitNarrowingandresourceServerInstanceson the initialisation config: unit-level confinement of a resource, and named MCP or A2A servers.
For engineers
How it is built
The dependency-injection container
Every runtime service is an Inversify singleton bound in SaasContainer
(container.ts, over two hundred bindings) against the
SAAS_SERVICE_TYPES symbol registry. Services take collaborators through constructor injection and
do no work in the constructor; the work happens in an initialize() method the boot sequence calls
in order, because order matters. The package re-exports the container class as
InversifyContainer, and an application binds its own services on the same container.
Drawn from index.ts:
const container = setupSaasBackendInjection();
setupModuleBackendInjection(container);
const startApplicationService = container.get<StartApplicationBackendService>(
SAAS_SERVICE_TYPES.StartApplicationService,
);
const initializationConfig = buildBackendApplicationInitializationConfig({
customControllers, container, serviceIdentifier: 'services.backendApi', providerRuntime,
});
await startApplicationService.initialize(initializationConfig);
The startup lifecycle
StartApplicationBackendService.initialize() in
start-application.backend.service.ts runs these
steps, each depending on the one before:
AppConfigurationService.initialize()loads the configuration envelope (see below), applies the authoredbackendConfig, and refuses to continue if a placeholder is left unfilled (assertNoPreOverridePlaceholders). MongoDB is connected only when the environment names a host; Redis degrades to an in-memory fallback with a warning.AuthorizationsBackendService.initializeRolesWithCustom()merges the application’s roles.ResourcesRegistryBackendService.initialize()merges four sources (engine shared, engine backend, application shared, application backend), calls every resource factory, validates the relationship graph, checks persistence-adapter coherence, then generates the URL paths and the request and response DTOs of every operation.- The other registries load: features (
FeatureDefinitionRegistryBackendService), milestones, the lifecycle evaluator, external providers (ExternalProvidersRegistryBackendService, with a required-capability floor that fails loudly), flows and actors (FlowsActorsRegistryService), named MCP and A2A resource-server instances, translations (I18nBackendService), email templates (EmailTemplateRegistryBackendService), charts (ChartRegistryBackendService). RepositoriesRegistryHandlerBackendService.initializeRepositories()turns each resource’s Zod schema into a Mongoose model or a PostgreSQL table plan; the custom-implementation registry then loads the application’s operation hooks.ServicesRegistryHandlerBackendService.initializeServices()creates oneServiceResourceBackendServiceper resource; billing and the webhook auto-controller follow.ControllersRegistryHandlerBackendService.initialize()creates oneControllerResourceBackendServiceper resource and registers the manual controllers; batches and cron jobs are registered withBatchesCronjobsExecutor_BackendService.HttpServerBackendService.initialize()mounts Express, skipped for headless runtimes.QueueWorkerInitializerBackendServiceconnects RabbitMQ and starts the job consumers;CronJobSyncBackendServicepublishes everyCRON_JOBoperation to the platform’s scheduler.- Data seeds run, three boot reports run (the super-administrator floor, the per-tenant owner
floor, and which resources have said nothing about personal data), and
validateInitializationconfirms the configuration and the resource registry are reachable.
The same lifecycle serves more than one process. ApplicationRuntimeProfile names the
APPLICATION_BACKEND (the full public surface) and the MINION (headless, for the resource graph
and the container). Boot assertions are resolved once from that profile, so two validators cannot
disagree about which runtime they are checking.
Boot-time gates that fail closed
Several checks refuse to boot rather than let a structural defect reach the first request. Each is hosted at the step where its subject first exists:
| Gate | Where | Question it settles |
|---|---|---|
validateRelationshipGraph | resources registry | is the relationship graph free of cycles? |
validateSharedCollectionRetentionCoherence | resources registry | do two configurations over one collection agree on retention? |
validateRequiredForeignKeyFillability, validateCompositionCascadeDeletability | before repositories | can every required foreign key be filled, every cascade executed? |
reconcileRetentionGovernedMongoCollections | inside initializeRepositories, MongoDB only | are retention markers and unique indexes coherent with the live collection? |
verifyPostgreSqlTableSchemas | after repositories, PostgreSQL only | do the physical tables match the plan? |
ApplicationStartupConfigValidatorService adds one more: an operation declared but not implemented
must carry an explicit acknowledgement, and doOperation then refuses it at request time with a
configuration error instead of answering a success it did not perform.
The execution context
ExecutionContext (execution-context.backend.schemas.ts)
is the immutable object every operation carries: the operation being executed, the execution type
(ExecutionContext_ExecutionType: USER_REQUEST, ORGANIZATION_MACHINE, APPLICATION_MACHINE,
CONSUMABLE_TOKEN, INTERNALLY_INITIATED_REQUEST, PUBLIC_EXECUTION, WEBHOOK_CALLBACK,
ANONYMOUS_SESSION), the initiator identifiers (user, organisation, application, machine
credential, consumable token, webhook provider, anonymous user), the initiator’s roles with their
scope, the relationship contexts derived from the URL, and a correlation identifier adopted from
the request frame (a module-level AsyncLocalStorage in request-context.als.backend.ts, entered
at every seam: HTTP, WebSocket, queue consume, cron tick).
Identity fields are set at construction and never mutate; withOperation() returns a new context
with the operation corrected, and addContextResourceId() is the only append, blocked once the
context is sealed. ExecutionContextCreatorBackendService owns the entry points:
createExecutionContextFromRequest() for HTTP, createForInternalOperation() for system work,
createForDeferredAuthorizedEffect() for persisted post-commit work that keeps its principal, and
createForSubCall() for nested operations, which counts depth and tracks visited resources so a
relationship traversal cannot loop.
One path for every request
HTTP request
└─ ControllerResourceBackendService route resolution, body validated against the DTO,
│ AuthorizationsBackendService.authorizeInController
▼
ExecutionContextCreatorBackendService principal, roles, scope, correlation id
▼
ServicesRegistryHandlerBackendService.doOperation() the single dispatch funnel
│ feature gate, custom hooks, transaction, post-commit effects, output transform
▼
RepositoriesRegistryHandlerBackendService.doByPath() contextual filter (tenant, ownership,
│ relationship), adapter dispatch
▼
MongoDB repository | PostgreSQL repository
doOperation() in services-registry-handler.backend.service.ts is private and has exactly two
entries, doByExecutionContext (HTTP controllers, batches, cron) and doByOperationPath (MCP,
queue, programmatic calls), so every dispatch surface converges on one policy pipeline. It corrects
the context’s operation reference, checks the feature gate (checkOperationFeatures), runs the
core phase, owns or reuses the transaction with a bounded conflict retry, schedules the post-commit
effects and runs the terminal phases. The repository tier builds the contextual filter from the
execution context, which is how tenant isolation and ownership apply to every read and write
without the application writing a where clause. Custom implementations resolve by an exact
operation-path key (resource, operation, variant, default flag); a custom verb inherits no guard
from the core operation it resembles.
Operation hooks
An application shapes an operation without replacing the pipeline.
createResourceCustomServiceImplementation takes the operation path, its DTO schemas and a
handlers object whose keys are the phases of ResourceOperation_CustomServiceImplementationMode:
prefixCoreOperations (before persistence, inside the transaction), replaceCoreOperations
(instead of persistence), postfixCoreOperations (after persistence, inside the transaction),
afterUserNotifications and atTheEnd (terminal phases outside it), and overrideAll (the whole
operation). A prefix may declare authoritativeFields it owns, so a caller-supplied value is
overwritten rather than trusted. Framework effects that are only safe after the root commit
(notifications, audit emission, lifecycle scheduling) are queued with
registerTransactionPostCommitEffect on the transaction context and drained after the commit; a
retried attempt gets a fresh queue, so effects from an aborted attempt cannot leak.
Drawn from todos.create.default.operation.ts:
const todosCreateDefaultCustomImpl: ResourceCustomServiceImplementationFactory = (registry) => {
const operationPath = {
resourceIdentifier: TasksManager_ResourceType.TODOS,
operationIdentifier: CoreResourceOperation.CREATE,
variantType: ResourceOperationVariantType.API_CALL,
};
return createResourceCustomServiceImplementation({
operationPath,
requestDtoSchema: registry.getRequestDtoFromPath(operationPath),
responseDtoSchema: registry.getResponseDtoFromPath(operationPath),
handlers: { postfixCoreOperations: async (_id, input) => input },
});
};
The HTTP server and its middleware phases
HttpServerBackendService mounts Express in a fixed order: security headers (Helmet, with the
configured content-security policy), the request-context frame, CORS, compression, rate limiting
(Redis-backed with an in-memory fallback, standard X-RateLimit-* headers), body parsing with
configured size limits, request logging, the resource routers, and the error handler last. A
manual controller extends AbstractControllerBackendService and declares where it enters that
order through CustomControllerMountPhase: PRE_REQUEST_TRANSFORM (after security headers, before
CORS can answer a preflight or a parser can consume bytes), RAW_REQUEST_STREAM (after CORS and
rate limiting, before body parsing) or STANDARD. A non-standard phase registered after the server
is up is rejected, because its declared ordering can no longer be met.
Health and readiness
HealthControllerBackendService mounts GET /health (liveness) and GET /health/ready
(readiness). Readiness evaluates MongoDB, the initialised configuration and the configured file
storage concurrently, each under one shared deadline, so a stuck provider yields a sanitised 503
rather than a request that never returns. Redis is deliberately not a universal gate; a process
that requires it says so through HealthReadinessContributorBackendService (getComponentName(),
checkReadiness()), the seam any subsystem uses to join readiness.
The configuration envelope
The runtime configuration is split in two, and the split is enforced on both sides.
WILDO_APPLICATION_APP_MANAGED_KEYS in the platform’s apps manager names what the application
owns (identity, preferences, authentication and user types, frontend services, minions, workers,
organisation types, compliance, email, storage, monitoring, engine capabilities, providers,
billing, analytics, lifecycle); WILDO_APPLICATION_PLATFORM_MANAGED_KEYS names what the platform
owns (runtime, database, jwt). At boot AppConfigurationLoaderService reads the environment
and fetches the bootstrap metadata from the apps manager, AppConfigurationService validates the
whole against AppConfiguration_Schema, and applyBackendConfig() merges the application’s
AppConfiguration_BackendAuthored object, a deep partial of the app-managed sections with the
platform-seeded ones removed from the type. Mutation-boundary rules reject any attempt to redeclare
runtime topology, database addresses or signing keys from application code. Provider secrets are
never in the envelope; they reach the process as environment material.
Background work
Four mechanisms, all declared rather than wired by hand:
- Queued jobs.
QueueBackendServicepublishes to RabbitMQ;JobConsumerBackendServiceandJobExecutorBackendServicedrain theoperations,operations.priority,scheduledandcallbacksqueues inside every backend process, so each pod both enqueues and executes. An operation declares itsResourceOperation_ServiceRuntimeMode(inline, background job, queued, and their non-awaitable forms) and the samedoOperationfunnel runs it. - Scheduled batches and cron operations. A
CRON_JOBoperation variant or a*.batch.backend.service.tsservice carries its schedule;CronJobSyncBackendServicepublishes the set toplatform-crontabs-batches-manager, which owns the clock and dispatches signed messages back through the queue. - Minions. A minion is a background application declared in
wildo.saas.config.tsunderminionswith aresourceAccessread and write list and aplatformAccess.scopeslist (MinionPlatformAccessScope). It boots the shared lifecycle headlessly through@wildo-ai/saas-minions-lib(defineMinionInit,StartMinionApplicationBackendService,MinionTickConsumerBackendService), consumes only its own tick queue, and reaches data through a system-access door that refuses anything outside its declaration and audits every read. - Workers. A worker is a standalone process declared under
workers, started through@wildo-ai/saas-workers-node-lib(defineWorkerInit,StartWorkerApplicationBackendService) with its own health surface; it does not boot the application lifecycle and holds no backing-service access of its own.
How an application module contributes
A backend module is a plain object typed BackendDomainModule
(domain-module.backend.definitions.ts). It extends the shared
module (resource configurations, field identifiers, relationships) with operations (custom
implementation factories), controllers, flowsActors (actor systems, agents, function tools),
pdfTemplateBindings, chartDefinitions, seeds and dataSeedScopeResolvers.
mergeBackendDomainModules() folds several modules into one, rejecting duplicate resolver keys,
and buildApplicationInitializationConfigFromModules() turns the merged registries into the
ApplicationInitializationConfig the lifecycle consumes.
Drawn from index.ts:
const backendModule: BackendDomainModule = {
...resourcesModule,
flowsActors: moduleBackend_FlowsActorsRegistry,
pdfTemplateBindings: tasksManagerPdfTemplateBindings,
chartDefinitions: [todoTaskStatusChartDefinition, todosByStatusChartDefinition],
seeds: [
...webhookConfigSeeds,
...enterpriseAuthConfigOrgSeedDefinitions,
...applicationMetadataSeedDefinitions,
],
};
modules-registry.backend.ts lists the owned modules (the engine’s own module first, then the
application’s), merges them, and saas-config.backend.ts default-exports the authored
AppConfiguration_BackendAuthored. Nothing in the application names a route, a table, a
transaction or a queue.
Boundaries and known limits
- MongoDB and PostgreSQL are separate repository classes behind one contract, and an
adapter-specific gate runs only for its adapter.
verifyPostgreSqlTableSchemasis called from application boot, not frominitializeRepositories, and its unique-constraint half needs a real catalogue, so an in-memory PostgreSQL double skips it with a warning. - The smaller platform services do not run
StartApplicationBackendService.initialize(); they have a two-phase startup of their own. - Custom implementations resolve by exact operation-path key. A custom verb that writes the same field as a guarded core operation is not guarded by it; the guard has to be declared again.
validateInitializationchecks the configuration and the resource registry; the repository, service and controller registry checks in that method are commented out.- The value-moment analytics engine bound in the container is a placeholder; the observability domain records it as PARTIAL.
- A Node worker receives only its own pre-scoped provider graph and no backing-service access; a process that needs the resource graph is a minion, not a worker.
- The Kubernetes deployment of these processes renders from the same environment descriptor as the Compose stack; the deployment domain records it as IMPLEMENTED, not yet run in a cluster.