Skip to content

Lifecycle events

Lasagna emits a typed event at every meaningful tenant state transition. Each event is a class extending the AdonisJS BaseEvent, so you subscribe with the standard emitter.on(EventClass, listener) API and get full payload typing for free.

Tenant lifecycle

EventPayloadDispatched by
TenantCreatedtenanttenant:create command, POST /admin/.../tenants
TenantProvisionedtenantInstallTenant job (after schema/database is ready)
TenantActivatedtenanttenant:activate command, POST .../activate
TenantSuspendedtenanttenant:suspend command, POST .../suspend
TenantUpdatedtenant, changesAvailable for host code; not auto-dispatched
TenantMigratedtenant, direction: 'up' | 'down'tenant:migrate and tenant:migrate:rollback
TenantBackedUptenant, metadata: BackupMetadataBackupTenant job
TenantRestoredtenant, fileNameRestoreTenant job
TenantClonedsource, destination, result: CloneResultCloneTenant job
TenantQuotaExceededtenant, quota, limit, current, attemptedQuotaService.consume() when an atomic check rejects the increment
QuotaTrackedtenant, quota, amount, totalQuotaService.track / consume when plans.emitTracked is on (drives the Stripe metering bridge)
TenantEnteredMaintenancetenant, message: string | nulltenant:maintenance command, POST .../maintenance
TenantExitedMaintenancetenanttenant:maintenance --off, DELETE .../maintenance
TenantDeletedtenanttenant:destroy command, UninstallTenant job, DELETE .../tenants/:id

TenantUpdated

The class is exported and ready to dispatch from host code (e.g. an admin controller mutating tenant metadata), but Lasagna does not emit it on its own. If you maintain a typed audit trail, dispatch it from the same writer that mutates the row.

Billing events

Available when --with=billing is configured. All ten are dispatched from ProcessStripeEventJob in response to verified Stripe webhook events. Full reference (and the dunning/ordering semantics) lives in the Billing satellite.

EventPayloadDispatched by
SubscriptionActivatedtenantId, stripeSubscriptionId, planNamecustomer.subscription.created (or .updated flipping to active)
SubscriptionUpdatedtenantId, stripeSubscriptionId, previousPlan, newPlancustomer.subscription.updated when plan changes
SubscriptionCanceledtenantId, stripeSubscriptionId, previousPlan, reasoncustomer.subscription.deleted (reason: user_canceled | dunning_failed | unknown)
SubscriptionPausedtenantId, stripeSubscriptionIdStripe pause-collection or customer.subscription.paused
SubscriptionResumedtenantId, stripeSubscriptionIdcustomer.subscription.resumed
TrialEndingtenantId, stripeSubscriptionId, daysLeftcustomer.subscription.trial_will_end
PaymentSucceededtenantId, invoiceId, amount, currencyinvoice.payment_succeeded
PaymentFailedtenantId, invoiceId, amount, currency, attempts, final, nextRetryinvoice.payment_failed (every attempt — match on final: true for the terminal step)
BillingMisconfiguredstripeSubscriptionId, productId, priceIdA Stripe product/price has no mapping in config.billing.products.
BillingEventDeadLetteredeventId, errorCode, detailsWebhook event exhausted all queue retries. errorCode is a stable enum (BillingErrorCode | 'unhandled_error').

Subscribe paging to BillingEventDeadLettered

This is the canary for "we permanently failed to process a Stripe event". Wire PagerDuty / Slack / Sentry to it. The payload is PII-safe: only the event id, an opaque code, and an optional package-controlled detail string.

Resilience

Dispatched by ResilienceService when a wrapped backing-dependency call fails and the configured degradation policy kicks in. Full detail on the Resilience page.

EventPayloadDispatched by
DependencyDegradeddependency, operation, tenantId, policy, errorCodeA Redis/Postgres/Stripe call wrapped by ResilienceService.run() failed (for example QuotaService on a Redis outage). Gated by config.resilience.observe.

Subscribe paging to DependencyDegraded

A burst of these means a backing service is down. The payload is alert-safe: a dependency name, an operation label, an optional tenant id, the applied policy, and a best-effort error code. No driver message, no PII.

Subscribing

Register listeners during boot — usually inside a service provider's boot() hook so they're attached before any tenant request hits the container:

ts
import emitter from '@adonisjs/core/services/emitter'
import {
  TenantProvisioned,
  TenantQuotaExceeded,
} from '@adonisjs-lasagna/saas-tenancy/events'

export default class AppProvider {
  async boot() {
    emitter.on(TenantProvisioned, async (event) => {
      // event.tenant is fully typed (TenantModelContract)
      await sendWelcomeEmail(event.tenant)
    })

    emitter.on(TenantQuotaExceeded, async (event) => {
      // payload arrived in the constructor order from src/events/
      logger.warn(
        { tenantId: event.tenant.id, quota: event.quota, attempted: event.attempted },
        'Quota threshold breached'
      )
    })
  }
}

Dispatching from your own code

Every event class exposes the static dispatch(...args) helper. The arguments mirror the constructor exactly, so TypeScript catches payload mismatches at compile time:

ts
import { TenantUpdated } from '@adonisjs-lasagna/saas-tenancy/events'

await tenant.merge({ name: newName }).save()
await TenantUpdated.dispatch(tenant, {
  name: { from: previousName, to: newName },
})

Async semantics

emitter.emit() runs every listener in parallel. If a listener throws, the rejection propagates to the awaited emit() call but sibling listeners still run. If you need ordering or want one bad listener to block the others, dispatch through a queue job instead of listening inline.

For batch use cases — long-running mailers, webhook fan-out, large DB writes — push the work onto a tenant queue from inside the listener so the dispatch path stays cheap:

ts
emitter.on(TenantBackedUp, async (event) => {
  await new TenantQueueService().dispatch(event.tenant.id, 'NotifyBackupReady', {
    file: event.metadata.file,
    size: event.metadata.size,
  })
})

Testing

Use emitter.fake([...EventClasses]) to capture dispatches in tests without invoking real listeners. The returned buffer exposes assertEmitted / assertEmittedCount / assertNotEmitted:

ts
import emitter from '@adonisjs/core/services/emitter'
import { TenantSuspended } from '@adonisjs-lasagna/saas-tenancy/events'

test('suspending a tenant emits TenantSuspended', async ({ client }) => {
  const buffer = emitter.fake([TenantSuspended])
  await client.post(`/admin/multitenancy/tenants/${tenant.id}/suspend`)
  buffer.assertEmittedCount(TenantSuspended, 1)
  emitter.restore()
})

The integration suite covers every event in tests/integration/events/lifecycle_dispatch.spec.ts.

  • Jobs — most events are dispatched from inside a job
  • Quotas — source of TenantQuotaExceeded
  • Contextual logging — listener log lines inherit the active tenantId

Was this page helpful?

Released under the MIT License.