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:
| Layer | When to use | API |
|---|---|---|
| Presets | The built-in plugins | registerBasePlugins() / registerAllPlugins() — see Plugin Presets |
registerX(...) factories | Your own custom adapters (recommended) | registerPaymentProvider({ … }) etc. — see Plugin Factories |
Hand-built IPlugin | Custom key/version, HTTP routes, a module, lifecycle hooks | pluginRegistry.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 shapeA 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):
| Director | Purpose | Authoring factory |
|---|---|---|
PaymentDirector | Payment processing | registerPaymentProvider |
DeliveryDirector | Shipping/delivery | registerDeliveryProvider |
WarehousingDirector | Inventory / tokenization | registerPhysicalWarehousing |
WorkerDirector | Background jobs | registerWorker |
FilterDirector | Product/assortment search | registerProductSearchFilter |
ProductPricingDirector | Product prices | registerProductPricing |
OrderPricingDirector | Order totals | registerOrderPricing |
DeliveryPricingDirector | Delivery fees | registerDeliveryPricing |
PaymentPricingDirector | Payment fees | registerPaymentPricing |
OrderDiscountDirector | Order discounts | registerOrderDiscount |
ProductDiscountDirector | Product discounts | registerProductDiscount |
QuotationDirector | RFQ processing | registerQuotation |
EnrollmentDirector | Subscriptions | registerEnrollment |
FileDirector | File storage | registerFileAdapter |
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.
| Domain | Adapter interface | Key behavior to implement | Deep dive |
|---|---|---|---|
| Payment | IPaymentAdapter | typeSupported, actions().{charge, isActive, isPayLaterAllowed, sign, validate, cancel, confirm} | Payment |
| Delivery | IDeliveryAdapter | typeSupported, actions().{send, isActive, isAutoReleaseAllowed, estimatedDeliveryThroughput} | Delivery |
| Warehousing | IWarehousingAdapter | actions().{stock, productionTime, commissioningTime} (physical) / {tokenize, tokenMetadata} (virtual) | Warehousing |
| Pricing | I*PricingAdapter | isActivatedFor, actions().calculate (push rows onto the sheet) | Pricing |
| Discount | IDiscountAdapter | isValidForSystemTriggering/isValidForCodeTriggering, discountForPricingAdapterKey | Order discounts |
| Filter | IFilterAdapter | actions().{searchProducts, searchAssortments, transformProductSelector, …} | Filters |
| Worker | IWorkerAdapter | type, doWork(input, api, workId) | Work Queue |
| File | IFileAdapter | createSignedURL, uploadFileFromStream, createDownloadURL, removeFiles | Files |
| Quotation | IQuotationAdapter | actions().{quote, transformItemConfiguration, submitRequest, …} | Quotation |
| Enrollment | IEnrollmentAdapter | isActivatedFor, 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.
Related
- Plugin Factories — the recommended
registerX()authoring layer - Plugin Presets — registering the built-ins
- Pricing System — the pricing chain and leveled tiers
- Work Queue — background job processing