Foundation Service — Extension & Lifecycle Framework
Platform: Infosys Equinox | The per-instance customisation model
This is the central capability of the extension-support work: a tenant (instance) can inject custom behaviour into Foundation at runtime — custom validation and request/response interception — without modifying or redeploying Foundation itself.
There are two independent mechanisms:
- Validator extensions — replace or augment the built-in domain validation for an entity.
- Lifecycle extensions — run custom logic before (pre-handle) and after (post-handle) a request.
Both are configured per instance under instanceContext.properties.pluginConfig and loaded through the shared @equinox/plugin-registry.
Why this exists
Different tenants have different rules. One retailer may require store names to match a corporate code pattern; another may need to enrich a create request with data from an external system, or reshape a response for a legacy client. Hard-coding every tenant’s rules into Foundation does not scale. The extension framework lets each instance plug in exactly the behaviour it needs.
Where extension code can be loaded from
@equinox/plugin-registry resolves an extension’s module reference from one of three sources, each guarded by an allowlist environment variable:
| Source | module value example |
Allowlist env var |
|---|---|---|
| npm package / Lambda layer | "my-store-validator" |
ALLOWED_PLUGIN_PACKAGES |
| Local / EFS JS file | "/opt/validators/store.js" |
ALLOWED_PLUGIN_FILE_PREFIXES |
| S3 JS file | "s3://my-bucket/store.js" |
ALLOWED_PLUGIN_S3_BUCKETS |
If a module is not permitted by the corresponding allowlist, it will not load. This prevents arbitrary code execution — only vetted packages/paths/buckets are honoured.
Validator Extensions
A validator extension replaces the built-in domain validation for an entity type. Foundation currently supports these validator extension types (loaded via getExtensions(<type>, pluginConfig)):
storeValidatorsiteValidatorfoundationLocationValidatorsellerValidator
How the service uses it
Example from src/service/store.service.ts (create flow):
// Load custom store validator if configured via pluginConfig (Extension Point)
let customStoreValidator;
try {
customStoreValidator = await getExtensions('storeValidator', null);
} catch {
// Fallback to default validation
}
if (customStoreValidator) {
const result = await customStoreValidator.validate(storeData);
// isWorking === false means the validator could not run — skip and fall back
if (result.isWorking !== false && !result.isValid) {
const err = new Error('STORE_VALIDATION_FAILED');
err.errorCode = ErrorCodes.STORE_VALIDATION_FAILED; // EQ-FND-4220124
err.details = result.errors; // surfaced to the caller
err.extensionDetails = result.details;
throw err;
}
}
// ...then the built-in domain validation runs as well
Behaviour rules
- Precedence: if a custom validator is configured, it runs (the built-in domain rules still apply afterward for core invariants like hierarchy depth).
- Graceful fallback: if the validator cannot be loaded or returns
isWorking: false, Foundation falls back to default validation rather than failing the request. - Failure surface: when a validator rejects a payload, the caller receives
422 STORE_VALIDATION_FAILED(EQ-FND-4220124) with the extension’serrorsindetailsand any extra context inextensionDetails.
Expected validator contract
A validator module exports a class with a validate method returning:
{
isValid: boolean;
isWorking: boolean; // false = "couldn't run, please fall back"
errors: string[]; // human-readable validation errors
details: unknown; // optional structured detail
}
Lifecycle Extensions (pre/post handlers)
Lifecycle extensions run around the normal request handling, driven by instanceContext.properties.pluginConfig.lifecycle. Managed by src/hooks/LifecycleExtensionManager.ts.
Request → PRE-HANDLERS (may ABORT the request) → normal route/controller/service logic → POST-HANDLERS (may REWRITE the response body) → Response
Configuration shape
{
"pluginConfig": {
"lifecycle": {
"preHandlers": {
"enabled": true,
"handlers": [
{
"name": "enrich-store-create",
"routes": ["/v1/stores"], // optional route filter (substring match)
"module": "/opt/ext/enrich.js", // in-process class...
"class": "EnrichStore",
"config": { "...": "..." }
},
{
"name": "remote-guard",
"appUrl": "https://ext.example.com/pre", // ...OR a remote HTTP app
"appId": "<bearer-token>"
}
]
},
"postHandlers": {
"enabled": true,
"handlers": [ /* same shape */ ]
}
}
}
}
Pre-handlers
- Run only if
preHandlers.enabledis true and (whenroutesis set) the current route matches (substringincludes). - In-process handler (
module+ optionalclass): dynamically imported; itspreHandle(context)is called with{ request, reply, instanceId, instanceContext }. - Remote handler (
appUrl): FoundationPOSTs a JSON payload{ handlerName, instanceId, route, method, body, queryParams, config, timestamp }(withAuthorization: Bearer <appId>if provided). If the response has{ abort: true, errorCode, message }, the request is aborted with that error. - Any pre-handler error aborts the request — the error propagates to the client.
Post-handlers
- Run only if
postHandlers.enabledis true and the route matches. - Receive the response data and may return a modified response body, which replaces the original before it is sent to the client.
- Also support in-process (
module/class) and remote (appUrl) forms.
Route scoping
Each handler may carry a routes: string[]. A handler runs only when the current route URL includes one of those strings. Omit routes (or leave it empty) to run on every route.
Security & operational notes
- Allowlists are mandatory in practice — set
ALLOWED_PLUGIN_PACKAGES/ALLOWED_PLUGIN_FILE_PREFIXES/ALLOWED_PLUGIN_S3_BUCKETSto the exact, vetted sources. Unlisted sources will not load. - Remote handlers make outbound HTTP calls on the request path — a slow or failing remote pre-handler will slow or abort requests. Use timeouts and health-check your extension apps.
- Fallback semantics differ by mechanism: a validator that can’t run falls back to default validation; a pre-handler that throws aborts the request. Design accordingly.
- Debugging: the lifecycle manager logs skipped handlers (route mismatch) at debug level and logs pre-handler failures at error level with the handler name.
Source map
| Concern | File |
|---|---|
| Validator loading | src/plugins/pluginRegistry.ts (wraps @equinox/plugin-registry getExtensions) |
| Validator usage | src/service/store.service.ts, and sibling services / src/domain/validator/* |
| Validator interfaces | src/domain/validator/foundationExtensions.ts, baseExtension.ts |
| Lifecycle manager | src/hooks/LifecycleExtensionManager.ts |
| Lifecycle interfaces | src/domain/validator/lifecycleExtensions.ts |
| Config source | instanceContext.properties.pluginConfig |
Revision History
2026-08-04 | AN – Page created and uploaded the contents