원클릭으로
hexagonal-architecture
Use when designing or reviewing system architecture. Hexagonal + DDD + CQRS patterns.
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
메뉴
Use when designing or reviewing system architecture. Hexagonal + DDD + CQRS patterns.
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
SOC 직업 분류 기준
| name | hexagonal-architecture |
| description | Use when designing or reviewing system architecture. Hexagonal + DDD + CQRS patterns. |
| version | 1.0.0 |
| tags | ["architecture","patterns"] |
Reference implementations:
Presentation → Infrastructure → Application → Domain
(inward-only dependencies)
∀ layer L: imports(L) ⊆ inner_layers(L) — NO EXCEPTIONS
| Layer | Depends On | Never Depends On |
|---|---|---|
| Domain | nothing (pure) | App, Infra, Presentation |
| Application | Domain | Infra, Presentation |
| Infrastructure | Domain, Application | Presentation |
| Presentation | Application | Domain directly, Infra directly |
Dependency arrow always points inward. Outer layers know about inner layers; inner layers oblivious to outside world.
Innermost ring. Pure business logic, zero framework dependencies.
Allowed imports: zod, node:crypto, Result type — NOTHING else.
Result<Entity, DomainError>import { z } from "zod";
import { Result, ok, err } from "@/shared/result";
const UserPropsSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string().min(1).max(255),
createdAt: z.date(),
});
type UserProps = z.infer<typeof UserPropsSchema>;
export class User {
private constructor(private readonly props: UserProps) {}
static create(input: { email: string; name: string }): Result<User, DomainError> {
const parsed = UserPropsSchema.safeParse({
id: crypto.randomUUID(),
email: input.email,
name: input.name,
createdAt: new Date(),
});
if (!parsed.success) return err(DomainError.validation(parsed.error));
return ok(new User(parsed.data));
}
static reconstitute(props: UserProps): User {
return new User(props);
}
get id(): string {
return this.props.id;
}
get email(): string {
return this.props.email;
}
get name(): string {
return this.props.name;
}
rename(newName: string): Result<void, DomainError> {
const parsed = z.string().min(1).max(255).safeParse(newName);
if (!parsed.success) return err(DomainError.validation(parsed.error));
(this.props as any).name = parsed.data;
return ok(undefined);
}
}
readonly)const EmailSchema = z.string().email();
export class Email {
private constructor(readonly value: string) {}
static create(raw: string): Result<Email, DomainError> {
const parsed = EmailSchema.safeParse(raw);
if (!parsed.success) return err(DomainError.validation(parsed.error));
return ok(new Email(parsed.data));
}
equals(other: Email): boolean {
return this.value === other.value;
}
}
UserCreatedEvent, OrderPlacedEvent{entity}-{action}.event.tsexport class UserCreatedEvent {
readonly occurredAt = new Date();
constructor(
readonly userId: string,
readonly email: string,
) {}
}
{aggregate}.repository.tsResult<T, DomainError> for fallible ops// domain/ports/user.repository.ts
export interface UserRepository {
findById(id: string): Promise<Result<User | null, DomainError>>;
save(user: User): Promise<Result<void, DomainError>>;
delete(id: string): Promise<Result<void, DomainError>>;
}
Orchestrates domain objects. One service per use case. Depends on domain only.
Result<void, AppError> ∨ Result<Id, AppError>{action}-{entity}.command.ts// application/commands/create-user.command.ts
export class CreateUserCommand {
constructor(
readonly email: string,
readonly name: string,
) {}
}
// application/commands/create-user.handler.ts
export class CreateUserHandler {
constructor(private readonly userRepo: UserRepository) {}
async execute(cmd: CreateUserCommand): Promise<Result<string, AppError>> {
const userResult = User.create({ email: cmd.email, name: cmd.name });
if (!userResult.ok) return err(AppError.fromDomain(userResult.error));
const saveResult = await this.userRepo.save(userResult.value);
if (!saveResult.ok) return err(AppError.fromDomain(saveResult.error));
return ok(userResult.value.id);
}
}
{action}-{entity}.query.ts// application/queries/get-user.query.ts
export class GetUserQuery {
constructor(readonly userId: string) {}
}
// application/queries/get-user.handler.ts
export class GetUserHandler {
constructor(private readonly userRepo: UserRepository) {}
async execute(query: GetUserQuery): Promise<Result<UserDto | null, AppError>> {
return this.userRepo.findById(query.userId);
}
}
Implements domain ports with real I/O. Depends on Domain ∧ Application.
sql-{aggregate}.repository.ts ∨ in-memory-{aggregate}.repository.ts// infrastructure/repositories/sql-user.repository.ts
export class SqlUserRepository implements UserRepository {
constructor(private readonly db: DatabaseClient) {}
async findById(id: string): Promise<Result<User | null, DomainError>> {
try {
const row = await this.db.query("SELECT * FROM users WHERE id = $1", [id]);
if (!row) return ok(null);
return ok(SqlUserMapper.toDomain(row));
} catch (e) {
return err(DomainError.infrastructure("Database query failed"));
}
}
async save(user: User): Promise<Result<void, DomainError>> {
try {
const row = SqlUserMapper.toPersistence(user);
await this.db.query(
"INSERT INTO users (id, email, name, created_at) VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO UPDATE SET name = $4",
[row.id, row.email, row.name, row.createdAt],
);
return ok(undefined);
} catch (e) {
return err(DomainError.infrastructure("Database write failed"));
}
}
}
sql-{aggregate}.mapper.tsEntity.reconstitute() to rebuild domain objects from persistence// infrastructure/mappers/sql-user.mapper.ts
export class SqlUserMapper {
static toDomain(row: UserRow): User {
return User.reconstitute({
id: row.id,
email: row.email,
name: row.name,
createdAt: new Date(row.created_at),
});
}
static toPersistence(user: User): UserRow {
return {
id: user.id,
email: user.email,
name: user.name,
created_at: user.createdAt.toISOString(),
};
}
}
Outermost ring. Entry points, wiring, user-facing I/O.
Depends on Application only (invokes commands/queries, ¬touches domain directly).
{action}-{entity}.dto.ts// presentation/dto/create-user.dto.ts
import { z } from "zod";
export const CreateUserRequestSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(255),
});
export type CreateUserRequest = z.infer<typeof CreateUserRequestSchema>;
export const CreateUserResponseSchema = z.object({
id: z.string().uuid(),
});
export type CreateUserResponse = z.infer<typeof CreateUserResponseSchema>;
Result errors to HTTP status codes (∨ CLI exit codes)// presentation/di/tokens.ts
export const USER_REPOSITORY = Symbol("UserRepository");
// presentation/di/module.ts
container.bind<UserRepository>(USER_REPOSITORY).to(SqlUserRepository);
class-validator, ¬class-transformer — Zod is the single validation libraryz.infer<typeof Schema> for all derived types| Rule | Rationale |
|---|---|
¬as casting | Breaks type safety; use type guards ∨ Zod .parse() |
¬any | Use unknown + narrowing ∨ generics |
| Symbol-based DI tokens | Type-safe injection, ¬magic strings |
| Generics for collections | Repository<T> base when patterns repeat |
| Type guards for narrowing | function isUser(x: unknown): x is User |
| Discriminated ∪s | { type: 'success'; data: T } | { type: 'error'; error: E } |
// Type guard example
function isValidStatus(value: unknown): value is OrderStatus {
return OrderStatusSchema.safeParse(value).success;
}
// Generic repository port
interface ReadRepository<T, Id = string> {
findById(id: Id): Promise<Result<T | null, DomainError>>;
findAll(): Promise<Result<T[], DomainError>>;
}
| Layer | Test Type | Dependencies |
|---|---|---|
| Domain | Unit | ∅ (pure logic) |
| Application | Unit | ∈-memory adapters for ports |
| Infrastructure | Integration | Real DB / external services |
| Presentation | E2E | Full stack ∨ supertest |
in-memory-{aggregate}.repository.ts// infrastructure/repositories/in-memory-user.repository.ts
export class InMemoryUserRepository implements UserRepository {
private readonly store = new Map<string, User>();
async findById(id: string): Promise<Result<User | null, DomainError>> {
return ok(this.store.get(id) ?? null);
}
async save(user: User): Promise<Result<void, DomainError>> {
this.store.set(user.id, user);
return ok(undefined);
}
async delete(id: string): Promise<Result<void, DomainError>> {
this.store.delete(id);
return ok(undefined);
}
}
user.entity.spec.ts next to user.entity.tscreate-user.handler.spec.ts next to create-user.handler.ts| Anti-Pattern | Why It's Wrong | Fix |
|---|---|---|
| Anemic domain model | Entities are data bags; logic lives ∈ services | Move behavior into entity methods |
| Domain imports infra | Violates dependency rule | Use ports (interfaces ∈ domain) |
App layer imports node:fs | Direct I/O coupling | Define a port, inject adapter |
throw ∈ domain | Untyped, unchecked error flow | Return Result<T, E> |
| Over-abstraction | Port ∀ trivial operation | Abstract only at architectural boundaries |
| Framework coupling ∈ domain | Domain tied to NestJS/Express decorators | Domain must be framework-agnostic |
| Massive inheritance | Fragile base class, tight coupling | Prefer composition over inheritance |
| String-based DI tokens | No type safety, collision risk | Use Symbol('TokenName') |
| Leaking domain types to API | Coupling consumers to internals | Map to DTOs at presentation boundary |
| God services | One service doing everything | One handler per command/query |
| Artifact | Pattern | Example |
|---|---|---|
| Entity | {entity}.entity.ts | user.entity.ts |
| Value Object | {vo}.value-object.ts | email.value-object.ts |
| Domain Event | {entity}-{action}.event.ts | user-created.event.ts |
| Repository Port | {aggregate}.repository.ts | user.repository.ts |
| Repository Adapter | sql-{aggregate}.repository.ts | sql-user.repository.ts |
| ∈-Memory Adapter | in-memory-{aggregate}.repository.ts | in-memory-user.repository.ts |
| Data Mapper | sql-{aggregate}.mapper.ts | sql-user.mapper.ts |
| Command | {action}-{entity}.command.ts | create-user.command.ts |
| Command Handler | {action}-{entity}.handler.ts | create-user.handler.ts |
| Query | {action}-{entity}.query.ts | get-user.query.ts |
| Query Handler | {action}-{entity}.handler.ts | get-user.handler.ts |
| DTO | {action}-{entity}.dto.ts | create-user.dto.ts |
| Domain Service | {name}.domain-service.ts | pricing.domain-service.ts |
| DI Token | UPPER_SNAKE symbol | USER_REPOSITORY |
src/
{module}/
domain/
entities/
value-objects/
events/
ports/
services/
errors/
application/
commands/
queries/
services/
infrastructure/
repositories/
mappers/
adapters/
presentation/
controllers/
dto/
filters/
di/
Use when reviewing architecture decisions. C4 model, dependency inversion, hexagonal boundaries.
Use during discuss-slice and plan-slice to stress-test a spec or plan against domain vocabulary before approval.
Use during research-slice to build disposable experiments that answer technical uncertainty.
Use during project:init to scaffold repo-level agent configuration (CLAUDE.md, AGENTS.md, tracker labels).
Use when debugging. 4-phase investigation, root cause, minimal fix.
Use when implementing features/fixes. Iron law: ¬∃ production code without failing test.