ORM base classes
The standard stack is BaseController → BaseService → BaseRepository, parameterized by an entity and a DTO. The repository owns query/scoping mechanics, the service maps domain output and mutation policy, and the controller exposes a small HTTP surface.
Entity and repository
import { Inject, Injectable } from '@nestjs/common';
import { Column, DataSource, Entity, Index } from 'typeorm';
import {
AUDIT_STRATEGY,
BaseEntity,
BaseRepository,
ContextService,
Scoped,
SearchableFields,
TENANCY_STRATEGY,
WithAudit,
type IAuditStrategy,
type ITenancyStrategy,
} from '@sdcorejs/nestjs/core';
@SearchableFields({ exact: ['sku'], contain: ['name'], activeColumn: 'isActive' })
@Entity('product')
@Index(['tenantCode', 'sku'], { unique: true })
export class Product extends WithAudit(BaseEntity) {
@Column() @Scoped() tenantCode!: string;
@Column() sku!: string;
@Column() name!: string;
@Column({ default: true }) isActive!: boolean;
}
@Injectable()
export class ProductRepository extends BaseRepository<Product> {
constructor(
dataSource: DataSource,
context: ContextService,
@Inject(TENANCY_STRATEGY) tenancy: ITenancyStrategy,
@Inject(AUDIT_STRATEGY) audit: IAuditStrategy,
) {
super(Product, dataSource, {
contextService: context,
tenancyStrategy: tenancy,
auditStrategy: audit,
logHistory: true,
});
}
}logHistory: true writes history only when a recorder is registered; enabling actionHistory: {...} registers its service by default. A scoped history write fails before commit if the persisted resource cannot supply its complete scope.
Service and DTO
import { Injectable } from '@nestjs/common';
import { BaseService, type Dto } from '@sdcorejs/nestjs/core';
export interface ProductDto extends Dto {
sku: string;
name: string;
}
@Injectable()
export class ProductService extends BaseService<Product, ProductDto> {
constructor(repository: ProductRepository) {
super(repository);
}
mapDTO(entity: Product | undefined | null): ProductDto | null {
if (!entity) return null;
return {
id: entity.id,
sku: entity.sku,
name: entity.name,
deletable: true,
restorable: Boolean(entity.deletedAt),
};
}
}deletable and restorable are service-level policy flags. delete(), softDelete(), and restore() act only on DTOs whose corresponding flag is true.
Controller and actual HTTP behavior
import { Controller } from '@nestjs/common';
import { BaseController } from '@sdcorejs/nestjs/core';
@Controller('products')
export class ProductController extends BaseController<Product, ProductDto> {
constructor(service: ProductService) {
super(service);
}
}| Method | Route | Nest default status | Body |
|---|---|---|---|
| POST | /products/search?keyword=... | 201 | { data: ProductDto[] } |
| POST | /products/paging | 201 | { data: { items, total } } |
| GET | /products/:id | 200 | { data: ProductDto | null } |
| DELETE | /products/:id | 200 | { data: null } |
There is no @HttpCode(204) on delete. Override a route and add @HttpCode(...) if your application needs another contract.
all(), pagingDeleted(), soft delete, restore, create, update, and import are service/repository APIs but are not exposed by BaseController. Add explicit application routes with permissions and validation.
Paging, filters, relations, and search
- Pages are 0-based; omitted/non-positive
pageSizebecomes 10 and values above 200 are capped. all()has no limit. Do not expose it as a generic public endpoint.BaseRepositoryArgs.relationssupports dot paths and scopes joined@Scoped()entities.andWheresis for application-owned predicates. Never place raw client strings in SQL.- Sort/filter column names are resolved against TypeORM metadata.
- UUID search is an exact scoped lookup; configured search returns at most 20 rows.
- Contains-search requires PostgreSQL's
unaccentextension.
Schema metadata
import { Schema, SchemaProp } from '@sdcorejs/nestjs/core';
@Schema({ name: 'Product', description: 'Sellable catalog item' })
class ProductForm {
@SchemaProp({ label: 'SKU', required: true, unique: true })
sku!: string;
}@Schema() and @SchemaProp() attach UI-facing metadata returned by service.schema(); they do not validate requests. Use ZodValidationGuard for runtime validation.
Transactions
Repository mutation methods accept an optional QueryRunner. For scoped operations, a supplied runner must have an active transaction. The repository starts, commits, rolls back, and releases its own runner only when the caller did not supply one.
Read APIs also accept an optional final qr?: QueryRunner: repository/service paging(req, args, qr), pagingDeleted(req, args, qr), all(filters, args, qr), search(keyword, filters, qr), and detail(id, args, qr), plus repository findByIds(ids, args, qr). Existing calls without a runner are unchanged.
const result = await dataSource.transaction(async (manager) => {
// Retain application authorization and scope setup around this operation.
return productService.paging(request, { relations: ['category'] }, manager.queryRunner);
});The runner must belong to the repository's exact DataSource, its manager must be bound to that runner, and it must be unreleased with an active transaction. Invalid runners fail before reading with core.repository.invalid-query-runner, including empty batch reads. Only undefined omits the runner; null is invalid. Core uses the existing connection and never begins, commits, rolls back, or releases a supplied read transaction, even on errors. The caller must finish its transaction and handle failures. This avoids borrowing a second connection while every pool slot is already held by an application transaction. Reads can see that transaction's uncommitted writes.
Tenancy and relation scope, filters, sorting, paging and DTO mapping still apply. The existing UUID-search shortcut applies tenancy scope but ignores caller filters; use a filtered paging request with an exact id filter when extra business predicates must also apply. QueryRunner is a server-side API only: BaseController does not bind it from HTTP input.
