Every team I've worked with has a module that different parts of the system need to use. Permissions are usually one of them, as the frontend needs it to hide buttons that a viewer should not be able to use.
The backend also needs it to stop someone from inviting people when they don't have permission. The rules, data, and decisions are the same. The business logic was written once, but the integration was written twice.
The frontend gets a REST endpoint, a fetch wrapper, and manually written response types. The backend gets its own HTTP client, a copy of the permission map, or even a second implementation that can fall out of sync a few sprints later.
In this post, we'll take a different approach. We'll write a TeamPermissionsService in TypeScript and expose it through Graftcode Gateway. A React app and a separate TypeScript backend will then use the same generated package.
At the end, we'll also move the service from a remote host into the same process as the Caller by changing one setting. We won't need to change the method calls.
You can find the finished project here in this GitHub repository.
What you will learn
- Why different parts of an application often need separate integrations for the same backend functionality
- What Receiver, Caller, Graft, and Gateway mean in Graftcode
- How to expose a TypeScript class as a callable service without controllers or DTOs
- How to install the same generated package in a React app and a Node.js backend
- How to switch between in-process and remote execution through configuration
Prerequisites
Before starting, make sure you have:
- Node.js 22 or higher installed locally
- A basic understanding of TypeScript and React
- Some experience with REST APIs and frontend-to-backend communication
- Graftcode Gateway installed and available on your
PATH
To install Gateway, run the installer:
curl -fsSL https://grft.dev/get | sh On Windows, use PowerShell:
iwr https://grft.dev/get | iex You can also download a release from the Gateway releases page on GitHub and add the gg binary to your PATH.
Check that the installation worked:
gg --help Everything in this tutorial runs locally, so you don't need a Graftcode account.
The problem: one capability, two integrations
Let's look at the problem. You have a permissions module, and two parts of your application need to use it.
Both integrations move arguments and return values between the services. Neither contains the actual business rules.
Both also need updates when you add a permission, rename a role, or change the shape of a response.
The problem isn't just writing this code. It's having to keep maintaining it.
Add one method, and you may need to design an endpoint, register a route, validate a request body, define a response type, and update two clients. The more consumers you have, the more integration code you need to maintain.
What is Graftcode?
Graftcode lets you call a module's public methods across processes, machines, and languages without manually building an API layer. You write normal code, and Gateway loads the module, discovers its public methods, and makes them available to consumers.
Consumers can then install a generated package and call those methods through it.
There are four terms we'll use throughout this post:
- Receiver is the service being called. In our example, it's the
TeamPermissionsServicemodule. Its public methods make up the callable surface. - Caller is the application or service making the call. We'll have two: a React app and a Node.js backend.
- Graft is the generated, typed package that a Caller installs using its normal package manager. You don't write this package yourself.
- Gateway hosts the Receiver module, discovers its callable surface, and publishes it so a Graft can be generated.
The main idea is that the Receiver's public methods become the contract. You don't need a separate schema file or specification to maintain alongside them.
The Graftcode documentation calls this the callable surface. It also explains that deciding which methods are exposed should be intentional, not simply a result of which methods happen to be public.
Both Callers in our example will use the same generated package from that same surface.
What we are building
We'll build a team permissions service for a SaaS application with four methods:
| Method | Purpose |
|---|---|
getUserPermissions(teamId, userId) | Gets a member's role and permissions |
hasPermission(teamId, userId, permission) | Checks whether a member has a specific permission |
getTeamMembers(teamId) | Gets the members of a team and their roles |
updateMemberRole(teamId, actingUserId, targetUserId, newRole) | Changes a member's role after checking permissions |
We'll then connect two consumers to it:
- A React settings page that lists team members and lets permitted users change their roles
- An onboarding backend service that checks
member.invitebefore creating an invite
Here's the project structure we'll use:
graftcode-team-permissions/ ├── service/ ├── web/ └── onboarding-api/ Step 1: Write the TypeScript service
First, create the service project:
mkdir -p graftcode-team-permissions/service/src cd graftcode-team-permissions/service npm init -y npm install -D typescript @types/node Now create src/index.ts.
This is plain TypeScript. There is no proper UI or anything special:
type Role = "owner" | "admin" | "member" | "viewer"; interface Member { userId: string; teamId: string; name: string; email: string; role: Role; } const ROLE_PERMISSIONS: Record<Role, string[]> = { owner: [ "team.view", "team.billing", "member.invite", "member.remove", "member.role.update", "project.create", "project.delete", ], admin: [ "team.view", "member.invite", "member.remove", "member.role.update", "project.create", ], member: ["team.view", "project.create"], viewer: ["team.view"], }; const members: Member[] = [ { userId: "user-1001", teamId: "team-apollo", name: "Sarah Johnson", email: "sarah@example.com", role: "owner" }, { userId: "user-1002", teamId: "team-apollo", name: "James Wilson", email: "james@example.com", role: "admin" }, { userId: "user-1003", teamId: "team-apollo", name: "Amara Okafor", email: "amara@example.com", role: "member" }, { userId: "user-1004", teamId: "team-apollo", name: "Daniel Brooks", email: "daniel@example.com", role: "viewer" }, ]; const ASSIGNABLE_ROLES: Role[] = ["admin", "member", "viewer"]; function findMember(teamId: string, userId: string): Member | undefined { return members.find((m) => m.teamId === teamId && m.userId === userId); } export class TeamPermissionsService { static getUserPermissions(teamId: string, userId: string): string { const member = findMember(teamId, userId); if (!member) { return JSON.stringify({ userId, teamId, role: null, permissions: [], found: false }); } return JSON.stringify({ userId: member.userId, teamId: member.teamId, role: member.role, permissions: ROLE_PERMISSIONS[member.role], found: true, }); } static hasPermission(teamId: string, userId: string, permission: string): boolean { const member = findMember(teamId, userId); if (!member) { return false; } return ROLE_PERMISSIONS[member.role].includes(permission); } static getTeamMembers(teamId: string): string { const teamMembers = members .filter((m) => m.teamId === teamId) .map((m) => ({ userId: m.userId, name: m.name, email: m.email, role: m.role, permissionCount: ROLE_PERMISSIONS[m.role].length, })); return JSON.stringify({ teamId, members: teamMembers }); } static updateMemberRole( teamId: string, actingUserId: string, targetUserId: string, newRole: string ): string { if (!TeamPermissionsService.hasPermission(teamId, actingUserId, "member.role.update")) { return JSON.stringify({ ok: false, reason: "ACTING_USER_NOT_PERMITTED" }); } if (!ASSIGNABLE_ROLES.includes(newRole as Role)) { return JSON.stringify({ ok: false, reason: "ROLE_NOT_ASSIGNABLE", assignableRoles: ASSIGNABLE_ROLES }); } const target = findMember(teamId, targetUserId); if (!target) { return JSON.stringify({ ok: false, reason: "TARGET_NOT_FOUND" }); } if (target.role === "owner") { return JSON.stringify({ ok: false, reason: "CANNOT_DEMOTE_OWNER" }); } const previousRole = target.role; target.role = newRole as Role; return JSON.stringify({ ok: true, userId: target.userId, previousRole, newRole: target.role, permissions: ROLE_PERMISSIONS[target.role], }); } } There are four things worth pointing out here.
Only the class is exported: ROLE_PERMISSIONS, members, and findMember stay inside the module. They won't become part of the callable contract. With Graftcode, the public surface is the contract, so keep everything else private. The docs explain this in Public surface vs implementation.
The methods are static. Static methods that take input and return output can be called independently. Instance methods and remote object references are stateful, and Graftcode documents them as Alpha, without a guarantee that their state will survive reconnects or restarts. See Known limitations.
The methods return strings instead of objects. We're keeping the parameters and return types to string and boolean, which are part of the portable baseline. Graftcode recommends primitives, strings, and plain models, and suggests using strings for dates and identifiers across runtimes. Returning JSON.stringify(...) lets us return structured data while staying within that baseline. The downside is that Callers need to parse the JSON themselves, as we'll see later.
updateMemberRole checks its own rules. The permission check, owner protection, and list of allowed roles all live in the Receiver. This matters once more than one consumer uses the service, because each Caller goes through the same rules.
Step 2: Build it so Gateway can read it
Gateway needs the compiled output and a package.json that points to it.
Create tsconfig.json:
{ "compilerOptions": { "target": "ES2020", "module": "CommonJS", "declaration": true, "outDir": "dist", "rootDir": "src", "strict": true, "esModuleInterop": true, "skipLibCheck": true }, "include": ["src"] } Two settings are especially important here.
"module": "CommonJS" is needed because Gateway loads the entry module through Node's CommonJS interop.
"declaration": true creates dist/index.d.ts. Gateway's analyzer reads that file to get the parameter names and types. Without it, you lose some of the typing that makes this setup useful.
Next, point package.json to the build output:
{ "name": "team-permissions-service", "version": "1.0.0", "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { "build": "tsc" } } Build the project:
npm run build Step 3: Expose it through Graftcode Gateway
Point Gateway at the project:
gg ./package.json --port 8080 --httpPort 8081 The default ports are 80 for service calls and 81 for Graftcode Vision. I'm using 8080 and 8081 here so you don't need elevated privileges.
Gateway reads package.json, finds the entry point, analyzes the module, and publishes the callable surface. The first run takes longer because Gateway prepares its Node.js analyzer.
Look for the discovered type in the logs:
[Info] Module analyzed: ./package.json [Info] Type enabled: TeamPermissionsService Gateway will then print the install commands for the supported ecosystems:
[Info] Hosted module team-permissions-service installation commands: FOR NPM PROJECT: npm install --registry https://grft.dev/<your-registry-id>__free @graft/npm-team-permissions-service@1.0.0 Copy the npm command. The registry ID is specific to your Gateway run.
One thing to know before you run into this: Without a Project Key, the registry ID and sometimes the package name can change when Gateway restarts. The docs explain this in Known limitations. I ran into it while writing this post: after changing the callable surface and restarting Gateway, I got
@graft/npm-team-permissions-service_3y3chyinstead of the original name.That's fine for a tutorial. For a project you want to keep working on, create a free project at portal.graftcode.com and pass
--projectKey YOUR_PROJECT_KEYto keep the coordinates stable.
Step 4: Review the callable surface in Graftcode Vision
Open Graftcode Vision at http://localhost:8081/GV.
This isn't Swagger. You won't see routes, HTTP verbs, or payload schemas. Instead, you'll see the classes and methods you wrote, their parameters and return types, buttons to call each method, and install commands for the supported ecosystems.
Treat Vision as a review step, not just an extra feature. Check that the callable surface is what you intended to expose before anyone installs a Graft.
Notice what's missing: findMember, ROLE_PERMISSIONS, and members aren't there. The Role and Member types are missing too. TypeScript interfaces and type aliases only exist at compile time, so they don't appear in the runtime surface.
For Node.js and TypeScript, the docs are clear: the surface shown in Vision is the one that matters, not just what you see in the source file.
If Gateway exposes more than you want, you can narrow the surface with filters:
gg ./package.json \ --types TeamPermissionsService \ --methods getUserPermissions,hasPermission I ran this while testing, and the surface was reduced to those two methods. Filters are useful when a module exports helpers that you don't want to publish. They're a simple way to keep the callable surface small.
Step 5: Install the Graft in the React frontend
Create the React app:
cd .. npm create vite@latest web -- --template react-ts cd web npm install Install the Graft using the command Gateway printed:
npm install --registry https://grft.dev/<your-registry-id>__free @graft/npm-team-permissions-service@1.0.0 The generated package depends on hypertube-nodejs-sdk, the runtime bridge that carries the calls. It uses some Node.js built-ins that browsers don't have, so Vite needs polyfills for them:
npm install -D vite-plugin-node-polyfills Now let's look at what you installed. Open node_modules/@graft/npm-team-permissions-service/TeamPermissionsService.d.ts:
export class TeamPermissionsService { static getUserPermissions(teamId: string, userId: string): Promise<string>; static hasPermission( teamId: string, userId: string, permission: string, ): Promise<boolean>; static getTeamMembers(teamId: string): Promise<string>; static updateMemberRole( teamId: string, actingUserId: string, targetUserId: string, newRole: string, ): Promise<string>; } The parameter names and types are there, and we didn't write this file or have to maintain it ourselves.
Notice that every method returns a Promise, including hasPermission, which was synchronous in the Receiver. That's expected. The generated Node.js Caller API is always asynchronous, regardless of whether the Receiver method is synchronous.
Step 6: Call the service from React
Replace src/App.tsx with this:
import { useEffect, useState } from "react"; import { GraftConfig, TeamPermissionsService, } from "@graft/npm-team-permissions-service"; GraftConfig.host = "ws://localhost:8080/ws"; GraftConfig.stateless = true; const TEAM_ID = "team-apollo"; const ACTING_USER = "user-1001"; type Member = { userId: string; name: string; email: string; role: string; permissionCount: number; }; export default function App() { const [members, setMembers] = useState<Member[]>([]); const [canManageRoles, setCanManageRoles] = useState<boolean | null>(null); const [status, setStatus] = useState("Loading team..."); async function load() { const raw = await TeamPermissionsService.getTeamMembers(TEAM_ID); const allowed = await TeamPermissionsService.hasPermission( TEAM_ID, ACTING_USER, "member.role.update" ); setMembers(JSON.parse(raw).members); setCanManageRoles(allowed); setStatus(""); } useEffect(() => { load().catch((err) => setStatus(`Failed: ${String(err)}`)); }, []); async function changeRole(targetUserId: string, newRole: string) { setStatus(`Updating ${targetUserId}...`); const raw = await TeamPermissionsService.updateMemberRole( TEAM_ID, ACTING_USER, targetUserId, newRole ); const result = JSON.parse(raw); setStatus(result.ok ? "" : `Rejected: ${result.reason}`); await load(); } return ( <main style={{ fontFamily: "system-ui", padding: 32, maxWidth: 820 }}> <h1>Team Apollo</h1> <p> Role management is{" "} <strong> {canManageRoles === null ? "..." : canManageRoles ? "enabled" : "disabled"} </strong>{" "} for the signed-in user. </p> {status && <p>{status}</p>} <table cellPadding={8} style={{ borderCollapse: "collapse", widthSource:https://dev.to/coderoflagos/build-a-typescript-service-once-then-use-it-from-your-frontend-and-backend-1oe5Related posts

Apple's Johny Srouji Eyed to Replace Nvidia CEO Jensen Huang



