Skip to content
Zap Studio
permit
Esc
navigateopen⌘Jpreview
On this page

Scaling Policies

Split a policy with many resources and actions across files while keeping one type-safe source of truth.

A policy with a handful of resources fits comfortably in one file. Once your app has a dozen resources — each with several actions and rules referencing different parts of your domain — that one createPolicy() call becomes hard to navigate and merge-conflict-prone to edit. Split it by domain instead.

Split Rules Across Files, Keep One Policy

The recommended pattern: each domain owns a file that exports its own resources, actions, and rules slice. A central file composes them into a single resources, a single actions, and a single rules object, then calls createPolicy() once. You still get one policy with one shared permission-string type — "post:read", "comment:write", and so on all type-check against the same can() — but no file holds more than one domain’s worth of rules.

Every domain file below imports one shared context type from context.ts:

export type AppContext = {
  user: { id: string; role: "guest" | "user" | "admin" } | null;
};
import { deny, when } from "@zap-studio/permit";
import type { ActionPolicyMap, Actions, Resources } from "@zap-studio/permit";
import { z } from "zod";
import type { AppContext } from "../context.js";

export const postResources = {
  post: z.object({
    id: z.string(),
    authorId: z.string(),
    visibility: z.enum(["public", "private"]),
  }),
} satisfies Resources;

export const postActions = {
  post: ["read", "write", "delete"],
} as const satisfies Actions<typeof postResources>;

export const postRules: ActionPolicyMap<
  AppContext,
  (typeof postActions)["post"][number],
  z.infer<typeof postResources.post>
> = {
  read: when((_, __, post) => post.visibility === "public"),
  write: when((ctx, _, post) => ctx.user?.id === post.authorId),
  delete: deny(),
};
import { allow, when } from "@zap-studio/permit";
import type { ActionPolicyMap, Actions, Resources } from "@zap-studio/permit";
import { z } from "zod";
import type { AppContext } from "../context.js";

export const commentResources = {
  comment: z.object({
    id: z.string(),
    postId: z.string(),
    authorId: z.string(),
  }),
} satisfies Resources;

export const commentActions = {
  comment: ["read", "write"],
} as const satisfies Actions<typeof commentResources>;

export const commentRules: ActionPolicyMap<
  AppContext,
  (typeof commentActions)["comment"][number],
  z.infer<typeof commentResources.comment>
> = {
  read: allow(),
  write: when((ctx, _, comment) => ctx.user?.id === comment.authorId),
};
import { createPolicy } from "@zap-studio/permit";
import type { Actions, Resources, Rules } from "@zap-studio/permit";
import type { AppContext } from "../context.js";
import { commentActions, commentResources, commentRules } from "./comment.js";
import { postActions, postResources, postRules } from "./post.js";

const resources = {
  ...postResources,
  ...commentResources,
} satisfies Resources;

const actions = {
  ...postActions,
  ...commentActions,
} as const satisfies Actions<typeof resources>;

const rules: Rules<AppContext, typeof resources, typeof actions> = {
  post: postRules,
  comment: commentRules,
};

export const policy = createPolicy<AppContext, typeof resources, typeof actions>({
  resources,
  actions,
  rules,
});
import { policy } from "./policies/index.js";

await policy.can(ctx, "post:read", post);
await policy.can(ctx, "comment:write", comment);

Add a new domain by adding a file and one spread in each of the three objects in policies/index.ts. Nothing else changes.

When to Merge Separate Policies Instead

If your domains are independent enough that they’re built and owned separately — different packages, different teams, policies that only come together at the edge of your app — build each as its own createPolicy() with its own narrower resources and actions, then combine them with mergePoliciesOr():

import { createPolicy, deny, when } from "@zap-studio/permit";
import type { Actions, Resources } from "@zap-studio/permit";
import { z } from "zod";
import type { AppContext } from "../context.js";

const postResources = {
  post: z.object({
    id: z.string(),
    authorId: z.string(),
    visibility: z.enum(["public", "private"]),
  }),
} satisfies Resources;

const postActions = {
  post: ["read", "write", "delete"],
} as const satisfies Actions<typeof postResources>;

export const postPolicy = createPolicy<AppContext, typeof postResources, typeof postActions>({
  resources: postResources,
  actions: postActions,
  rules: {
    post: {
      read: when((_, __, post) => post.visibility === "public"),
      write: when((ctx, _, post) => ctx.user?.id === post.authorId),
      delete: deny(),
    },
  },
});
import { mergePoliciesOr } from "@zap-studio/permit";
import { postPolicy } from "./policies/post.js";
import { commentPolicy } from "./policies/comment.js"; // built the same way

const policy = mergePoliciesOr(postPolicy, commentPolicy);

await policy.can(ctx, "post:read", post); // routed to postPolicy
await policy.can(ctx, "comment:write", comment); // routed to commentPolicy

This works because a policy denies any permission for a resource type it doesn’t define — postPolicy only knows about "post", so it denies every "comment:*" check without commentPolicy’s rules ever coming into play. With mergePoliciesOr(), each permission check is effectively routed to the one policy that owns that resource type.

Which Approach to Use

Split rules, one policy Separate policies, merged
Permission strings One shared type across all domains Only valid within each domain’s own policy
Cross-domain rules (e.g. a comment rule that reads the parent post) Straightforward — one rules object Not possible — each policy only sees its own resources
Ownership One codebase, split by file Can live in separate packages
Composition Compile-time, via object spread Runtime, via mergePoliciesOr()

Default to splitting rules across files with one policy. Reach for merging separate policies only when domains are genuinely owned and shipped independently.

Best Practices

  1. One file per domain — a domain’s resources, actions, and rules live together, next to the code that uses them
  2. Keep the composition file thinpolicies/index.ts should only spread and call createPolicy(), not contain any rule logic itself
  3. Name exports after the domainpostResources, postActions, postRules scan easily across files
  4. Share the context type — import one AppContext type everywhere instead of redefining it per domain
  5. Reach for mergePoliciesOr() only across real boundaries — package, team, or deployment boundaries, not just “this file got long”

Last updated on September 21, 2026

Was this page helpful?