Patterns
A plugin that follows the contract works. A plugin that follows the patterns composes: it coexists with the other plugins, accepts a real backend without a rewrite, and can be promoted to official. This page is the set of conventions that make that difference.
Factory, not instance
A plugin is exported as a factory — a function that returns the PluginManifest — not as a ready-made object. That lets whoever assembles the app inject options (a provider, a menu position, a vertical) without editing the plugin:
export function createLoyaltyPlugin(options?: LoyaltyPluginOptions): PluginManifest {
// ...
}
Wiring the plugin up means calling the factory in the plugins array. Configuring it means passing options. It's never editing the plugin's internals.
Provider-first: data is a contract
A plugin's UI never talks to a database directly. It talks to a DataProvider — an interface every backend implements. The plugin defines the contract in data/types.ts, ships a mock and a supabase one, and picks between them with createSafeDataProvider:
const provider =
options?.dataProvider ??
createSafeDataProvider(
() => createSupabaseLoyaltyProvider(), // used when a backend is available
() => createMockLoyaltyProvider(), // fallback without a database
)
The payoff: the plugin runs on day zero (mock), and switching to real data touches no screen — it only switches the provider. It's the same pattern that makes a plugin testable without infrastructure (see Test and debug).
The provider boundary: talk to Fayz, not to the provider
The rule fayz doctor polices: an app or plugin does not import provider SDKs directly (@supabase/supabase-js, mercadopago, stripe, googleapis, …). It gets access through the Fayz boundary — getSupabaseClientOptional() / the DataProvider interface — or through the connector spine.
import { getSupabaseClientOptional, getActiveTenantId } from '@fayz-ai/core'
// ✔ goes through the Fayz boundary, scoped by tenant
// import { createClient } from '@supabase/supabase-js'
// -> doctor would flag this as a boundary break
There's one deliberate exception: an app-local plugin may own a connector + Edge Function for a provider Fayz doesn't support yet — because the credential stays on the server and the app still calls its own boundary, not the provider from the browser. That exception is declared with a marker on the import line, not casually.
The supported surface
An app only depends on packages from the supported public surface — @fayz-ai/sdk, core, saas, ui, auth, db, storefront, shop and the @fayz-ai/plugin-* ones. Importing an internal subpath or a package outside that list makes doctor warn (off-surface-import / off-surface-dependency). The rule exists to keep upgrades safe: anything not on the surface can change without notice.
plg_ tables and core reuse
By convention, a plugin installs its own tables with the plg_ prefix (which is what shop and courses do — plg_shop_products, plg_courses_*) to make ownership obvious. The convention is recommended, not universal: not every module follows it — the openbanking connector, for instance, creates bank_integrations with no prefix. What the plugin does not create, it reuses: it references the core library tables it needs instead of copying them. A plugin never redefines a core entity; if it needs to know "who booked", it references public.people. One source of truth per entity.
The migrations a plugin ships follow rules that make fayz db apply reproducible:
- ordered — the apply sequence is deterministic.
- versioned — every migration has a version; the runner knows what already ran.
- fix-forward — migrations are append-only: never edit one that's already been applied, write the next one that fixes it. There is no
.down.sql. - idempotent — running again neither breaks nor duplicates.
Isolation, always
Every plg_ table a plugin owns carries tenant_id and RLS in the canonical form. This isn't optional or a "later" thing — it's the condition for the table to exist in the model at all. The details are in RLS and multi-tenancy.
Enforcement is soft by design: fayz doctor reports these violations as warnings, it doesn't fail the build. The idea is visibility without getting in the way of the DX while you're experimenting. But a plugin that wants to graduate to official needs a clean doctor run.
Next: exercise the app and catch breakage early in Test and debug.