Adapters & ports
Engine packages never import one another directly. When a package needs a capability it does not own — sending mail, charging a card, listing settlement banks — it depends on a port: a DI token plus an abstract contract. The engine shell binds an adapter (a concrete implementation) to each port.
This is what makes a payment gateway, mailer, or settlement provider a drop-in for a box operator.
The pattern
- A port is a
Symbol.for('@withpotter/<pkg>:<NAME>')token plus an abstract class or interface describing the methods. - An adapter implements that contract.
- The engine binds the adapter to the token, either by importing a module that
provides it (often
@Global()) or via the consuming module’s.forRoot()options.
Box ships noop defaults for every port so a fresh box boots and runs. An operator overrides only the ports they want to make real.
Built-in ports & default adapters
| Domain | Port token | Default adapter | Default behavior |
|---|---|---|---|
| Payments | PAYMENT_PROVIDER | NoopPaymentProviderAdapter | Throws 503 Payments are not configured |
| Mailer | MAILER_PORT | NoopMailerAdapter | Discards mail |
| Template engine | TEMPLATE_ENGINE_PORT | NoopTemplateEngineAdapter | No-op render |
| Invite codes | (shared) | StubInviteCodeAdapter | Stub |
| Org bootstrap | (shared) | StubOrgBootstrapAdapter | Stub |
| Booking notify | BOOKING_NOTIFIER | NoopBookingNotifierAdapter | Discards notifications |
| Booking sessions | BOOKING_SESSION_STORE | InMemoryBookingSessionStoreAdapter | In-memory store |
| Invoice mailer | INVOICE_MAILER | NoopInvoiceMailerAdapter | Discards mail |
| Media cleanup | MEDIA_CLEANUP | PlatformMediaCleanupAdapter | Platform media cleanup |
| Subscription lookup | SUBSCRIPTION_LOOKUP | SubscriptionLookupAdapter | Resolves subscriptions |
| Settlement banking | SETTLEMENT_BANKING | NoopSettlementBankingAdapter | Empty bank list; blocks writes |
| Settlement provider | SETTLEMENT_PROVIDER | NoopSettlementProviderAdapter | Empty settlement page |
| Settlement notify | SETTLEMENT_NOTIFIER | NoopSettlementNotifierAdapter | Logs and discards |
| Tenant domains | TENANT_DOMAIN_REPOSITORY | NullTenantDomainRepository | Null repository |
Payments
The payment port is the one most operators replace, so it is worth detailing.
The contract
A payment provider extends the abstract PaymentProvider class from
@withpotter/payments:
export abstract class PaymentProvider {
abstract readonly name: string
// Start a payment; returns a reference / redirect for the buyer.
abstract initialize(params: InitializePaymentParams): Promise<InitializePaymentResult>
// Confirm a payment's status by reference.
abstract verify(reference: string): Promise<VerifyPaymentResult>
// Validate a webhook signature and parse its payload.
abstract verifyWebhook(
signature: string,
payload: unknown,
): WebhookVerificationResult | Promise<WebhookVerificationResult>
}
export const PAYMENT_PROVIDER = Symbol.for('@withpotter/payments:PAYMENT_PROVIDER')The PAYMENT_PROVIDER token is consumed by BookingModule, InvoiceModule,
and SubscriptionsModule — booking checkout, invoice payment, and recurring
billing. Bind the token once and all three resolve the same provider.
The default
The shell binds NoopPaymentProviderAdapter, whose initialize() and verify()
throw ServiceUnavailableException('Payments are not configured in this environment'). It is provided globally:
@Global()
@Module({
providers: [
NoopPaymentProviderAdapter,
{ provide: PAYMENT_PROVIDER, useExisting: NoopPaymentProviderAdapter },
],
exports: [PAYMENT_PROVIDER, NoopPaymentProviderAdapter],
})
export class NoopPaymentProviderModule {}Replacing it with a real gateway
To wire a real provider (for example Paystack):
-
Write an adapter that extends
PaymentProvider:@Injectable() export class PaystackPaymentAdapter extends PaymentProvider { readonly name = 'paystack' async initialize(params) { /* call Paystack, return reference + redirect */ } async verify(reference) { /* call Paystack verify */ } verifyWebhook(signature, payload) { /* validate HMAC signature */ } } -
Provide the token from a module:
@Global() @Module({ providers: [ PaystackPaymentAdapter, { provide: PAYMENT_PROVIDER, useExisting: PaystackPaymentAdapter }, ], exports: [PAYMENT_PROVIDER], }) export class PaystackPaymentModule {} -
Swap it in. Replace the
NoopPaymentProviderModuleimport inengine.module.tswith your module, or override the binding in thepaymentProvideroption ofBookingModule/InvoiceModule/SubscriptionsModule’s.forRoot()call.
The engine shell reads no payment gateway environment variables itself.
Provider credentials (e.g. PAYSTACK_SECRET_KEY) are read by the adapter you
bind, keeping gateway secrets out of the shell.
Webhooks
verifyWebhook(signature, payload) validates an inbound gateway webhook before
the engine acts on it. Always verify the signature against the raw body — never
trust an unverified webhook to mark an order paid.