Domain modelling with the SDK
A worked slice — Zod schemas, a use-case, an oRPC procedure and a TanStack Query hook, with Connect at the edges.
The SDK is deliberately not an application framework. It answers questions about identity, entitlement and storage; your domain is yours. This is one worked slice showing where the seams go.
The example is a campaign. It has rules (you cannot send an empty one, or one whose plan quota is spent), it is stored, and it is rendered.
The layering
One direction, all the way down:
schema (Zod) → use-case → procedure (oRPC) → hook (TanStack Query) → component
↓
Connect SDKA use-case may call Connect. A component never does — it calls a hook, which calls a procedure. The two rules that make this hold:
- Connect calls happen on the server.
@cosmoops/connect-sdk/serverholds your API key and is fenced off from browser bundles by an export condition — importing it from a client component fails the build rather than shipping the key. - Entitlement is checked in the use-case, not the component. A gate in the UI is a courtesy to the user; a check in the use-case is the one that actually holds.
1. The schema is the contract
import { z } from "zod";
export const CampaignStatus = z.enum(["draft", "sending", "sent", "failed"]);
export const Campaign = z.object({
id: z.string(),
subject: z.string().min(1).max(200),
body: z.string().min(1),
status: CampaignStatus,
recipientCount: z.number().int().nonnegative(),
});
export const CreateCampaignInput = Campaign.pick({ subject: true, body: true });
export type Campaign = z.infer<typeof Campaign>;
export type CreateCampaignInput = z.infer<typeof CreateCampaignInput>;One schema, three jobs: it validates the input, it types the use-case, and it becomes the API contract. A hand-written interface beside a validator is how the two come to disagree.
2. The use-case holds the rules
import { ConnectClient } from "@cosmoops/connect-sdk/server";
import { CreateCampaignInput, type Campaign } from "./campaign.schema";
export class QuotaExceededError extends Error {}
export async function createCampaign(
connect: ConnectClient,
workspaceId: string,
input: CreateCampaignInput,
): Promise<Campaign> {
// Entitlement first, and on the server. A `ConnectFeatureGate` in the UI
// hides the button; this is what stops a direct API call.
const usage = await connect.billing.getAddonUsage(workspaceId);
const credits = usage.addons.find((addon) => addon.addonId === "campaign-sends");
if (credits && credits.remaining <= 0) {
throw new QuotaExceededError("No campaign sends left on this plan's cycle.");
}
const created = await connect.data.create({
workspaceId,
collection: "campaigns",
data: { ...input, status: "draft", recipientCount: 0 },
});
// Product analytics, not billing. Fire-and-forget by design — a metrics
// failure must never fail the thing it was measuring.
void connect.analytics.emit([
{ name: "campaign.created", workspaceId, props: { subject: input.subject } },
]);
return { id: created.id, ...(created.data as Omit<Campaign, "id">) };
}Three things worth noticing.
The store is Connect's, so there is no Firestore client, no security rules
and no second credential. campaigns is declared on your application's Store
tab; an undeclared name is refused rather than created.
_createdAt/_createdBy come back for free and cannot be forged — Connect
writes them and refuses any _-prefixed field from a caller.
The quota check is where the money is. getAddonUsage reads the balance;
consumeAddon debits it when the campaign actually sends, not when it is
drafted.
3. The procedure is thin
import { os } from "@orpc/server";
import { getConnectSession } from "@cosmoops/connect-sdk/next";
import { CreateCampaignInput, Campaign } from "@/domain/campaign/campaign.schema";
import { createCampaign } from "@/domain/campaign/create-campaign.use-case";
import { connect } from "@/server/connect";
/** Resolves the caller once. The workspace comes from their session, never the input. */
const authed = os.use(async ({ next }) => {
const session = await getConnectSession();
if (!session) throw new Error("UNAUTHORIZED");
return next({ context: { session } });
});
export const router = {
createCampaign: authed
.input(CreateCampaignInput)
.output(Campaign)
.handler(({ input, context }) =>
createCampaign(connect, context.session.user.workspaceId, input),
),
};The workspace comes from the session
context.session.user.workspaceId, never an input field. A workspaceId a
caller can supply is a cross-tenant write one forgotten check away — and
since the session is what a workspace switch rotates, reading it here is also
what makes switching work without touching this code.
The handler resolves a use-case and returns it. Authorization lives in the middleware; business rules live in the use-case. Neither belongs here.
4. The hook owns cache behaviour
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { client } from "@/lib/orpc";
export const campaignKeys = {
all: ["campaigns"] as const,
list: (filter: string) => [...campaignKeys.all, filter] as const,
};
export function useCreateCampaign() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: client.createCampaign,
onSuccess: () => {
// Prefix invalidation — every list, whatever its filter.
queryClient.invalidateQueries({ queryKey: campaignKeys.all });
},
});
}One hook per operation, keys in one place, invalidation by prefix. The keys being a shared object rather than inline arrays is what stops a list somewhere else in the app from quietly never refreshing.
When not to be optimistic
Optimistic updates are right when you know the outcome at click time. Three kinds of mutation here do not qualify, and patching the cache for them shows the user something that is about to be untrue:
- anything that settles on a webhook — payments, verification, uploads
- a workspace switch — it rotates the session; reload instead
- a store write that could hit a quota — the server may refuse it
5. The component
"use client";
import { useConnectFeature } from "@cosmoops/connect-sdk/client";
import { ConnectPageShell, ConnectSection } from "@cosmoops/connect-sdk/ui";
import { useCreateCampaign } from "@/hooks/use-create-campaign";
export default function NewCampaign() {
const canSend = useConnectFeature("campaigns");
const create = useCreateCampaign();
return (
<ConnectPageShell title="New campaign">
<ConnectSection title="Content">
<CampaignForm
isDisabled={!canSend}
isPending={create.isPending}
onSubmit={(values) => create.mutate(values)}
/>
</ConnectSection>
</ConnectPageShell>
);
}useConnectFeature disables the form; the use-case refuses the write. Both,
not either — the first is the explanation, the second is the enforcement.
Where each piece lives
| Concern | Owner |
|---|---|
| Who is signed in, which workspace | Connect (getConnectSession) |
| What they may do on your application | Connect (permissions) |
| Whether their plan includes a feature | Connect (useConnectFeature) |
| Whether an action is allowed | Your use-case |
| What the data means | Your Zod schema |
| Where it is stored | Connect (connect.data / connect.kv) |
| Cache and invalidation | Your hooks |
The line worth holding: Connect answers questions about the customer; your code answers questions about your product. Anything that starts "should this user be able to…" is a question about the customer, and asking it yourself means keeping a second copy of an answer Connect already has.