Install
$ agentstack add skill-softtor-nestjs-hexagonal-infrastructure ✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ● Network access Used
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
Verified badge
Passed review? Show it. Paste this badge into your README — it links to the public security report.
Reliability & compatibility
Declared compatibility
Compatibility is declared by the source manifest. End-to-end runtime verification is coming — see below.
We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps — measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.
How agent discovery & health will work →About
Infrastructure Layer
Infrastructure wires the domain to the outside world: databases, event buses, HTTP frameworks, and external services. It is the only layer allowed to import NestJS decorators and Prisma.
Directory Structure
infrastructure/
├── .module.ts # @Module wiring — exports ONLY port tokens
├── controllers/
│ ├── .controller.ts
│ └── dtos/ # Request DTOs — class-validator lives here only
│ └── __tests__/
├── database/
│ ├── prisma/
│ │ ├── models/
│ │ │ └── -model.mapper.ts # static toEntity() / toModel()
│ │ └── repositories/
│ │ ├── prisma-.repository.ts
│ │ └── __tests__/
│ └── in-memory/
│ └── repositories/
│ └── -in-memory.repository.ts # for unit tests
├── adapters/
│ └── .adapter.ts # implements cross-module port interface
└── listeners/
└── -created.handler.ts # @EventsHandler — side effects only
1. Prisma Repository
Pure persistence. No domain logic, no event dispatch.
// infrastructure/database/prisma/repositories/prisma-.repository.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '@/shared/infrastructure/database/prisma.service';
import { Entity } from '../../domain/entities/.entity';
import type { Repository } from '../../domain/repositories/.repository';
@Injectable()
export class PrismaRepository implements Repository.Repository {
sortableFields: string[] = ['name', 'createdAt'];
constructor(private readonly prisma: PrismaService) {}
async findById(id: string): PromiseEntity | null> {
const record = await this.prisma..findUnique({ where: { id } });
if (!record) return null;
return ModelMapper.toEntity(record);
}
async save(entity: Entity): Promise {
const data = ModelMapper.toModel(entity);
const existing = await this.prisma..findUnique({ where: { id: entity.id } });
if (existing) {
await this.prisma..update({ where: { id: entity.id }, data });
} else {
await this.prisma..create({ data });
}
}
async search(
props: Repository.SearchParams,
): PromiseRepository.SearchResult> {
const sortable = this.sortableFields.includes(props.sort ?? '') || false;
const orderByField = sortable ? props.sort : 'createdAt';
const orderByDir = sortable ? props.sortDir : 'desc';
const whereClause: Record = {
organizationId: props.filter?.organizationId,
};
if (typeof props.filter?.name === 'string') {
whereClause.name = { contains: props.filter.name, mode: 'insensitive' };
}
const [count, records] = await Promise.all([
this.prisma..count({ where: whereClause }),
this.prisma..findMany({
where: whereClause,
orderBy: { [orderByField]: orderByDir },
skip: props.page > 0 ? (props.page - 1) * props.perPage : 0,
take: props.perPage > 0 ? props.perPage : 15,
}),
]);
return new Repository.SearchResult({
items: records.map(ModelMapper.toEntity),
total: count,
currentPage: props.page,
perPage: props.perPage,
sort: orderByField,
sortDir: orderByDir,
filter: props.filter,
});
}
async delete(id: string, organizationId: string): Promise {
await this.prisma..delete({ where: { id, organizationId } });
}
}
See full template: references/prisma-repository.md
2. Model Mapper
Bridges Prisma models and domain entities. Static methods only.
// infrastructure/database/prisma/models/-model.mapper.ts
import { Entity } from '../../../domain/entities/.entity';
import type { } from '@prisma/client';
export class ModelMapper {
static toEntity(model: ): Entity {
return Entity.restore(
{
organizationId: model.organizationId,
name: model.name,
createdAt: model.createdAt,
updatedAt: model.updatedAt ?? undefined,
},
model.id,
);
}
static toModel(entity: Entity): Omit, never> {
return {
id: entity.id,
organizationId: entity.organizationId,
name: entity.name,
createdAt: entity.createdAt,
updatedAt: entity.updatedAt ?? null,
};
}
}
Rule: toEntity() always calls Entity.restore() — never Entity.create().
3. In-Memory Repository
For unit tests. Holds state in a plain Map. No Prisma, no NestJS.
// infrastructure/database/in-memory/repositories/-in-memory.repository.ts
import { Entity } from '../../../domain/entities/.entity';
import type { Repository } from '../../../domain/repositories/.repository';
export class InMemoryRepository implements Repository.Repository {
readonly items: MapEntity> = new Map();
sortableFields: string[] = ['name', 'createdAt'];
async findById(id: string): PromiseEntity | null> {
return this.items.get(id) ?? null;
}
async save(entity: Entity): Promise {
this.items.set(entity.id, entity);
}
async search(
props: Repository.SearchParams,
): PromiseRepository.SearchResult> {
let items = Array.from(this.items.values()).filter(
(e) => e.organizationId === props.filter?.organizationId,
);
if (props.filter?.name) {
const term = props.filter.name.toLowerCase();
items = items.filter((e) => e.name.toLowerCase().includes(term));
}
const total = items.length;
const page = props.page ?? 1;
const perPage = props.perPage ?? 15;
const start = (page - 1) * perPage;
return new Repository.SearchResult({
items: items.slice(start, start + perPage),
total,
currentPage: page,
perPage,
sort: props.sort ?? null,
sortDir: props.sortDir ?? null,
filter: props.filter ?? null,
});
}
async delete(id: string, organizationId: string): Promise {
const entity = this.items.get(id);
if (entity?.organizationId === organizationId) {
this.items.delete(id);
}
}
}
See full template: references/in-memory-repository.md
4. Module Wiring
Module exports ONLY port tokens. Never use cases, never Prisma classes.
// infrastructure/.module.ts
import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { DatabaseModule } from '@/shared/infrastructure/database/database.module';
import { _REPOSITORY } from '../domain/repositories/.repository';
import { PrismaRepository } from './database/prisma/repositories/prisma-.repository';
import { CreateHandler } from '../application/commands/create-.handler';
import { GetHandler } from '../application/queries/get-.handler';
import { CreatedHandler } from './listeners/-created.handler';
import { Controller } from './controllers/.controller';
@Module({
imports: [CqrsModule, DatabaseModule],
controllers: [Controller],
providers: [
PrismaRepository,
{ provide: _REPOSITORY, useExisting: PrismaRepository },
CreateHandler,
GetHandler,
CreatedHandler,
],
exports: [_REPOSITORY], // NEVER export use cases or repositories
})
export class Module {}
For plain UseCase pattern (non-CQRS), inject via useFactory:
{
provide: CREATE__USE_CASE_TOKEN,
useFactory: (repo: Repository.Repository) =>
new CreateUseCase(repo),
inject: [_REPOSITORY],
},
See full template: references/module-wiring.md
5. Adapter Pattern
Adapters implement port interfaces defined in other bounded contexts. They translate between the external contract and the local domain.
// infrastructure/adapters/.adapter.ts
import { Injectable } from '@nestjs/common';
import { } from '@//application/ports/.port';
import { Service } from '../services/.service';
@Injectable()
export class Adapter implements {
constructor(private readonly service: Service) {}
async findById(id: string): Promise {
const entity = await this.service.findById(id);
return entity ? this.mapToProps(entity) : null;
}
private mapToProps(entity: Entity): ExternalPortProps {
return { id: entity.id, name: entity.name };
}
}
// In module providers:
// Adapter,
// { provide: '', useExisting: Adapter },
// exports: [''],
See full template: references/adapter-patterns.md
6. Event Handler Infrastructure
Event handlers react to domain events and perform side effects. They must never throw — log errors and continue.
// infrastructure/listeners/-created.handler.ts
import { Logger } from '@nestjs/common';
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { CreatedEvent } from '../../domain/events/-created.event';
@EventsHandler(CreatedEvent)
export class CreatedHandler implements IEventHandlerCreatedEvent> {
private readonly logger = new Logger(CreatedHandler.name);
async handle(event: CreatedEvent): Promise {
try {
// side effects: publish to RabbitMQ, broadcast via WebSocket, update Redis projection
this.logger.log(`Handling CreatedEvent for ${event.entityId}`);
} catch (error) {
this.logger.error(
`Failed to handle CreatedEvent: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
Use @EventsHandler + IEventHandler for all new code. Never use @OnEvent for domain event handlers.
See full template: references/event-infra-patterns.md
Rules
- Repository is PURE persistence —
save(),findById(),search(),delete(). No event dispatch. - Events are committed by the Handler via
entity.commit()afterrepo.save(). - Module exports ONLY port tokens — never use cases, never Prisma repository classes.
- Adapters implement port interfaces with
{ provide: TOKEN, useExisting: Adapter }. class-validatoris ONLY allowed in presentation request DTOs.- Event handlers use
@EventsHandler+IEventHandler— never@OnEventfor new code. organizationIdin every Prisma query (multi-tenant isolation).
Exception: findById(id) may omit organizationId when used internally after authorization has been verified at the controller/guard level. For user-facing queries, prefer findById(id, organizationId).
- Use
Entity.restore()in mappers — neverEntity.create().
Checklist
- [ ] Prisma repository:
@Injectable(), implements domain repository interface - [ ]
save()uses upsert (findById check + create/update), not bareupsert - [ ]
search()always scopes byorganizationIdin whereClause - [ ] Model mapper:
toEntity()callsEntity.restore(),toModel()returns plain data - [ ] In-memory repository: state in
Map, no external dependencies - [ ] Module imports
CqrsModule+DatabaseModule(or equivalent) - [ ] Module providers: Prisma repo class +
{ provide: TOKEN, useExisting: ... } - [ ] CQRS handlers listed in providers (auto-registered via
@CommandHandler/@QueryHandler) - [ ] Event handlers listed in providers (auto-registered via
@EventsHandler) - [ ] Module
exportscontains ONLY the port token Symbol(s) - [ ] Adapter class:
@Injectable(), implements external port interface - [ ] Adapter registered:
{ provide: TOKEN, useExisting: Adapter }+ exported - [ ] Event handlers:
try/catchwith logger, never re-throw - [ ] Run
pnpm lint && pnpm check-typesbefore committing
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: Softtor
- Source: Softtor/nestjs-hexagonal
- License: MIT
- Homepage: https://github.com/Softtor/nestjs-hexagonal#readme
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.