@skapxd/nest
v0.2.0
Published
Layer marker mixins and decorators for NestJS applications checked by @skapxd/eslint-opinionated
Maintainers
Readme
@skapxd/nest
Layer markers for NestJS contracts enforced by @skapxd/eslint-opinionated.
The package exports only the names the linter can verify structurally:
Dto()marks request or response DTOs through a mixin with an empty default base.Dto(Base)marks DTOs that must compose with another superclass.@UseCase()marks the application boundary that a controller injects.SKAPXD_LAYERis the shared layer key.UseCaseuses it as Nest metadata;Dtouses it as a type-level brand on instances.
The lower layer is intentionally not marked. A repository, provider, or domain service is inferred by discard: it is an @Injectable that is not @UseCase and not @Controller. Adding @Repository, @Provider, or @DomainService now would sell a semantic guarantee that ESLint cannot prove from AST alone. @Repository may still make sense later for a concrete ORM rule, for example enforcing that @InjectModel or Mongoose imports only appear in repository classes.
Why this exists
The model comes from:
- skapxd/eslint-opinionated#141: lower layers model runtime/package failures with
Result; Nest application boundaries translate those failures into constructed HTTP exceptions. - skapxd/eslint-opinionated#146: rules detect Nest layer boundaries from stable names and import origin.
Keep imports explicit:
import { Dto, UseCase } from '@skapxd/nest';Dto
Dto is a mixin, not a decorator. A decorator can write runtime metadata, but with Nest's experimentalDecorators it does not change the TypeScript type of the class. That makes it a weak signal for a rule that needs to validate the actual return type of a controller. A mixin returns a base class, so its members are part of the instance type inherited by the concrete DTO.
Use Dto() for ordinary data DTOs. It uses an empty class as its default base:
import { Dto } from '@skapxd/nest';
export class CreateUserRequestDto extends Dto() {
email!: string;
}Use Dto(Base) when the DTO must compose with another base class:
import { StreamableFile } from '@nestjs/common';
import { Dto } from '@skapxd/nest';
export class PdfFileDto extends Dto(StreamableFile) {}Intention: a DTO is transport structure only. It has no business logic, no mutable domain state, and is not injected. A database entity (@Schema or @Entity) must not be used as a DTO; the explicit marker gives the rule a whitelist instead of a fragile blacklist.
The linter has two redundant detection signals:
- Brand: the instance type has
readonly [SKAPXD_LAYER]: "dto"from@skapxd/nest. - Base identity:
Dto()and the class returned byDto(Base)are declared in@skapxd/nest, so a type-aware rule can walk the base-type chain and detect the origin.
Dto() and Dto(Base) also provide data DTO helpers through class-transformer:
import { Expose, Type } from 'class-transformer';
import { Dto } from '@skapxd/nest';
export class AddressDto extends Dto() {
@Expose()
street!: string;
}
export class UserDto extends Dto() {
@Expose()
name!: string;
@Expose()
@Type(() => AddressDto)
address!: AddressDto;
}
const user = UserDto.fromPrimitives({
name: 'Ada',
address: { street: 'Main Street' },
});
user.address instanceof AddressDto; // true
user.toPrimitives(); // { name: 'Ada', address: { street: 'Main Street' } }toPrimitives() is typed from the DTO instance: methods and the SKAPXD_LAYER brand are omitted, arrays and nested DTO properties are transformed recursively, and Date is preserved as the Date instance that class-transformer returns. It does not depend on @codelytv/primitives-type; that package targets domain entities and Value Objects, while this package keeps the DTO contract local and license-compatible.
fromPrimitives and toPrimitives are for data DTOs. They are not a universal construction contract for DTOs that wrap non-JSON resources. For example, class PdfFileDto extends Dto(StreamableFile) {} is valid for the linter and instanceof StreamableFile, but reconstructing a binary stream from primitives is not a useful API.
Breaking change from 0.1.x: replace @Dto() class X {} with class X extends Dto() {} or class X extends Dto(Base) {}.
@UseCase
@UseCase replaces @Injectable() on application use-cases:
import { UseCase } from '@skapxd/nest';
@UseCase()
export class CreateUserUseCase {
async execute(input: CreateUserRequestDto): Promise<CreateUserResponseDto> {
// Consume Result from lower-layer services and throw constructed HttpException on failure.
}
}Intention: a use-case orchestrates one business flow, typically one endpoint. It is the application boundary where a lower-layer Result becomes a constructed HttpException; it does not return Result. Controllers inject use-cases. Use-cases inject lower-layer services, repositories, or providers.
@UseCase is composed with Nest's @Injectable() and writes "use-case" under the imported SKAPXD_LAYER key.
Read metadata by importing the exported key, not by using the string manually:
import { SKAPXD_LAYER } from '@skapxd/nest';
Reflect.getMetadata(SKAPXD_LAYER, CreateUserUseCase);Follow-ups not in this release
- Validating DTOs with
class-validatorand deciding whether the boundary throws or lower layers returnResultbelongs in a separate release. Shipping validation here would couple marking, transformation, and error policy into one API. - Runtime guards or runtime brands for
Dto, and a type/runtime brand forUseCase, are optional future work. The current linter contract is type-aware for DTOs and decorator-aware for use-cases.
Publishing
This package is configured for npm Trusted Publishing through GitHub Actions OIDC. There is no NPM_TOKEN in the workflow. Publishing runs from .github/workflows/ci.yml when a GitHub Release is published.
The package owner must create the Trusted Publisher in npm for package @skapxd/nest with:
- GitHub repository:
skapxd/nest - Workflow:
ci.yml
Without that npm-side configuration, npm publish --access public from CI will fail, commonly as a scoped package 404.
