Where to put your code
Find the right home for functions, tools, agents, events, jobs, databases, storage, schemas, routes, and shared code.
Keep files with the service that owns their behavior, regardless of how callers reach them. For example, an order lookup belongs to
Orders whether it is reached from HTTP, another function, or an AI model. Its implementation stays in
src/services/orders/functions/; the entry points reference it.
The placement map
The paths below are patterns, not files you must create in every domain. Replace orders with the owner.
| What you are adding | Where it belongs | Continue learning |
|---|---|---|
| Public domain boundary | src/services/orders/service.ts |
Define a service |
| Business operation | src/services/orders/functions/create-order.function.ts |
Functions |
| Event contract | src/services/orders/events/order-created.event.ts |
Events: getting started |
| Event subscriber | functions/*.function.ts in the receiving domain, such as src/services/receipts/functions/order-receipt.function.ts |
Consume an event |
| Task and its job | src/services/receipts/tasks/send-receipt.task.ts and src/services/receipts/jobs/send-receipt.job.ts |
Jobs: getting started and schedules |
| AI tool | src/services/orders/tools/lookup-order.tool.ts |
AI: getting started, then tools |
| AI agent | src/services/orders/agents/order-support.agent.ts |
AI: getting started, then your first agent |
| Cache | src/services/orders/cache/prices.cache.ts |
Caching: getting started |
| Object bucket | src/services/assets/buckets/assets.bucket.ts in the domain that owns the files |
Storage: getting started |
| Database service | src/services/database/service.ts using defineDrizzleService |
Database: getting started |
| Database table | src/services/database/schema/, exported through its index.ts |
Define the schema |
| Custom data-model method | src/services/database/models/*.model.ts, registered in the database service |
Custom models |
| Database migration | Tracked SQL/metadata under src/services/database/migrations; runner owned by installed tooling |
Migrations |
| Domain schema | Beside its sole consumer, or a domain module such as src/services/orders/schemas.ts when shared locally |
Schemas |
| Declared domain error | src/services/orders/errors/order-not-found.error.ts |
Errors |
| Prompt, constant, or helper | Beside its consumer or in a focused module within the same domain | Agent instructions, domain boundaries |
| URL and HTTP method | src/routes/orders/route.ts |
Services and routes |
| HTTP middleware or input transform | src/routes/middleware/*.middleware.ts or src/routes/transforms/ |
Middleware, request mapping |
| Environment contract | src/platform/env.ts |
Environment |
| Provider and server configuration | relkit.config.ts, importing infrastructure support from Platform |
Integrations, application configuration |
| Function and application tests | tests/unit/ and tests/integration/ |
Testing |
Default-export each ordinary descriptor from its own file, following its capability’s guide. Route files are different: they export named HTTP methods. Plain helper modules are ordinary TypeScript; they do not need a RELKIT descriptor or their own service.
One selected service root
New applications place boundaries at src/services/<name>/service.ts. Existing flat
applications keep src/<name>/service.ts, and relkit add preserves the detected layout.
Choose one root for the whole application, including Database and Auth. A directory alone,
an empty services/ directory, or a folder containing only helpers does not select a layout.
Discovery uses authored service and descriptor evidence.
Each service is an immediate child of the selected root. Every descriptor belongs beneath
exactly one boundary. Nested boundaries, mixed flat/grouped services, missing boundaries,
and an aggregate src/services/service.ts are structural errors. A source-only move keeps
logical IDs, URLs, exposure, resource ownership and policies stable. Use the
optional migration guide for coordinated moves and rollback.
Imports follow the boundary
Service implementation files can import their own files, Platform, and another service’s
public service.ts boundary. Routes can import service boundaries and Platform. Platform
can import only Platform within application source; relkit.config.ts can import Platform
fragments. Application code cannot depend on tests, migrations, scripts or other tooling.
Tooling can import the application for testing and operational work.
The compiler follows helper modules, re-exports, type-only edges and literal dynamic imports using the project’s effective TypeScript aliases. A barrel cannot make a private cross-service import public. Computed dynamic imports and unresolved dynamic targets produce analysis-limit warnings; they are not proof that the target respects a boundary.
Keep prompt text, JSON assets, schemas and local helper modules beside their consumer. Assets move with their service but do not become independently discovered descriptors.
Functions own behavior; dependencies grant clients
A function is where validated business input becomes a result. Keep HTTP parsing in routes and model instructions in agents. The function declares the provider-backed resources it uses; putting a cache or bucket in the same folder does not grant access automatically.
/** Prices validated order input through the declared cache, records request tracing and derives its receipt key. */
import { defineFunction } from "@relkit/app/functions";
import prices from "../cache/prices.cache.js";
import { receiptObjectName } from "../../../platform/receipt-object.js";
import { createOrderOutput, orderInput } from "../../../platform/schemas.js";
const createOrder = defineFunction({
// RELKIT validates these schemas before the handler runs.
input: orderInput,
output: createOrderOutput,
dependencies: {
cache: { prices },
},
timeoutMs: 10_000,
concurrency: 100,
handler: async (input, context) => {
// Dependencies are accessed through the checked execution context.
const unitPrice = await context.trace.span(
"pricing.calculate",
{ attributes: { "pricing.strategy": "catalog" } },
() => context.cache.prices.getOrSet({ sku: input.sku }, async () => 1_000),
);
context.trace.event("pricing.calculated", { "pricing.unit_cents": unitPrice });
context.trace.setAttributes({ "pricing.quantity": input.quantity });
const totalCents = unitPrice * input.quantity;
return {
orderId: input.orderId,
receiptKey: receiptObjectName(input.orderId),
totalCents,
};
},
});
export default createOrder;
Here, dependencies.cache supplies context.cache.prices. Event publishers separately declare
publishes before using context.events; see publish an event.
Neither permission is configured in service.ts. The price cache is an
Orders implementation detail, while createOrder can be a public operation.
Use functions for the handler and validation model, context for execution resources, and your first cache for a complete resource-backed example.
Tools and agents stay with their business domain
Put an order lookup tool in Orders, not in a separate application-wide AI domain just because a model
calls it. A tool points at a function with defineTool({ target: ... }) or adapts that function with
.asTool(). The schemas and business behavior stay with the function. Start with
defining a tool or turning a function into a tool.
An agent’s agents/ file owns its instructions, schemas, model, tool allowlist, and execution limits.
Store long instructions in a local prompt module if that makes them easier to maintain. Plain text does
not need a new service. See defining an agent.
Native execution requires installing @relkit/langchain and its selected SDKs
and configuring the required runtime profiles. A folder or generic agent contract does not select that engine.
To let a browser run an agent, expose its client contract and select it with the generated
useAgent hook. Every run, observation, control, and continuation supplies the application’s stable
business thread ID. Agents and tools remain compiler-discovered domain files; do not add an agents or
tools map to defineService. A tool in another domain can target a public function from the owning
service; it should not import that domain’s private tool file.
“Internal to the domain” does not mean “hidden from MCP.” When the endpoint is explicitly enabled, tools are eligible by default unless disabled. Complete the separate MCP SDK/setup and review tool approvals before exposing them.
Events and jobs belong to the work’s owner
Put the fact in the producing domain’s events/ directory and the reaction in the receiving domain’s
functions/ directory. For example, Orders defines orders.created, while Receipts defines the
defineEventFunction that reacts to it. The subscriber stays out of service.functions: it runs through
event delivery or replay, not direct invocation.
A task and job belong to the domain responsible for completing the durable work. The task stays in that
domain’s tasks/ directory and the job owns its public name, admission, and schedules. A schedule is an
option on the job, not a separate service or a reason to move the task. Follow your first event,
jobs, and schedules for the complete workflows.
Resources do not go in the service member list
Caches and buckets live under their owning domains. Functions declare dependencies.cache or
dependencies.buckets, and relkit.config.ts supplies provider bindings for the selected profiles.
Changing the provider does not require moving the domain’s business code.
If another domain needs access, expose an operation on the owner—such as uploading or reading a file— instead of publishing the raw cache or bucket as a service member. See the storage tutorial for a bucket, function, service, and route working together.
Database and authentication integrations have specialized boundaries: src/services/database/service.ts uses
defineDrizzleService, with table schemas beneath that domain; src/services/auth/service.ts uses
defineBetterAuthService. They are not database or auth options on defineService. Start with
Database for setup, schemas, queries, and model customization. The compiler supplies
context.database; functions do not add a dependencies.database entry. Ordinary business domains
still expose operations through defineService. See the
Database and Auth guidance when replacing older adapters, or
Integrate Better Auth
to share the current database with authentication. Continue with Auth for service setup,
sessions, route protection, clients, and authentication customization.
Shared code is not a shortcut around ownership
Keep domain-specific schemas, constants, errors, and helpers with their owner. Other domains consume
public service operations instead of importing those private modules. A public function’s declared
errors are part of its checked contract; you do not add an errors member map to the service.
Use src/platform/ for genuinely shared, domain-independent support, such as the environment contract
or common schema primitives. Platform cannot import domains or routes. If a “shared” helper knows how
Orders calculates a total, it belongs to Orders.
Tests live outside src/ in the generated layout. Unit tests can import the specific function they
exercise; integration tests verify the application’s routes and runtime behavior. Do not add a domain
service just to hold tests.
Let the compiler check the organization
Run these commands from your generated application’s root after adding or moving artifacts:
bun run check
bun run typecheck
Expect a successful graph check and type check. Fix missing services, invalid IDs, unresolved targets,
and boundary errors before continuing. Do not register every file manually in relkit.config.ts or edit
.relkit/generated; the compiler derives the model from source.
Naming and folder-placement suggestions remain nonfatal convention warnings. They are distinct from ownership, missing-boundary, mixed-layout and forbidden-import errors.