EngineAdapters & ports

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

DomainPort tokenDefault adapterDefault behavior
PaymentsPAYMENT_PROVIDERNoopPaymentProviderAdapterThrows 503 Payments are not configured
MailerMAILER_PORTNoopMailerAdapterDiscards mail
Template engineTEMPLATE_ENGINE_PORTNoopTemplateEngineAdapterNo-op render
Invite codes(shared)StubInviteCodeAdapterStub
Org bootstrap(shared)StubOrgBootstrapAdapterStub
Booking notifyBOOKING_NOTIFIERNoopBookingNotifierAdapterDiscards notifications
Booking sessionsBOOKING_SESSION_STOREInMemoryBookingSessionStoreAdapterIn-memory store
Invoice mailerINVOICE_MAILERNoopInvoiceMailerAdapterDiscards mail
Media cleanupMEDIA_CLEANUPPlatformMediaCleanupAdapterPlatform media cleanup
Subscription lookupSUBSCRIPTION_LOOKUPSubscriptionLookupAdapterResolves subscriptions
Settlement bankingSETTLEMENT_BANKINGNoopSettlementBankingAdapterEmpty bank list; blocks writes
Settlement providerSETTLEMENT_PROVIDERNoopSettlementProviderAdapterEmpty settlement page
Settlement notifySETTLEMENT_NOTIFIERNoopSettlementNotifierAdapterLogs and discards
Tenant domainsTENANT_DOMAIN_REPOSITORYNullTenantDomainRepositoryNull 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):

  1. 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 */ }
    }
  2. Provide the token from a module:

    @Global()
    @Module({
      providers: [
        PaystackPaymentAdapter,
        { provide: PAYMENT_PROVIDER, useExisting: PaystackPaymentAdapter },
      ],
      exports: [PAYMENT_PROVIDER],
    })
    export class PaystackPaymentModule {}
  3. Swap it in. Replace the NoopPaymentProviderModule import in engine.module.ts with your module, or override the binding in the paymentProvider option of BookingModule / 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.