Skip to main content

Data Services

A data service is the layer between an OPRA controller and a backing data store. It abstracts query translation, codec generation, projection mapping, filter application, and pagination — so your service subclass only expresses business logic, not plumbing.

OPRA ships first-class adapters for MongoDB (@opra/mongodb), relational databases via SQB (@opra/sqb), and Elasticsearch (@opra/elastic). But the data service layer is not limited to these. The base classes are designed to be extended: you can build an adapter for any data source — Redis, DynamoDB, a REST API, a custom cache — by implementing the same interface. The patterns described here apply universally, regardless of which adapter you use or build.


The request lifecycle

When a request reaches an OPRA controller, the framework parses the HTTP input — filters, projections, sort fields, pagination, and the request body — and assembles an ExecutionContext. This context is then passed into the data service layer.

HTTP Request


OPRA Controller — parses input, builds ExecutionContext


Data Service — applies documentFilter, interceptor, lifecycle hooks


Database Driver — MongoDB, SQL, Elasticsearch


HTTP Response — OPRA encodes and serializes the result

The service does not build queries from scratch on every call. The framework handles filter translation, codec generation, projection mapping, and pagination automatically. Your service subclass only needs to express what is always true (via documentFilter or commonFilter) and what changes per event (via lifecycle hooks).


Context-based execution

The ExecutionContext is the single object that carries everything known about the current request: the authenticated user, the active scope, the tenant identifier, the session or transaction handle, and any other metadata propagated through the request lifecycle.

Services access this through this.context:

export class OrdersService extends MongoCollectionService<Order> {
protected override async _beforeCreate(command: MongoEntityService.CreateCommand<Order>) {
command.input.createdBy = this.context.userId;
command.input.tenantId = this.context.tenantId;
}
}

This means authorization decisions, row-level security, and audit data are all derived from the same source — the context — rather than being passed as function arguments or stored in global state. There is no req.user thread-local or static singleton involved.


for() — contextual service instantiation

In classic service patterns, a service singleton is shared across all requests. Any per-request state (the current user, tenant, scope, or transaction handle) must be passed explicitly through every method call, or worse, stored in mutable shared state. The service itself has no awareness of who is calling it or under what conditions.

OPRA takes a fundamentally different approach. for() does not merely scope a service to a request — it creates a fully configured, ready-to-execute service instance tailored to the current moment. The context, the active scope, the security constraints, the transaction handle, and any overridden properties are all baked into the instance at construction time. From that point on, every method call on that instance operates within those conditions automatically:

// A fully configured instance — context, documentFilter, interceptor, scope all active
const order = await this.ordersService.for(ctx).get(id);

The instance is created via prototype chaining — no constructor is called, no data is copied. The context is available throughout the entire call: in lifecycle hooks, in the interceptor, and in any method you add to the subclass. Once the request is done, the instance is garbage-collected.

You can compose further specializations without touching the original service:

// Elevate to admin scope for a privileged sub-operation
const adminSvc = this.ordersService.for(ctx, { scope: 'admin' });

// Join a transaction
const txSvc = this.ordersService.for(ctx, { session }); // MongoDB
const txSvc = this.ordersService.for(ctx, { connection }); // SQL

// Switch index/collection at runtime
const archiveSvc = this.ordersService.for(ctx, { collectionName: 'orders_archive' });

Inside a service method, pass this instead of ctx to propagate the current context — including any active transaction — to a collaborating service:

await this.inventoryService.for(this, { connection }).updateOnly(productId, patch);

This means a collaborating service called from within a transaction automatically joins that transaction, without any extra wiring at the call site.


Security

OPRA data services address security at multiple layers — not as an afterthought, but as a structural guarantee.

Row-level security with documentFilter

In classic applications, developers must remember to add a tenant check or a soft-delete condition to every query. A single forgotten clause leaks data across tenants or exposes deleted records.

The documentFilter (or commonFilter in the SQL adapter) is declared once on the service and applied automatically to every read and write operation — including operations triggered internally from lifecycle hooks. It is impossible to call a method and accidentally bypass it:

export class CustomersService extends MongoCollectionService<Customer> {
constructor(db: Db) {
super(Customer, {
db,
documentFilter: (_, _this) => ({
tenantId: _this.context.tenantId,
deletedAt: { $exists: false },
}),
});
}
}

Field-level security with scope

Scope is one of the most important security mechanisms in OPRA. Every field on a data type can be tagged with a scope pattern. The service's scope property determines which fields are active for the current request — and this affects both input and output:

  • Input codec — fields outside the current scope are stripped from the input before it reaches the database. A public-scoped request cannot write to an admin-only field, even if it is included in the request body.
  • Output codec — fields outside the current scope are stripped from the result before it is returned to the caller. Sensitive fields like passwordHash, internalScore, or billingDetails are never sent to clients that don't have the right scope.
// Admin controller — full access
async adminGet(ctx: HttpContext) {
return this.customersService.for(ctx, { scope: 'admin' }).get(id);
}

// Public controller — only public fields readable and writable
async publicGet(ctx: HttpContext) {
return this.customersService.for(ctx).get(id); // scope: undefined → public fields only
}

Scope can also be derived from the context itself — for example, set it based on the authenticated user's role — so access control is applied consistently without repeating the check in every controller method.

Type coercion

The output codec does more than filter fields — it also coerces every returned value to the declared type of its field. A number stored as a string in the database is returned as a number. A date stored as an ISO string is returned as a Date. This prevents type confusion bugs from reaching the client and ensures the response always conforms to the declared contract, regardless of what the database actually stored.

Access control with the interceptor

The service-level interceptor can enforce access control uniformly across all operations. Because it sits above the database call, it can reject or modify requests before any I/O happens:

interceptor: async (next, command) => {
if (command.crud !== 'read' && !this.context.roles.includes('editor')) {
throw new ForbiddenError('Write access requires editor role');
}
return next();
}

This is particularly useful for coarse-grained access control that does not depend on the specific document being accessed — role checks, rate limiting, or audit trail enforcement.


Lifecycle hooks — business logic without boilerplate

Instead of wrapping every call site with pre/post logic, OPRA services expose _before* and _after* hooks that fire automatically around each operation. This keeps call sites clean and centralizes concerns like timestamping, validation, and audit logging in one place:

protected override async _beforeCreate(command: MongoEntityService.CreateCommand<Customer>) {
const exists = await this.existsOne({ filter: { email: command.input.email } });
if (exists) throw new ConflictError('Email already registered');
command.input.createdAt = new Date();
}

protected override async _afterDelete(command, affected) {
if (affected) await this.events.publish('customer.deleted', { id: command.documentId });
}

Interceptor — cross-cutting concerns

The service-level interceptor wraps every operation uniformly — without knowing which specific method is being called. Use it for logging, distributed tracing, or access control that applies to the service as a whole:

super(Customer, {
db,
interceptor: async (next, command) => {
const span = tracer.startSpan(command.method);
try {
return await next();
} finally {
span.finish();
}
},
});

Differences from classic service patterns

ClassicOPRA
Per-request state passed as arguments or stored in thread-localsCarried by ExecutionContext, accessed via this.context
Tenant/security filter added manually to each queryDeclared once in documentFilter, applied automatically
Pre/post logic wrapped around call sites_before* / _after* hooks declared on the service
Cross-cutting concerns (logging, tracing) added to each methodSingle interceptor wraps every operation
Service singleton shared across requestsScoped per request via for(context) — zero allocation cost
Transaction handle passed as a method argumentPassed once via for(ctx, { session }), flows automatically