Interfaces and drivers
Your app calls one interface per capability; a driver talks to the provider you pick in main.yaml. Swap the provider, keep the code.
MainSDK gives your app one interface for each thing almost every app does: sign people in, store records, send email, take payments, call a model. Your code calls the interface. A driver talks to the provider you chose in main.yaml. Changing the provider is a change to main.yaml, not to your code.
Engineers know this pattern as ports and adapters. Dapr uses it for backend services, OpenFeature for flags and OpenTelemetry for telemetry. MainSDK adds guarantees, a policy engine and an MCP surface on top.
Four layers under every call
flowchart LR A[Your code] --> I[Interface<br/>email.send] I --> G[Guarantees<br/>unsubscribe, consent, idempotency] G --> P[Policy engine<br/>consent, region, audit] P --> D[Driver<br/>resend] D --> X[(Provider API)]
- Interface: the call you write, such as
email.send()ordb.orders.for(user).list(). Its name and shape are the same whatever the provider. - Guarantees: what the interface always does. A marketing email without an unsubscribe link fails; a query without an owner scope does not compile.
- Policy engine: decides the call in process (who, which data, which region) and writes the decision to the audit log. See Policy as code.
- Driver: talks to the provider. It declares the hosts, data labels and regions it needs, and nothing else is allowed.
What you write
import { auth, email, models, s } from "mainsdk";
const { orders } = models({
orders: s.object({ item: s.string(), total: s.int() }),
});
export async function POST(req: Request) {
const session = await auth.require(req); // 401 problem details if signed out
const input = await req.json() as { item: string; total: number };
const order = await orders.for(session.user.id).create(input); // owner scope is required
await email.send({ to: session.user.email, template: "receipt", data: { orderId: order.id } });
return Response.json(order, { status: 201 });
}providers:
email: resend # was ses: change this line, no code change
auth:
driver: github
db:
driver: postgres
regions: { us: secret:DATABASE_URL_US, eu: secret:DATABASE_URL_EU }
email:
key: secret:RESEND_API_KEY
fallback:
email: [resend, ses] # fail over if the first is downEach interface has its own page with its calls, drivers, guarantees and check codes, for example auth, db, email and ai.
How seamless a swap is
| Kind of interface | What a swap takes |
|---|---|
| Stateless: email, SMS, push, webhooks out, realtime, AI | Fast. The contract's tests run on the new driver, it sends in shadow mode, then traffic switches. |
| Stateful: users in an auth provider, customers in a payment provider, rows in a database | A guided migration job with a rollback, never a silent switch. |
See Swap a driver.
Capability profiles
Every driver declares what it supports. An access driver lists its models (RBAC, ABAC, ReBAC), listing, conditions and consistency tokens; an auth driver lists the sign-in methods it covers. mainsdk check compares the profile with what your app uses and names the gap before production. There is no silent fallback that weakens a decision.
$ npx mainsdk check
mainsdk check · pinecart · env dev · Main 27
access 1 finding
MAIN-ACCESS-0004 high policies/folders.cedar:1
fix: relations need a ReBAC driver (openfga, spicedb, permit) or Cedar entities
config secrets policy network data
1 gate failed · exit 1The escape hatch
When you need a feature only one provider has, every interface gives you the provider's own client through raw():
import { email } from "mainsdk";
const resend = email.raw(); // logged on each use and listed by mainsdk checkcheck marks each use as bypassing that interface's guarantees, so you always know where you depend on one provider.
Versioned contracts
Every interface is a versioned contract, such as email@1.4. Drivers declare the range they implement (implements: email@^1.2), and mainsdk sync resolves versions into main.lock with hashes. Two majors can run side by side during an upgrade: import mainsdk/email for the current one and mainsdk/email/v2 for the other. A deprecation gives at least 12 months before removal, and every breaking change names its codemod. How contracts are defined and diffed is in Contracts and versioning.
Write your own driver
The driver kit lets anyone build a driver for any interface. The manifest declares the hosts, data labels and regions the driver touches; the harness fails a driver that reaches anything undeclared. mainsdk driver test runs the same conformance suite first-party drivers pass.
import { defineDriver, type EmailMessage } from "mainsdk";
export default defineDriver({
implements: "email@1",
id: "relaymail",
manifest: {
regions: ["us", "eu", "in"],
hosts: ["api.relaymail.dev"],
data: ["email-address", "message-body"],
subprocessors: ["Relaymail Inc."],
},
async send(msg: EmailMessage, ctx: { http: { post(path: string, body: unknown): Promise<{ id: string }> } }) {
const res = await ctx.http.post("/v1/send", msg);
return { id: res.id };
},
});defineDriver comes from mainsdk today. Whether the driver kit becomes its own package or part of @mainsdk/testing is still open.
Next
Was this page helpful?