Data Modelling
Recommended Folder Layout
There is no single required structure, but the patterns below work well in practice.
The models/ folder is organized into four subfolders by type category:
simple/— custom simple types extending built-in primitives (e.g. aMoneytype built onnumber)entity/— complex types representing domain entities, typically mapped to database recordsdto/— data transfer objects used as request bodies, response shapes, or operation payloads; often derived from entities viaPartialType,OmitType, etc.enum/— enum type definitions created withEnumType()
Small applications
src/
├── models/
│ ├── simple/
│ │ └── money.ts
│ ├── entity/
│ │ ├── customer.ts
│ │ └── order.ts
│ ├── dto/
│ │ ├── customer-create.dto.ts
│ │ └── customer-update.dto.ts
│ ├── enum/
│ │ ├── order-status.ts
│ │ └── currency.ts
│ └── index.ts # re-exports all models
├── controllers/
│ ├── customers.controller.ts
│ └── orders.controller.ts
└── services/
├── customers.service.ts
└── orders.service.ts
Feature-based applications — each feature owns its models, with a shared/ folder for types used across features.
src/
├── shared/
│ └── models/
│ ├── simple/
│ │ └── money.ts
│ ├── entity/
│ │ └── address.ts
│ ├── enum/
│ │ └── currency.ts
│ └── index.ts
├── customers/
│ ├── models/
│ │ ├── entity/
│ │ │ └── customer.ts
│ │ ├── dto/
│ │ │ ├── customer-create.dto.ts
│ │ │ └── customer-update.dto.ts
│ │ └── index.ts
│ ├── customers.controller.ts
│ └── customers.service.ts
└── orders/
├── models/
│ ├── entity/
│ │ ├── order.ts
│ │ └── order-item.ts
│ ├── dto/
│ │ └── order-create.dto.ts
│ ├── enum/
│ │ └── order-status.ts
│ └── index.ts
├── orders.controller.ts
└── orders.service.ts
Defining a Model
OPRA provides four type categories. Each is defined with a decorator or factory function from @opra/common.
Simple Types
Simple types are used to introduce new primitive types into OPRA's type system by extending an existing type class. This is typically done when you need a domain-specific primitive with its own name mappings, coercion logic, and validation.
import { DECODER, ENCODER } from '@opra/common';
import { SimpleType, NumberType } from '@opra/common';
@SimpleType({
name: 'money',
nameMappings: { js: 'number', json: 'string' },
})
export class MoneyType extends NumberType {
currencySymbol?: string;
constructor(attributes?: Partial<MoneyType>) {
super(attributes);
}
// Decodes "$123.45" → extracts symbol into currencySymbol, passes 123.45 to NumberType decoder
protected [DECODER](value: unknown) {
if (typeof value === 'string') {
const match = /^([^\d-]*)(-?\d+(?:\.\d+)?)/.exec(value);
if (match) {
this.currencySymbol = match[1] || '$';
value = Number(match[2]);
}
}
return super[DECODER](value);
}
// Encodes 123.45 → "$123.45"
protected [ENCODER](value: number) {
const symbol = this.currencySymbol ?? '$';
return `${symbol}${super[ENCODER](value)}`;
}
}
See Simple Types for the full reference.
Complex Types
Complex types are class-based structures composed of named fields. They represent entities, DTOs, and any composite data shape.
import { ComplexType, ApiField, NumberType, ArrayType } from '@opra/common';
@ComplexType()
export class Customer {
@ApiField({ type: 'integer' })
declare id: number;
@ApiField({ required: true })
declare name: string;
@ApiField()
declare email?: string;
@ApiField({ type: new NumberType({ minValue: 1, maxValue: 9 }) })
declare rating?: number;
@ApiField({ type: ArrayType(Address, { maxOccurs: 5 }) })
declare addresses?: Address[];
}
Each @ApiField() carries the full contract for that field — type, required status, constraints. See Complex Types for all field options.
Enum Types
Enum types are created with the EnumType() factory. The runtime rejects any value outside the declared set.
import { EnumType } from '@opra/common';
export enum Gender {
MALE = 'M',
FEMALE = 'F',
OTHER = 'O',
UNKNOWN = 'U',
}
EnumType(Gender, {
name: 'Gender',
description: 'The gender of a person',
meanings: {
MALE: 'Male',
FEMALE: 'Female',
OTHER: 'Other',
UNKNOWN: 'Unknown',
},
});
See Enum Types for advanced usage including labels and mappings.
Mixin Types
Mixin types combine multiple complex types into a single composite structure without inheritance.
import { MixinType } from '@opra/common';
import { Address } from '../shared/models/entity/address.js';
import { Customer } from './customer.js';
@MixinType([Customer, Address])
export class CustomerWithAddress {}
See Mixin Types for details.
PickType
PickType creates a new type retaining only the specified fields from the source type.
import { PickType } from '@opra/common';
import { Customer } from './customer.js';
export class CustomerSummary extends PickType(Customer, ['id', 'name']) {}
OmitType
OmitType creates a new type with the specified fields removed. Useful for create DTOs where the server assigns generated fields such as id.
import { OmitType } from '@opra/common';
import { Customer } from './customer.js';
export class CustomerCreate extends OmitType(Customer, ['id']) {}
PartialType
PartialType makes the specified fields of the source type optional. Commonly used for PATCH operations.
import { PartialType } from '@opra/common';
import { Customer } from './customer.js';
export class CustomerPatch extends PartialType(Customer, ['name', 'email']) {}
RequiredType
RequiredType marks the specified fields of the source type as required.
import { RequiredType } from '@opra/common';
import { Customer } from './customer.js';
export class CustomerRequired extends RequiredType(Customer, ['email', 'rating']) {}
See Mapped Types for the full reference.
Named Types vs Embedded Types
Named Types
A named type is registered in the document under an explicit name and can be referenced by that name from multiple operations, other types, or external documents.
@ComplexType()
export class Address {
@ApiField({ required: true }) declare street: string;
@ApiField({ required: true }) declare city: string;
@ApiField() declare zip?: string;
}
@ComplexType()
export class Customer {
@ApiField({ type: 'integer' }) declare id: number;
@ApiField({ required: true }) declare name: string;
@ApiField({ type: Address }) declare address?: Address; // reference by name
}
Address appears in the schema as Address and can be reused in Order, Supplier, or any other type.
Embedded Types
An embedded type is defined inline inside a field declaration — no separate class, no schema name. It cannot be referenced elsewhere.
A simple type instance with custom constraints:
@ApiField({ type: new NumberType({ minValue: 1, maxValue: 9 }) })
declare rating?: number;
A mapped type projection scoped to a single field:
@ApiField({ type: PartialType(Address, ['zip']) })
declare address?: Address;
Use embedded types for one-off constraints or projections that have no meaning outside their parent field. Prefer named types for anything reused across operations or other types.
Inheriting Types
Complex types support full class inheritance. A subtype inherits every field from its parent and can add new fields or override inherited ones.
@ComplexType()
export class Animal {
@ApiField({ required: true }) declare name: string;
@ApiField() declare age?: number;
}
@ComplexType()
export class Dog extends Animal {
@ApiField({ required: true }) declare breed: string;
}
Dog exposes name, age, and breed in the schema. Overriding a field in the subtype replaces the parent's definition for that field only.
See Complex Types for inheritance rules and discriminator configuration.
Further Reading
- Simple Types — built-in primitives, semantic types, custom extensions
- Complex Types — field options, inheritance, discriminators
- Enum Types — labels, mappings, advanced usage
- Mixin Types — field merging and conflict resolution
- Mapped Types — PartialType, RequiredType, OmitType, PickType
- HTTP Controllers — full operation reference
- NestJS Integration — DI, modules, interceptors