Skip to main content

Plugin System (Directors & Adapters)

The Director/Adapter pattern is the foundation of Unchained Engine's extensibility. Understanding it is essential for customizing payment processing, delivery, pricing, and other behaviors.

Overview​

Adapters implement a specific behavior (a payment gateway, a pricing rule, a delivery method…). Directors are the internal machinery that selects and invokes the right adapter(s) of a domain at runtime. The adapters themselves are held by the plugin registry (pluginRegistry); each director looks up the adapters whose adapterType symbol matches its domain. In v5 you rarely touch a director directly: registering the plugin is enough.

How to register a plugin​

There are three ways, recommended-first:

LayerWhen to useAPI
PresetsThe built-in pluginsregisterBasePlugins() / registerAllPlugins() — see Plugin Presets
registerX(...) factoriesYour own custom adapters (recommended)registerPaymentProvider({ … }) etc. — see Plugin Factories
Hand-built IPluginCustom key/version, HTTP routes, a module, lifecycle hookspluginRegistry.register(MyPlugin)

The preset functions are imported from @unchainedshop/plugins/presets/base / @unchainedshop/plugins/presets/all; the registerX() factories and pluginRegistry from @unchainedshop/core. Register before calling startPlatform().

import { registerPaymentProvider, pluginRegistry } from '@unchainedshop/core';

// Recommended: a factory builds and registers the plugin for you
registerPaymentProvider({ adapterId: 'acme', charge: async (c, ctx) => ({ transactionId: '…' }) });

// Low-level: hand-built IPlugin (custom key/version, routes, lifecycle hooks)
pluginRegistry.register({ key: 'com.acme.payment', label: 'Acme', version: '1.0.0', adapters: [AcmeAdapter] });
IPlugin shape

A plugin bundles one or more adapters with optional infrastructure:

interface IPlugin {
key: string;
label: string;
version: string;
adapters?: IBaseAdapter[]; // self-route to their director
module?: PluginModuleFactory; // DB-backed module
routes?: PluginHttpRoute[]; // WHATWG Fetch handlers (e.g. webhooks)
onRegister?: (api) => void | boolean | Promise<void | boolean>;
onShutdown?: (api) => void | Promise<void>;
}

pluginRegistry.register() records the plugin. During startPlatform(), its module is initialized before onRegister runs. Returning false or throwing from onRegister logs a warning and excludes the plugin's adapters and routes; it does not abort startup or undo its module. The Express/Fastify connector mounts the remaining routes. onShutdown runs for all registered plugins, including skipped ones.

Available Directors​

You don't register with these — they're the internal dispatch targets. Listed for reference (and for cross-module work):

DirectorPurposeAuthoring factory
PaymentDirectorPayment processingregisterPaymentProvider
DeliveryDirectorShipping/deliveryregisterDeliveryProvider
WarehousingDirectorInventory / tokenizationregisterPhysicalWarehousing
WorkerDirectorBackground jobsregisterWorker
FilterDirectorProduct/assortment searchregisterProductSearchFilter
ProductPricingDirectorProduct pricesregisterProductPricing
OrderPricingDirectorOrder totalsregisterOrderPricing
DeliveryPricingDirectorDelivery feesregisterDeliveryPricing
PaymentPricingDirectorPayment feesregisterPaymentPricing
OrderDiscountDirectorOrder discountsregisterOrderDiscount
ProductDiscountDirectorProduct discountsregisterProductDiscount
QuotationDirectorRFQ processingregisterQuotation
EnrollmentDirectorSubscriptionsregisterEnrollment
FileDirectorFile storageregisterFileAdapter

Adapter contracts​

An adapter is a plain object: identity fields (key, label, version) plus the behavior the director expects. Payment/delivery/warehousing expose their behavior through an actions(config, context) factory; pricing adapters expose actions().calculate, while discount adapters expose validation and discount-configuration actions. File adapters expose storage methods directly. The matching registerX factory lets you supply just the behavior without writing the wrapper.

DomainAdapter interfaceKey behavior to implementDeep dive
PaymentIPaymentAdaptertypeSupported, actions().{charge, isActive, isPayLaterAllowed, sign, validate, cancel, confirm}Payment
DeliveryIDeliveryAdaptertypeSupported, actions().{send, isActive, isAutoReleaseAllowed, estimatedDeliveryThroughput}Delivery
WarehousingIWarehousingAdapteractions().{stock, productionTime, commissioningTime} (physical) / {tokenize, tokenMetadata} (virtual)Warehousing
PricingI*PricingAdapterisActivatedFor, actions().calculate (push rows onto the sheet)Pricing
DiscountIDiscountAdapterisValidForSystemTriggering/isValidForCodeTriggering, discountForPricingAdapterKeyOrder discounts
FilterIFilterAdapteractions().{searchProducts, searchAssortments, transformProductSelector, …}Filters
WorkerIWorkerAdaptertype, doWork(input, api, workId)Work Queue
FileIFileAdaptercreateSignedURL, uploadFileFromStream, createDownloadURL, removeFilesFiles
QuotationIQuotationAdapteractions().{quote, transformItemConfiguration, submitRequest, …}Quotation
EnrollmentIEnrollmentAdapterisActivatedFor, transformOrderItemToEnrollmentPlan, actions().{configurationForOrder, nextPeriod}Enrollment

Each deep-dive page shows the full method signatures and a worked example — leading with the registerX factory and falling back to the hand-built form.

Best Practices​

1. Use stable, namespaced keys​

Factories namespace keys for you (shop.unchained.<domain>.<adapterId>). For hand-built plugins, use a reverse-DNS key like com.mycompany.payment.gateway. A stable key makes registration idempotent (the registry dedupes by key).

2. Report configuration errors, don't throw​

Return a configurationError() for missing configuration rather than throwing — the provider is then surfaced as misconfigured instead of crashing checkout:

configurationError() {
// one of: ADAPTER_NOT_FOUND | NOT_IMPLEMENTED | INCOMPLETE_CONFIGURATION | WRONG_CREDENTIALS
if (!process.env.API_KEY) return 'INCOMPLETE_CONFIGURATION';
return null;
}

For plugins, return false or throw in onRegister to skip their adapters and routes during startup.

3. Offload long work to the Worker queue​

For slow external calls, enqueue work instead of blocking the adapter:

async send() {
await context.modules.worker.addWork({ type: 'EXTERNAL_SHIPPING_API', input: { orderId: order._id } });
return false; // not complete yet
}

4. Mind the pricing orderIndex​

Pricing/discount adapters run in ascending orderIndex. The built-ins use: base price (0) → conversions/discounts (10–40) → taxes (80). See Order Index Guidelines.