Skip to main content

Services

Services are where your application logic lives. They contain your GraphQL operations, REST endpoints, entity hooks, and subscriptions. This is the single reference for everything related to services in BunSane.

Creating a Service

Extend BaseService and pass the App instance:

import { BaseService } from "bunsane/service";
import App from "bunsane/core/App";

class UserService extends BaseService {
constructor(private app: App) {
super();
UserArcheType.registerFieldResolvers(this);
}
}

export default UserService;

If your archetype has computed fields or relations, call registerFieldResolvers() in the constructor.

Registering Services

Register services in your App class using ServiceRegistry:

import { ServiceRegistry } from "bunsane/service";
import UserService from "./services/UserService";
import OrderService from "./services/OrderService";

export default class MyAPI extends App {
constructor() {
super("MyAPI", "1.0.0");

ServiceRegistry.registerService(new UserService(this));
ServiceRegistry.registerService(new OrderService(this));
}
}

Make sure to import your component files in the App so their @Component decorators run:

// Import components to ensure decorators are executed
import "./components/UserComponent";
import "./components/OrderComponent";

GraphQL Operations

Use @GraphQLOperation to create GraphQL queries and mutations. This is the decorator-based approach -- you explicitly mark each method and specify whether it is a query or mutation.

Queries

import { GraphQLOperation } from "bunsane/gql";
import type { GraphQLContext, GraphQLInfo } from "bunsane/types/graphql.types";

class UserService extends BaseService {
@GraphQLOperation({
type: "Query",
output: UserArcheType,
})
async profile(args: {}, context: GraphQLContext, info?: GraphQLInfo) {
const userId = context.jwt.payload.user_id;
const user = await Entity.FindById(userId);
if (!user) {
return new GraphQLError("User not found", {
extensions: { code: "NOT_FOUND" }
});
}
return user;
}
}

Mutations

@GraphQLOperation({
type: "Mutation",
input: z.object({
title: z.string(),
description: z.string(),
}),
output: TodoArcheType,
})
async createTodo(
args: { title: string; description: string },
context: GraphQLContext
) {
const todo = Entity.Create()
.add(TodoTag, {})
.add(TodoInfoComponent, {
title: args.title,
description: args.description,
});
await todo.save();
return todo;
}

Operation Options

The @GraphQLOperation decorator accepts:

OptionTypeDescription
type"Query" or "Mutation"Whether this is a GraphQL query or mutation
inputZod schema or archetype inputDefines the input arguments and generates a GraphQL input type
outputArchetype instanceDefines the return type and generates a GraphQL output type

Returning Lists

Wrap the archetype in an array to return a list:

@GraphQLOperation({
type: "Query",
output: [TodoArcheType],
})
async listTodos(args: {}, context: GraphQLContext) {
return await new Query().with(TodoTag).exec();
}

Input Validation with Zod

You can use Zod schemas as operation inputs. BunSane converts them to GraphQL input types.

import { z } from "zod";

@GraphQLOperation({
type: "Mutation",
input: z.object({
latitude: z.number(),
longitude: z.number(),
}),
output: UserArcheType,
})
async updatePosition(
args: { latitude: number; longitude: number },
context: GraphQLContext
) {
// ...
}
Zod Type Limitations

Only simple Zod types are supported for GraphQL schema generation: z.string(), z.number(), z.boolean(), z.enum(), and z.object(). Complex chains like .min(), .max(), .email() are not reflected in the generated GraphQL schema. If you need complex validation, validate inside your resolver method.

Using Archetype Schemas as Input

You can derive input schemas from archetypes:

@GraphQLOperation({
type: "Mutation",
input: UserArcheType.getInputSchema().partial().pick({
name: true,
}),
output: UserArcheType,
})
async updateProfile(args: any, context: GraphQLContext) {
const user = await Entity.FindById(context.jwt.payload.user_id);
if (!user) {
return new GraphQLError("User not found", {
extensions: { code: "NOT_FOUND" }
});
}
const updated = await UserArcheType.updateEntity(user, args);
await updated.save();
return updated;
}

Enum Types in GraphQL

Register enum types using asEnumType:

import { asEnumType } from "bunsane/core/ArcheType";

@GraphQLOperation({
type: "Mutation",
input: z.object({
payment_method: z.enum(["cash", "credit_card"]).register(asEnumType, {
name: "PaymentMethod",
}),
}),
output: OrderArcheType,
})
async createOrder(args: { payment_method: "cash" | "credit_card" }, context: GraphQLContext) {
// ...
}

REST Endpoints

Use HTTP method decorators to create REST routes:

import { BaseService, Get, Post, Put, Delete } from "bunsane/service";

class AuthService extends BaseService {
@Get("/v1/health")
async healthCheck() {
return Response.json({ status: "ok" }, { status: 200 });
}

@Post("/v1/auth/register")
async registerUser(req: Request) {
const body = await req.json();
// Validate and process...
return Response.json(
{ message: "User registered", data: { id: user.id } },
{ status: 201 }
);
}

@Put("/v1/users/:id")
async updateUser(req: Request) {
// ...
}

@Delete("/v1/users/:id")
async deleteUser(req: Request) {
// ...
}
}

REST handlers receive the raw Request object and should return a Response.

OpenAPI Documentation

Add OpenAPI documentation to your REST endpoints with @ApiDocs and @ApiTags:

import { ApiDocs, ApiTags } from "bunsane/swagger";

const RegisterSchema = z.object({
email: z.string(),
name: z.string(),
password: z.string(),
});

@ApiTags("Authentication")
class AuthService extends BaseService {
@Post("/v1/auth/register")
@ApiDocs({
summary: "Register a new user",
description: "Register a new user with email, password, and name",
requestBody: {
required: true,
content: {
"application/json": {
schema: z.toJSONSchema(RegisterSchema),
},
},
},
responses: {
"201": { description: "User registered successfully" },
"400": { description: "Validation error" },
},
})
async registerUser(req: Request) {
const body = await req.json();
const parse = RegisterSchema.safeParse(body);
if (!parse.success) {
return Response.json(
{ errors: parse.error.issues },
{ status: 400 }
);
}
// Process registration...
}
}

The OpenAPI spec is served at http://localhost:3000/openapi.json and a Swagger UI is available at http://localhost:3000/docs.

Entity Hooks

React to entity lifecycle events with @ComponentTargetHook. Hooks fire automatically when entities with specific components are created or updated.

import { ComponentTargetHook } from "bunsane/core/decorators/EntityHooks";
import type { EntityCreatedEvent, EntityUpdatedEvent } from "bunsane/core/events/EntityLifecycleEvents";

class OrderService extends BaseService {
@ComponentTargetHook("entity.created", {
includeComponents: [OrderTag, OrderInfoComponent],
})
async onOrderCreated(event: EntityCreatedEvent) {
const orderEntity = event.entity;
const infoComp = await orderEntity.get(OrderInfoComponent);
if (!infoComp) return;

console.log("New order created:", orderEntity.id);
}

@ComponentTargetHook("entity.updated", {
includeComponents: [OrderTag, OrderStatusComponent],
})
async onOrderStatusUpdated(event: EntityUpdatedEvent) {
const orderEntity = event.entity;
const statusComp = await orderEntity.get(OrderStatusComponent);
if (!statusComp) return;

this.app.pubSub.publish(`orderUpdated_${orderEntity.id}`, orderEntity);
}
}

The hook fires only for entities that have all the components listed in includeComponents.

GraphQL Subscriptions

Create real-time subscriptions using PubSub:

import { GraphQLSubscription } from "bunsane/gql/Generator";

class OrderService extends BaseService {
@GraphQLSubscription({
output: OrderArcheType,
})
async orderUpdated(args: { orderId: string }, context: GraphQLContext) {
return this.app.pubSub.subscribe(`orderUpdated_${args.orderId}`);
}
}

Publish events from any method in any service:

this.app.pubSub.publish(`orderUpdated_${orderId}`, orderEntity);

GraphQL Context

Every GraphQL operation receives a context object with useful properties:

async getUser(args: { id: string }, context: GraphQLContext) {
// Access authenticated user's JWT payload
const currentUserId = context.jwt?.payload?.user_id;

// Use DataLoaders for efficient batching
const user = await context.loaders.entity.load(args.id);

return user;
}
  • context.jwt -- JWT payload (available when using the JWT plugin)
  • context.loaders -- DataLoaders for efficient batched data fetching

Error Handling

In GraphQL Operations

Return or throw GraphQLError:

import { GraphQLError } from "graphql";
import { responseError } from "bunsane/core/ErrorHandler";

// Option 1: Return a GraphQLError
if (!user) {
return new GraphQLError("User not found", {
extensions: { code: "NOT_FOUND" }
});
}

// Option 2: Use the responseError helper
if (!user) {
return responseError("User not found", {
extensions: { code: "NOT_FOUND" }
});
}

In REST Endpoints

Return standard Response objects:

return Response.json(
{ errors: ["Invalid input"] },
{ status: 400 }
);

Authentication

Access the JWT payload from the GraphQL context:

@GraphQLOperation({
type: "Query",
output: UserArcheType,
})
async profile(args: {}, context: GraphQLContext) {
if (!context.jwt?.payload?.user_id) {
return responseError("Authentication required", {
extensions: { code: "UNAUTHENTICATED" }
});
}

const userId = context.jwt.payload.user_id;
return await Entity.FindById(userId);
}

The context.jwt object is available when you configure the JWT plugin. See the JWT Authentication Setup example for full setup instructions.

File Uploads

Handle file uploads in GraphQL mutations:

import { Upload } from "bunsane/gql";
import { UploadManager } from "bunsane/upload";

@GraphQLOperation({
type: "Mutation",
input: z.object({
file: Upload,
}),
output: UserArcheType,
})
async uploadProfilePicture(args: { file: any }, context: GraphQLContext) {
const userId = context.jwt.payload.user_id;
const user = await Entity.FindById(userId);
if (!user) return responseError("User not found");

const uploadManager = UploadManager.getInstance();
const uploadResult = await uploadManager.uploadFile(args.file, {
maxFileSize: 5 * 1024 * 1024, // 5MB
});

if (!uploadResult.success) return responseError("Failed to upload file");

await user.set(ProfilePictureComponent, { path: uploadResult.path });
await user.save();
return user;
}

Logging

Use the built-in structured logger:

import { logger as MainLogger } from "bunsane/core/Logger";

const logger = MainLogger.child({ service: "UserService" });

class UserService extends BaseService {
async someMethod() {
logger.trace({ msg: "Detailed debug info" });
logger.info({ msg: "Normal operation" });
logger.warn({ msg: "Something unexpected" });
logger.error({ msg: "Something went wrong" });
}
}

Service Communication

Services can communicate through several patterns:

PubSub Events

Use app.pubSub for loose coupling between services:

// Publishing service
class OrderService extends BaseService {
async completeOrder(orderId: string) {
const order = await Entity.FindById(orderId);
await order.set(OrderStatusComponent, { value: "completed" });
await order.save();
this.app.pubSub.publish("order.completed", { orderId, order });
}
}

// Subscribing service
class NotificationService extends BaseService {
constructor(private app: App) {
super();
this.app.pubSub.subscribe("order.completed", this.onOrderCompleted.bind(this));
}

async onOrderCompleted(data: { orderId: string; order: Entity }) {
// Send notification...
}
}

Entity Hooks

Use @ComponentTargetHook to react to data changes across services (described above).

Organizing Services

A recommended project structure:

src/
components/
UserComponent.ts
OrderComponent.ts
archetypes/
UserArcheType.ts
OrderArcheType.ts
services/
AuthService.ts
UserService.ts
OrderService.ts
admin/
AdminUserService.ts
App.ts
index.ts

Register all services in your App constructor:

export default class MyAPI extends App {
constructor() {
super("MyAPI", "1.0.0");

ServiceRegistry.registerService(new AuthService(this));
ServiceRegistry.registerService(new UserService(this));
ServiceRegistry.registerService(new OrderService(this));

// Conditional registration
if (process.env.NODE_ENV === "development") {
ServiceRegistry.registerService(new DebugService(this));
}
}
}