Skip to main content
Wildo.ai Coming soon

Foundations

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 per backend-api/src/modules/<module>/.
  • createResourceCustomServiceImplementation in a *.operation.ts file: hooks on a core or custom operation, in any of the six phases.
  • AbstractControllerBackendService with CustomControllerMountPhase: a manual controller with a declared place in the middleware order, passed through customControllers.
  • AppConfiguration_BackendAuthored in saas-config.backend.ts: the app-managed configuration.
  • setupModuleBackendInjection(container): bind the application’s services on the shared InversifyContainer before the lifecycle starts.
  • *.batch.backend.service.ts and the CRON_JOB operation variant: scheduled work.
  • seeds and dataSeedScopeResolvers: declarative reference data.
  • HealthReadinessContributorBackendService: a process-specific readiness dependency.
  • minions and workers in wildo.saas.config.ts with defineMinionInit / defineWorkerInit.
  • organizationUnitNarrowing and resourceServerInstances on 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:

  1. AppConfigurationService.initialize() loads the configuration envelope (see below), applies the authored backendConfig, 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.
  2. AuthorizationsBackendService.initializeRolesWithCustom() merges the application’s roles.
  3. 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.
  4. 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).
  5. 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.
  6. ServicesRegistryHandlerBackendService.initializeServices() creates one ServiceResourceBackendService per resource; billing and the webhook auto-controller follow.
  7. ControllersRegistryHandlerBackendService.initialize() creates one ControllerResourceBackendService per resource and registers the manual controllers; batches and cron jobs are registered with BatchesCronjobsExecutor_BackendService.
  8. HttpServerBackendService.initialize() mounts Express, skipped for headless runtimes.
  9. QueueWorkerInitializerBackendService connects RabbitMQ and starts the job consumers; CronJobSyncBackendService publishes every CRON_JOB operation to the platform’s scheduler.
  10. 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 validateInitialization confirms 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:

GateWhereQuestion it settles
validateRelationshipGraphresources registryis the relationship graph free of cycles?
validateSharedCollectionRetentionCoherenceresources registrydo two configurations over one collection agree on retention?
validateRequiredForeignKeyFillability, validateCompositionCascadeDeletabilitybefore repositoriescan every required foreign key be filled, every cascade executed?
reconcileRetentionGovernedMongoCollectionsinside initializeRepositories, MongoDB onlyare retention markers and unique indexes coherent with the live collection?
verifyPostgreSqlTableSchemasafter repositories, PostgreSQL onlydo 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. QueueBackendService publishes to RabbitMQ; JobConsumerBackendService and JobExecutorBackendService drain the operations, operations.priority, scheduled and callbacks queues inside every backend process, so each pod both enqueues and executes. An operation declares its ResourceOperation_ServiceRuntimeMode (inline, background job, queued, and their non-awaitable forms) and the same doOperation funnel runs it.
  • Scheduled batches and cron operations. A CRON_JOB operation variant or a *.batch.backend.service.ts service carries its schedule; CronJobSyncBackendService publishes the set to platform-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.ts under minions with a resourceAccess read and write list and a platformAccess.scopes list (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. verifyPostgreSqlTableSchemas is called from application boot, not from initializeRepositories, 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.
  • validateInitialization checks 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.

Building a B2B product or an internal tool?

Wildo is not self-service yet. Tell us what you have in mind and we will say plainly whether it fits, and what happens next.