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 --fullThese 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.tsThe 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.
Reference and source
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.