Gating Enterprise Without Forking the Core

A guide to open core: gating paid features without maintaining two copies of your codebase.

The standard way to sell enterprise features on top of open source is a private fork: one repo that’s open, one that’s open plus extras. It works for about three months. Then a bug gets fixed in one copy and not the other, and you’re maintaining two products that happen to share a name.

There is a cleaner structure. Keep one core that builds on its own. Put enterprise in a separate package that depends on core, never the reverse. Include features at build time, enforce permission at runtime. This post is the full pattern, with generic code you can adapt to any stack.

The dependency rule

Enterprise may import from core. Core may never import from enterprise.

Enforce it mechanically, not by convention. Two checks in CI are enough:

Terminal window
# 1. Core builds with enterprise deleted.
rm -rf ./enterprise && npm run build
# 2. No import of enterprise exists inside core.
rg "from ['\"]enterprise|from ['\"]\.\./enterprise" core/src

If both pass, the boundary is real: the community artifact physically cannot contain enterprise code, because it was never imported. There is nothing to leak behind a flag.

The resulting layout:

core/ MIT, builds alone
src/
edition.ts the seam (interfaces only)
enterprise/ private, depends on core
src/
edition.ts implements the seam
license/ verification + policy
sso/ enterprise auth
audit/ enterprise trails
seats/ entitlement checks
server.ts composition root

Core defines the seam

Core declares what “an edition of this app” looks like. It does not know what the enterprise implementation is, or whether enterprise exists at all:

core/edition.ts
export interface Edition {
id: "community" | "enterprise";
capabilities: string[];
hooks: {
beforeInvite?(ctx: InviteContext): Promise<void>;
onEvent?(event: AppEvent): Promise<void>;
};
registerRoutes(app: App): void;
}
export const CommunityEdition: Edition = {
id: "community",
capabilities: [],
hooks: {},
registerRoutes() {},
};

The app factory takes an edition and nothing else:

core/app.ts
export function createApp(edition: Edition) {
const app = new App();
app.use(authRoutes);
app.use(workspaceRoutes);
edition.registerRoutes(app);
return app;
}

The point of the interface is what it eliminates: if (isEnterprise) scattered through core. Flag checks feel flexible until there are thirty of them and nobody can say what community actually runs. With a seam, either the route is registered or it isn’t. Either the hook runs or it doesn’t. The whole enterprise surface is enumerable from one file.

Enterprise implements the seam

Enterprise provides the implementation. It never patches core files — it only fills the slots core left open:

enterprise/edition.ts
import type { Edition } from "myapp-core";
export function createEnterpriseEdition(deps: Deps): Edition {
return {
id: "enterprise",
capabilities: ["sso", "audit", "seats"],
hooks: {
beforeInvite: (ctx) => deps.seats.assertCanInvite(ctx),
onEvent: (event) => deps.audit.record(event),
},
registerRoutes(app) {
app.route("/sso", ssoRoutes(deps));
app.route("/billing", billingRoutes(deps));
app.route("/audit", auditRoutes(deps));
},
};
}

The only file that knows both worlds is the entrypoint, and it contains no business logic:

// enterprise/server.ts — composition root
import { createApp } from "myapp-core";
import { createEnterpriseEdition } from "./edition.js";
const edition = createEnterpriseEdition({ db, license, audit, seats });
serve(createApp(edition), { port: 3000 });

Community boots with createApp(CommunityEdition). Enterprise boots with createApp(createEnterpriseEdition(deps)). Same core, two compositions.

Inclusion at build time, permission at runtime

Two decisions that must stay separate:

  • Whether a feature exists in the binary — decided at build time, by which edition module gets compiled in.
  • Whether a customer may use it — decided at runtime, by license verification.

The runtime side is a small, isolated subsystem. Verify the license signature locally against an embedded public key (a signed token such as JWS works well), so serving traffic never depends on reaching a license server. Self-hosted users go offline; that must not break them:

async function assertCanInvite(ctx: InviteContext) {
const license = await licenses.verifyCurrent(ctx);
if (license.status === "expired") {
throw new LicenseError("License expired. Existing data stays available.");
}
if (license.status === "over_seat") {
throw new LicenseError("Seat limit reached.");
}
}

Renewal happens on a background schedule with jitter, never on the request path. Verification results that haven’t changed are not rewritten — a license check should be a read, not a write. Enforcement hooks (beforeInvite, route guards, config-change guards) all funnel through the same policy functions, so routes and lifecycle hooks can never disagree about what a license allows.

One owner per table

Enterprise features need storage — seat state, audit events, license records. The rule that keeps migrations sane: every table has exactly one owner, and packages only ever reference each other’s tables, never redefine or alter them.

In practice this means side-tables. Core owns member. Enterprise needs per-member status, so it owns a separate table and joins against core’s:

-- core owns: member(id, workspace_id, role)
-- enterprise owns: member_status(workspace_id, user_id, status)
select count(*) from member m
where m.workspace_id = $1
and m.role in ('owner', 'admin', 'member')
and not exists (
select 1 from member_status s
where s.workspace_id = m.workspace_id
and s.user_id = m.user_id
and s.status in ('deactivated', 'banned')
);

Each package migrates only its own tables with its own migration journal, applied in dependency order (core, then identity, then enterprise). If two packages ever claim the same table, community and enterprise databases diverge silently — so the ownership is worth writing down and checking in CI.

Gate the frontend the same way

One bundle with runtime flags puts paid UI in everyone’s JavaScript. Swap the edition module at build time instead, so the community bundle never contains enterprise components:

// build config (adapt to your bundler)
alias: {
"./edition": process.env.EDITION === "enterprise"
? "./edition-enterprise.tsx"
: "./edition-community.tsx",
}

Then assert it in CI: grep the community bundle for an enterprise-only string. If it appears, the boundary leaked and the build fails.

Pin the contract, not the branch

When enterprise lives outside the core repo, it should not float on core’s main branch. Pin the tested core version — a small compatibility file is enough — and fail CI when the checkout doesn’t match:

{ "core": { "version": "0.4.x", "commit": "a3f8c1d" } }

Local development can link the folders directly for speed. Releases always resolve through the pin, so upgrades are deliberate rather than surprise breakages.

The shape of it

core/ defines Edition, builds alone, knows nothing of enterprise
enterprise/ implements Edition, composed only at server.ts
build time decides what exists in the binary
runtime decides what the license allows
database one owner per table, references across, never shared writes
frontend edition swapped at build, verified by bundle grep
versions enterprise pins core, CI enforces the pin

Boundaries beat flags. A compiler error on a wrong-direction import is worth any number of code reviews about discipline — and when the boundary holds, the guarantee is structural: clone the core repo, build it, and you have the whole community app, no private keys required.

Enterprise becomes an addition. Not a condition.