Reference

Services

Tenant-scoped business logic exposed through a dedicated services API in dnax Framework.

Services let you group related business logic — outside of any specific collection — and expose it through a dedicated tenant-scoped HTTP endpoint. They are the right place for cross-collection workflows, third-party integrations, billing logic, reporting, or anything that doesn't naturally belong to a single collection.

How It Works

dnax discovers all files matching **/*.service.ts inside the services/ directory of each tenant. Each file exports a service created with define.Service(). Services are loaded at boot time — after collections are synced — and registered under a dedicated route prefix:

POST /services/:tenant_id/:service/:action

The :service segment matches the service name and :action matches a key in its actions map.

Directory Structure

Place your service files inside {tenant.dir}/services/:

v1/
├── collections/
│   └── users.model.ts
├── routes/
│   └── health.route.ts
└── services/
    ├── factures.service.ts
    ├── billing.service.ts
    └── reports/
        └── monthly.service.ts
Files are discovered recursively — you can organize services in subdirectories. Only files ending with .service.ts are loaded.

Define a Service

Use define.Service() to create a service:

import { define } from '@dnax/core';

export default define.Service({
  name: 'factures',
  enabled: true,
  actions: {
    generate: async ({ rest, data }) => {
      const invoice = await rest.insertOne('invoices', {
        client: data.clientId,
        total: data.total,
        status: 'draft',
      });
      return { ok: true, invoice };
    },
  },
});

The file must use export default and end with .service.ts.

Service Options

PropertyTypeRequiredDescription
namestringYesUnique service identifier — used in the URL as :service
enabledbooleanYesWhether this service is active
actionsRecord<string, Function>YesMap of action name → handler function
The name must be unique per tenant. Two services with the same name in the same tenant will collide.

Enable / Disable

Toggle enabled to control whether a service responds to requests:

export default define.Service({
  name: 'reports',
  enabled: false, // Service is loaded but rejects all calls
  actions: {
    daily: async ({ rest }) => {
      // ...
    },
  },
});

When enabled is false, calls return:

{
  "message": "Service `reports` is not enabled",
  "code": "SERVICE_NOT_ENABLED"
}

Action Context

Each action receives a context object:

{
  data: any;       // Request body `data` field
  rest: useRest;   // Rest instance scoped to the tenant
  io: Server;      // Socket.IO server instance
  error: function; // Error helper
}

data — Request Payload

The value of the data property in the request body:

actions: {
  generate: async ({ data }) => {
    console.log(data.clientId, data.total);
  },
}

rest — Database Access

A useRest instance scoped to the owning tenant — no need to pass tenant_id again:

actions: {
  listOverdue: async ({ rest }) => {
    return await rest.find('invoices', {
      $match: { status: 'overdue' },
      $sort: { dueDate: 1 },
    });
  },
}

io — Socket.IO

Emit real-time events from a service action:

actions: {
  generate: async ({ rest, data, io }) => {
    const invoice = await rest.insertOne('invoices', data);
    io.emit('invoice:created', invoice);
    return invoice;
  },
}

error — Custom Errors

Throw structured errors that map to HTTP responses:

actions: {
  pay: async ({ rest, data, error }) => {
    const invoice = await rest.findOne('invoices', data.id);
    if (!invoice) {
      throw error('Invoice not found', {
        code: 'INVOICE_NOT_FOUND',
        status: 404,
      });
    }
    if (invoice.status === 'paid') {
      throw error('Invoice already paid', {
        code: 'INVOICE_ALREADY_PAID',
        status: 400,
      });
    }
    return await rest.updateOne('invoices', data.id, {
      $set: { status: 'paid' },
    });
  },
}

Calling a Service

Services are invoked with POST requests on the /services/:tenant_id/:service/:action endpoint. The payload to the action is read from the data field of the JSON body:

POST /services/v1/factures/generate
Content-Type: application/json

{
  "data": {
    "clientId": "64f1a2b3c4d5e6f7a8b9c0d1",
    "total": 1250.00
  }
}

Response:

{
  "ok": true,
  "invoice": {
    "_id": "...",
    "client": "64f1a2b3c4d5e6f7a8b9c0d1",
    "total": 1250,
    "status": "draft"
  }
}

Examples

Multi-Action Service

import { define } from '@dnax/core';

export default define.Service({
  name: 'factures',
  enabled: true,
  actions: {
    generate: async ({ rest, data }) => {
      return await rest.insertOne('invoices', {
        client: data.clientId,
        total: data.total,
        status: 'draft',
      });
    },

    send: async ({ rest, data, error }) => {
      const invoice = await rest.findOne('invoices', data.id);
      if (!invoice) {
        throw error('Invoice not found', {
          code: 'INVOICE_NOT_FOUND',
          status: 404,
        });
      }
      // ... send email
      return await rest.updateOne('invoices', data.id, {
        $set: { status: 'sent', sentAt: new Date() },
      });
    },

    cancel: async ({ rest, data }) => {
      return await rest.updateOne('invoices', data.id, {
        $set: { status: 'cancelled' },
      });
    },
  },
});

Cross-Collection Workflow

import { define } from '@dnax/core';

export default define.Service({
  name: 'orders',
  enabled: true,
  actions: {
    checkout: async ({ rest, data, error }) => {
      const cart = await rest.findOne('carts', data.cartId);
      if (!cart || !cart.items?.length) {
        throw error('Cart is empty', {
          code: 'CART_EMPTY',
          status: 400,
        });
      }

      const total = cart.items.reduce(
        (sum: number, item: any) => sum + item.price * item.qty,
        0,
      );

      const order = await rest.insertOne('orders', {
        userId: cart.userId,
        items: cart.items,
        total,
        status: 'pending',
      });

      await rest.deleteOne('carts', data.cartId);

      return { order };
    },
  },
});

Real-Time Notification

import { define } from '@dnax/core';

export default define.Service({
  name: 'notifications',
  enabled: true,
  actions: {
    broadcast: async ({ rest, data, io }) => {
      const notif = await rest.insertOne('notifications', {
        message: data.message,
        level: data.level || 'info',
      });
      io.emit('notification', notif);
      return notif;
    },
  },
});

Multi-Tenant

Services are scoped per tenant. Each tenant's services/ directory is scanned independently and the rest instance passed to actions is automatically connected to that tenant's database:

app.boot({
  tenants: [
    { id: 'v1', dir: 'v1', database: { uri: 'mongodb://localhost:27017/app_v1' } },
    { id: 'v2', dir: 'v2', database: { uri: 'mongodb://localhost:27017/app_v2' } },
  ],
});
v1/
└── services/
    └── factures.service.ts    → POST /services/v1/factures/:action

v2/
└── services/
    └── factures.service.ts    → POST /services/v2/factures/:action

A service registered in v1 is not reachable through /services/v2/... even if the file content is identical.

Activity Logging

Every service action call is automatically logged as an activity. Each call records:

FieldDescription
tenantThe tenant ID from the URL
actionThe action name being executed
serviceThe service name (stored as collection)
inputThe data payload from the request body
resultThe response returned by the action
errorError details if the action failed
status"success" or "error"
durationExecution time in milliseconds
tokenThe decoded JWT token used for authentication

Activities are stored in the activities collection of the tenant's database.

Error Codes

Calls to the services endpoint can return the following framework errors:

CodeStatusCause
TENANT_ID_REQUIRED400Missing :tenant_id in URL
SERVICE_REQUIRED400Missing :service in URL
SERVICE_NOT_FOUND400No service matches name for this tenant
SERVICE_NOT_ENABLED400Service exists but enabled: false
SERVICE_NOT_DEFINED400Action key not present in the actions map
INVALID_JSON_BODY400JSON body could not be parsed

Custom errors thrown from your action via error(...) are returned as-is with their code, status, and meta.

Services vs Custom Actions vs Routes

Use casePick
Logic tied to a single collection (CRUD-adjacent)Custom Actions
Cross-collection or domain workflow under a tenantServices
Webhook, health check, public page, anything outside the API surfaceRoutes
Logic that runs once at boot (seed, migrate)Scripts

Summary

PropertyDescription
namestring — unique service identifier per tenant
enabledboolean — whether the service accepts calls
actionsRecord<string, (ctx) => any> — handlers exposed as :action
ctx.dataRequest body data field
ctx.restuseRest — database client scoped to the tenant
ctx.ioSocket.IO server instance
ctx.errorHelper to throw structured errors
File pattern**/*.service.ts inside {tenant.dir}/services/
EndpointPOST /services/:tenant_id/:service/:action
TimingLoaded at boot, after collections are synced

See also

Copyright © 2026