Skip to content
RELKIT
Esc
↑↓navigate↵open⌘Jpreview
On this page

Define a service

Expose selected functions and events, keep implementation details private, and use another domain through its service.

A service’s service.ts file lists the operations and event contracts other parts of your application may use. Keep handlers in their own files and list only the operations and event contracts that should form that boundary. defineService is available from @relkit/app/services.

Scaffold a service

From an installed application’s root, create a service with one example function:

bun run relkit add service Billing

If the terminal asks which contents to include, choose the simple service. This creates src/services/billing/service.ts and functions/example.function.ts beneath it. The service exposes the function as example. Replace the placeholder schemas and handler with your behavior, then choose the public members as explained below. If you already have the service, add a function to it instead.

For a manual setup, create the service and its owned descriptors yourself. The CLI and manual paths use the same public APIs. See the service command reference for all flags, or scaffold constants and prompts.

Custom and full service bundles

Choose a custom or full bundle when you need more than the example function:

bun run relkit add service Receipts --include function --include error --include job
bun run relkit add service Shipping --full

These create different domains; do not run all three if you only need one. Without a mode in headless execution, the default is service.ts plus functions/example.function.ts, exposed as example. --include is repeatable and cannot be combined with --full; dependent sample artifacts are added and wired automatically.

Simple service creation adds no native model/provider stack. A selected custom/full bundle normally installs its required adapters, native SDKs and local tooling. With --no-install, finish the reported bun install and check steps. Manual source setup follows each capability’s installation guide.

The full bundle creates a service, callable function, event-only function, typed error, event, job, cache, bucket, tool, prompt, agent, constants, and GET /shipping. Auth, database, middleware, and transforms are excluded. See full-bundle prerequisites before starting the generated providers.

For Shipping --full, the domain files are:

src/services/shipping/
  service.ts
  functions/example.function.ts
  functions/on-example.function.ts
  errors/example.error.ts
  events/example.event.ts
  tasks/example.task.ts
  jobs/example.job.ts
  cache/example.cache.ts
  buckets/example.bucket.ts
  tools/example.tool.ts
  prompts/example.prompt.ts
  agents/example.agent.ts
  constants/example.constants.ts
src/routes/shipping/route.ts

The service exposes the example function, event, task, and job. The function declares its sample error and publishes the event; the event-only function consumes it. The job targets the task, the tool targets the callable function, the agent uses the tool and prompt, and the route maps GET to example member. Provider configuration, environment examples, and package dependencies are merged as needed.

For every scaffolding flag, see the CLI reference.

Select public members

/** Declares the orders service boundary without acquiring native clients. */
import { defineService } from "@relkit/app/services";
import createOrder from "./functions/create-order.function.js";
import deleteOrder from "./functions/delete-order.function.js";
import getOrder from "./functions/get-order.function.js";
import searchOrders from "./functions/search-orders.function.js";
import updateOrder from "./functions/update-order.function.js";
import authorizeOrder from "./functions/authorize-order.function.js";
import streamOrderReport from "./functions/stream-order-report.function.js";
import cleanupOrders from "./jobs/cleanup-orders.job.js";
import exportOrders from "./jobs/export-orders.job.js";
import cleanupOrdersTask from "./tasks/cleanup-orders.task.js";
import exportOrdersTask from "./tasks/export-orders.task.js";

export default defineService({
  functions: {
    createOrder,
    deleteOrder,
    getOrder,
    searchOrders,
    updateOrder,
    authorizeOrder,
    streamOrderReport,
  },
  tasks: { cleanupOrdersTask, exportOrdersTask },
  jobs: { cleanupOrders, exportOrders },
});

functions contains ordinary callable function descriptors. events contains handler-free event contracts. tasks and jobs contain the task/job descriptors owned by the service. All maps are optional. They are definition options, not namespaces on the resulting service: callers use orders.createOrder and orders.orderCreated, not orders.functions.createOrder or orders.events.orderCreated.

Members are the original descriptors, not copies. A function exposed through a service retains its identity, input and output schemas, declared errors, and .invoke() behavior. Changing the local import name does not create a new operation.

Use public functions from the frontend

The service boundary is internal to the backend. Expose its selected functions through a route before calling them from the browser. These are the same Orders members declared above:

/**
 * Maps order HTTP operations to public service members with the existing request
 * and rate-limit policies. The rate-limit reference addresses the declared cache
 * by its stable identity without importing a private service implementation.
 */
import { defineServiceRoutes, http } from "@relkit/app/routes";
import { z } from "@relkit/app/schema";
import orders from "../../services/orders/service.js";

export const { GET, POST } = defineServiceRoutes(orders, {
  GET: "searchOrders",
  POST: {
    member: "createOrder",
    request: http.input({
      orderId: http.header("idempotency-key"),
      customerEmail: http.header("x-customer-email"),
      sku: http.body("sku"),
      quantity: http.body("quantity"),
    }),
    rateLimit: {
      limit: 20,
      windowMs: 60_000,
      key: http.header("x-customer-email"),
      store: {
        ref: { kind: "cache", id: "orders.rate-limits" },
        key: z.string(),
        value: z.number().int().nonnegative(),
      },
    },
    successStatus: 201,
  },
});

After frontend setup and pulling this backend’s contract, render this component under RelkitClientProvider. Each exported service-route method produces its own typed operation: GET /orders reads the search count, while POST /orders creates an order.

"use client";

/** Calls both commerce service-route methods using their generated domain inputs inside the frontend provider. */
import { useRelkitClient, useRoute, useRouteMutation } from "@relkit/client/react";

/** Reads the Orders search count and submits one explicit creation without replaying a failed write.
 * @returns Typed query results, creation feedback, and an action available after identity is ready. */
export function Orders() {
  const { status } = useRelkitClient();
  const orders = useRoute("GET /orders", { input: { status: "pending" } });
  const create = useRouteMutation("POST /orders");

  return (
    <section>
      {orders.isPending && <p role="status">Loading orders…</p>}
      {orders.data && <p>{orders.data.count} orders</p>}
      {orders.isError && <p role="alert">{orders.error.message}</p>}
      <button
        disabled={status !== "ready" || !create.isIdle}
        onClick={() =>
          create.mutate({
            orderId: crypto.randomUUID(),
            customerEmail: "reader@example.com",
            sku: "book",
            quantity: 1,
          })
        }
      >
        Create an order
      </button>
      {create.isPending && <p role="status">Creating…</p>}
      {create.isSuccess && <p>Created: {create.data.orderId}</p>}
      {create.isError && (
        <p role="alert">{create.error.message}. Check the outcome before retrying.</p>
      )}
    </section>
  );
}

status, count, and the creation input come from the selected functions’ schemas. RPC receives orderId and customerEmail as input fields even though REST maps them from headers. A service’s public member alone is not a browser endpoint: the client-exposed route supplies that boundary.

Reference: useRoute, useRouteMutation. See request mapping for the REST side and mutations for submission and recovery behavior.

Decide what stays internal

Leave a function out of the service if only its own domain uses it. It is still discovered and can be called by other functions in that domain. A helper does not become public just because its module exports it in TypeScript.

Do not put caches, buckets, tools, agents, or plain helper values into the member maps. A task/job member does not expose a browser client by itself; its explicit client policy still controls that boundary. defineEventFunction subscribers are event-only and cannot be public callable members either. Keep those artifacts in the domain; expose an ordinary function when another domain needs their behavior.

For example, expose askAssistant as a function that calls a declared agent dependency, rather than trying to expose the agent descriptor itself. See the agent placement example.

This boundary is about application dependencies. It is not user authorization, an HTTP registration, or a switch controlling MCP tool visibility.

Use another domain’s service

Import @app/services/orders/service.js, then invoke a member such as orders.getOrder.invoke({ orderId: "order-1" }) from your function handler. Use .invoke(), not .handler(), so the call goes through the common validation and execution boundary.

Within Orders, import other Orders modules directly rather than importing the service back into its own implementation. Across domains, import the service rather than a private function path.

Importing layer Allowed application imports
A domain Its own modules, other domains’ service.ts, and Platform.
Routes Route modules, domain service.ts boundaries, and Platform.
Platform Other Platform modules, not domains or routes.
relkit.config.ts Platform support, not domains or routes.

Generated projects map @app/* to src/*. Keep the .js extension in imports for emitted ESM. An import of another domain’s service records a depends-on-service graph edge; there is no service imports option or service dependency container to configure.

A service may expose only its own functions and events. To compose another service’s behavior, write an operation in the consuming domain that calls it. Do not re-export another domain’s descriptor as your own service member.

Keep runtime access explicit

Service membership does not provide a cache, bucket, job client, or agent client. Those remain explicit function dependencies. Event publication uses publishes and context.events[eventId].publish(...). Exposing an event in service.events at definition time does not authorize a function to publish it.

There is no general service lifecycle, service middleware, or context.service API. Put HTTP policy in route middleware. Put rules that must apply to every invocation in function logic or function hooks. The specialized database and authentication services have their own integration APIs.

Naming and discovery rules

Use a lowercase domain name beginning with a letter and containing only letters, digits, and hyphens, such as orders or customer-support. Technical layer names such as functions, events, and tools are reserved as legacy roots, not useful domain names.

Each domain’s service.ts must construct and export exactly one service runtime value. Follow the default-export form above; do not use it as a general barrel of unrelated exports.

The service ID is inferred from its domain directory. If you set it explicitly, it must equal that directory name. IDs of owned descriptors must start with the domain prefix, such as orders.. Capabilities that support inferred IDs can omit them; follow each capability’s guide for its ID rules.

Public member names must be nonempty, normalized, and distinct across both maps. Descriptor fields such as id, ref, functions, events, and handler are reserved member names. See the Services API for the full type and validation reference.

The recognized flat-to-grouped move preserves explicit and inferred logical IDs: src/orders becomes src/services/orders without introducing a services. identity prefix. Diagnostics, source maps, manifests, and editor links retain the real physical path. Other file moves or renaming the domain can change inferred IDs and their required prefix; review those as contract migrations.

Can a service expose nothing?

Yes. A domain consisting of internal event subscribers can use this boundary:

import { defineService } from "@relkit/app/services";

export default defineService({});

The receiving domain owns actual subscriber functions even though it exposes no public members. A normal domain containing only this empty service and no graph-visible artifacts fails compilation. Conversely, a domain that only publishes event contracts can expose events without exposing any functions.

Verify the boundary

From your application’s root, run:

bun run check
bun run typecheck

The compiler reports missing or duplicate services, invalid domain IDs, foreign service members, and disallowed imports. Once the checks pass, connect public functions to routes or test a first service end to end.

Scaffold domain constants

With an existing orders service, run from the application root:

bun run relkit add constants receipt-settings --service orders

Creates src/services/orders/constants/receipt-settings.constants.ts with a domain-qualified key and a "replace-me" value. Replace that placeholder in source. Constants remain internal and are not added to service member maps. There is no CLI flag for the final value.

For every constants scaffolding flag, see the CLI reference.