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 anadmin-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, orbillingDetailsare 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
| Classic | OPRA |
|---|---|
| Per-request state passed as arguments or stored in thread-locals | Carried by ExecutionContext, accessed via this.context |
| Tenant/security filter added manually to each query | Declared 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 method | Single interceptor wraps every operation |
| Service singleton shared across requests | Scoped per request via for(context) — zero allocation cost |
| Transaction handle passed as a method argument | Passed once via for(ctx, { session }), flows automatically |