Arcwayv0.3.0

Business Logic

Organizing shared logic across handlers, listeners, and jobs

All business logic in Arcway lives in your handlers — route handlers, event listeners, and job handlers. Each handler receives a ctx object with all the infrastructure it needs.

Route Handlers

Route handlers receive ctx with req, db, events, cache, queue, files, mail, and log:

// api/users/index.js
export const GET = {
  handler: async (ctx) => {
    const { db } = ctx;
    const users = await db('users').select('id', 'name', 'email');
    return { data: users };
  },
};

export const POST = {
  schema: {
    body: {
      name: 'string >= 1',
      email: 'string.email',
    },
  },
  handler: async (ctx) => {
    const { db, events } = ctx;
    const [id] = await db('users').insert(ctx.req.body);
    await events.emit('users/created', { id, ...ctx.req.body });
    return { status: 201, data: { id, ...ctx.req.body } };
  },
};

Event Listeners

Listeners receive ctx with event, db, and other infrastructure:

// listeners/users/created.js
export default async (ctx) => {
  const { event, db, mail } = ctx;
  const user = await db('users').where('id', event.payload.id).first();

  await mail.send({
    to: user.email,
    subject: 'Welcome!',
    text: `Hi ${user.name}, welcome aboard.`,
  });
};

Job Handlers

Jobs receive ctx with payload, db, and other infrastructure:

// jobs/generate-report.js
export default {
  schedule: '0 9 * * 1', // every Monday at 9am
  handler: async (ctx) => {
    const { db, mail } = ctx;
    const stats = await db('orders')
      .where('created_at', '>=', db.raw("date('now', '-7 days')"))
      .count('* as total')
      .sum('amount as revenue')
      .first();

    await mail.send({
      to: 'team@example.com',
      subject: 'Weekly Report',
      text: `Orders: ${stats.total}, Revenue: $${stats.revenue}`,
    });
  },
};

Sharing Logic Between Handlers

When multiple handlers need the same logic, extract it into a plain function that receives what it needs as parameters:

// api/users/[id].js
async function findUserOrFail(db, id) {
  const user = await db('users').where('id', id).first();
  if (!user) return null;
  return user;
}

export const GET = {
  schema: { query: { id: /^\d+$/ } },
  handler: async (ctx) => {
    const { db } = ctx;
    const user = await findUserOrFail(db, Number(ctx.req.query.id));
    if (!user) return { status: 404, error: { code: 'NOT_FOUND', message: 'User not found' } };
    return { data: user };
  },
};

export const DELETE = {
  schema: { query: { id: /^\d+$/ } },
  handler: async (ctx) => {
    const { db } = ctx;
    const user = await findUserOrFail(db, Number(ctx.req.query.id));
    if (!user) return { status: 404, error: { code: 'NOT_FOUND', message: 'User not found' } };
    await db('users').where('id', ctx.req.query.id).delete();
    return { status: 204, data: null };
  },
};

There's nothing special about where shared functions live — colocate them in the same file, or put them in a separate module and import them. The key is that infrastructure (db, events, etc.) is always passed explicitly as parameters, never imported as globals.

On this page