refactor: 프론트엔드 리펙토링

This commit is contained in:
donghyeon-ka
2026-09-18 22:05:42 +09:00
parent 5cc41467ae
commit ec7f20e2ee
100 changed files with 6005 additions and 2867 deletions
+4 -2
View File
@@ -19,8 +19,10 @@ augmentation으로 `"reference-feature": ReferenceFeatureInput`을 기여하므
별도 cast나 runtime shape 확인 없이 정확한 input type을 받는다. 다만 동적 호출로
설치되지 않은 ID가 들어오는 경우를 위해 `get`의 runtime guard도 유지한다.
예측 가능한 실패는 `src/application/result.ts`의 공통
`Result<Value, Failure = AppFailure>`로 반환한다. `AppFailure.kind`
예측 가능한 실패는 canonical shared contract인
`src/contracts/result.ts``Result<Value, Failure = AppFailure>`로 반환한다.
`src/application/result.ts`는 기존 호출자를 위한 deprecated migration shim일
뿐 새 코드의 import 경로가 아니다. `AppFailure.kind`
`ERROR_REGISTRY` key에서 파생된 닫힌 vocabulary이며, transport 호환 이름인
`ApiFailure`는 같은 type의 alias다.
@@ -2,6 +2,9 @@ import {
createFeatureHttpBinding,
type InstalledHttpOperationExecutor,
} from "../../../adapters/http/index.ts";
import {
defineFeatureAdapterContribution,
} from "../../feature-adapter-contribution.ts";
import { createReferenceFeatureInput } from "../application/reference-feature-api.ts";
import {
REFERENCE_FEATURE_ID,
@@ -17,15 +20,14 @@ export type InstalledContractOperationExecutor =
/**
* Feature-owned composition seam.
*
* The feature contributes typed operation descriptors and its application
* gateway. HTTP lifecycle/error normalization stays in the reusable capability
* binding rather than being repeated by every product feature.
* The feature receives only the HTTP capability it declares in `needs`.
* HTTP lifecycle/error normalization stays in the reusable capability binding.
*/
export function createReferenceFeatureInstalledInput(context: Readonly<{
contractOperations: InstalledHttpOperationExecutor;
http: InstalledHttpOperationExecutor;
}>) {
const http = createFeatureHttpBinding(
context.contractOperations,
context.http,
REFERENCE_HTTP_OPERATIONS,
);
@@ -35,7 +37,9 @@ export function createReferenceFeatureInstalledInput(context: Readonly<{
});
}
export const REFERENCE_FEATURE_ADAPTER_CONTRIBUTION = Object.freeze({
featureId: REFERENCE_FEATURE_ID,
createInput: createReferenceFeatureInstalledInput,
});
export const REFERENCE_FEATURE_ADAPTER_CONTRIBUTION =
defineFeatureAdapterContribution({
featureId: REFERENCE_FEATURE_ID,
needs: ["http"] as const,
createInput: createReferenceFeatureInstalledInput,
});
@@ -1,40 +1,57 @@
import {
defineFeatureHttpOperation,
defineFeatureHttpOperationForRoutes,
type FeatureHttpBinding,
} from "../../../adapters/http/index.ts";
import type {
ReferenceCreateCommand,
ReferenceGateway,
ReferenceListFilters,
} from "../application/reference-feature-api.ts";
import type { ReferenceResource } from "../domain/reference-resource.ts";
import {
CREATE_REFERENCE_RESOURCE_CONTRACT,
GET_REFERENCE_RESOURCE_CONTRACT,
LIST_REFERENCE_RESOURCES_CONTRACT,
} from "../contracts/reference-feature-contract-contribution.ts";
import {
REFERENCE_FEATURE_CONTRACT,
} from "../contracts/reference-feature-contract.ts";
import {
mapReferenceResourceListPayload,
mapReferenceResourcePayload,
} from "../contracts/reference-mapper.ts";
import type {
ReferenceGateway,
} from "../application/reference-feature-api.ts";
export type ReferenceFeatureRouteId =
keyof typeof REFERENCE_FEATURE_CONTRACT.routes;
const defineReferenceHttpOperation =
defineFeatureHttpOperationForRoutes<ReferenceFeatureRouteId>();
export const REFERENCE_HTTP_OPERATIONS = Object.freeze({
LIST_REFERENCE_RESOURCES: defineFeatureHttpOperation<
ReferenceListFilters,
readonly ReferenceResource[]
>({
operationId: "LIST_REFERENCE_RESOURCES",
LIST_REFERENCE_RESOURCES: defineReferenceHttpOperation({
contract: LIST_REFERENCE_RESOURCES_CONTRACT,
routeId: "REFERENCE_RESOURCE_LIST",
mapSuccess: mapReferenceResourceListPayload,
}),
CREATE_REFERENCE_RESOURCE: defineFeatureHttpOperation<
ReferenceCreateCommand,
ReferenceResource
>({
operationId: "CREATE_REFERENCE_RESOURCE",
CREATE_REFERENCE_RESOURCE: defineReferenceHttpOperation({
contract: CREATE_REFERENCE_RESOURCE_CONTRACT,
routeId: "REFERENCE_RESOURCE_LIST",
mapSuccess: mapReferenceResourcePayload,
mapProblem(problem, metadata) {
if (metadata.status === 409) {
return Object.freeze({
kind: "CONFLICT" as const,
code: problem.code ?? "REFERENCE_RESOURCE_CONFLICT",
});
}
if (metadata.status === 422) {
return Object.freeze({
kind: "VALIDATION_REJECTED" as const,
code: problem.code ?? "REFERENCE_RESOURCE_REJECTED",
});
}
return undefined;
},
}),
GET_REFERENCE_RESOURCE: defineFeatureHttpOperation<
Readonly<{ resourceId: string }>,
ReferenceResource
>({
operationId: "GET_REFERENCE_RESOURCE",
GET_REFERENCE_RESOURCE: defineReferenceHttpOperation({
contract: GET_REFERENCE_RESOURCE_CONTRACT,
routeId: "REFERENCE_RESOURCE_DETAIL",
mapSuccess: mapReferenceResourcePayload,
}),
@@ -2,17 +2,22 @@ import { z } from "zod";
import type {
CommandEffectDescriptor,
HttpRequestProjection,
InstalledContractContribution,
InstalledHttpContract,
RuntimeValidator,
} from "../../../contracts/external-contract-runtime.ts";
import { REFERENCE_FEATURE_ID } from "./reference-feature-contract.ts";
import {
createReferenceResourceCommandSchema,
referenceProblemSchema,
referenceResourceDtoSchema,
referenceResourceListQuerySchema,
referenceResourceParamsSchema,
type ReferenceProblem,
} from "./reference-schemas.ts";
export type { ReferenceProblem } from "./reference-schemas.ts";
/**
* §4.8. The single `TEMPLATE_FIXTURE` contribution. It keeps the reference
* HTTP vertical executable as deterministic template data and is excluded from
@@ -53,33 +58,10 @@ function zodValidator<T>(
});
}
const referenceResourceDto = z
.object({
id: z.string().min(1).max(120),
name: z.string().min(1).max(240),
createdAt: z.string().min(1).optional(),
})
.strip();
const problemSchema = z
.object({
type: z.string().min(1).max(512),
title: z.string().min(1).max(240),
status: z.int().min(100).max(599),
code: z.string().min(1).max(120).optional(),
})
.strip();
export type ReferenceProblem = z.output<typeof problemSchema>;
const PROBLEM_VALIDATOR = zodValidator("ReferenceProblem", problemSchema);
const createCommandSchema = z
.object({
name: z.string().trim().min(1).max(120),
note: z.string().trim().max(500).optional(),
})
.strict();
const PROBLEM_VALIDATOR = zodValidator(
"ReferenceProblem",
referenceProblemSchema,
);
/**
* Fixture-side classifier standing in for the package-provided pure bounded
@@ -87,17 +69,17 @@ const createCommandSchema = z
*/
const CREATE_EFFECT: CommandEffectDescriptor<ReferenceProblem> = Object.freeze({
successEffect: "APPLIED_CONFIRMED" as const,
classifyProblem({ status }: Readonly<{ status: number; problem: ReferenceProblem }>) {
if (status === 400 || status === 409 || status === 422) return "NOT_APPLIED";
classifyProblem({
status,
}: Readonly<{ status: number; problem: ReferenceProblem }>) {
if (status === 400 || status === 409 || status === 422) {
return "NOT_APPLIED";
}
return "MAYBE_APPLIED";
},
});
const LIST_REFERENCE_RESOURCES: InstalledHttpContract<
z.output<typeof referenceResourceListQuerySchema>,
readonly z.output<typeof referenceResourceDto>[],
ReferenceProblem
> = Object.freeze({
export const LIST_REFERENCE_RESOURCES_CONTRACT = Object.freeze({
contract: Object.freeze({
operationId: "LIST_REFERENCE_RESOURCES",
method: "GET" as const,
@@ -108,7 +90,7 @@ const LIST_REFERENCE_RESOURCES: InstalledHttpContract<
),
outputValidator: zodValidator(
"ReferenceResourceListPayload",
z.array(referenceResourceDto).max(100),
z.array(referenceResourceDtoSchema).max(100),
),
problemValidator: PROBLEM_VALIDATOR,
acceptedStatuses: Object.freeze([200]),
@@ -139,13 +121,13 @@ const LIST_REFERENCE_RESOURCES: InstalledHttpContract<
authProfileId: "REFERENCE_EXTERNAL_BEARER",
diagnosticsOperation: "reference.list",
}),
});
const GET_REFERENCE_RESOURCE: InstalledHttpContract<
z.output<typeof referenceResourceParamsSchema>,
z.output<typeof referenceResourceDto>,
}) satisfies InstalledHttpContract<
z.output<typeof referenceResourceListQuerySchema>,
readonly z.output<typeof referenceResourceDtoSchema>[],
ReferenceProblem
> = Object.freeze({
>;
export const GET_REFERENCE_RESOURCE_CONTRACT = Object.freeze({
contract: Object.freeze({
operationId: "GET_REFERENCE_RESOURCE",
method: "GET" as const,
@@ -156,7 +138,7 @@ const GET_REFERENCE_RESOURCE: InstalledHttpContract<
),
outputValidator: zodValidator(
"ReferenceResourcePayload",
referenceResourceDto,
referenceResourceDtoSchema,
),
problemValidator: PROBLEM_VALIDATOR,
acceptedStatuses: Object.freeze([200]),
@@ -183,24 +165,24 @@ const GET_REFERENCE_RESOURCE: InstalledHttpContract<
authProfileId: "REFERENCE_EXTERNAL_BEARER",
diagnosticsOperation: "reference.detail",
}),
});
const CREATE_REFERENCE_RESOURCE: InstalledHttpContract<
z.output<typeof createCommandSchema>,
z.output<typeof referenceResourceDto>,
}) satisfies InstalledHttpContract<
z.output<typeof referenceResourceParamsSchema>,
z.output<typeof referenceResourceDtoSchema>,
ReferenceProblem
> = Object.freeze({
>;
export const CREATE_REFERENCE_RESOURCE_CONTRACT = Object.freeze({
contract: Object.freeze({
operationId: "CREATE_REFERENCE_RESOURCE",
method: "POST" as const,
pathTemplate: "/api/reference-resources",
inputValidator: zodValidator(
"CreateReferenceResourceCommand",
createCommandSchema,
createReferenceResourceCommandSchema,
),
outputValidator: zodValidator(
"ReferenceResourcePayload",
referenceResourceDto,
referenceResourceDtoSchema,
),
problemValidator: PROBLEM_VALIDATOR,
acceptedStatuses: Object.freeze([200, 201]),
@@ -213,7 +195,7 @@ const CREATE_REFERENCE_RESOURCE: InstalledHttpContract<
operationIdentityField: "idempotencyKey",
}),
commandEffect: CREATE_EFFECT,
projectRequest(input: z.output<typeof createCommandSchema>) {
projectRequest(input: z.output<typeof createReferenceResourceCommandSchema>) {
return Object.freeze({
pathValues: Object.freeze({}),
queryEntries: Object.freeze([]),
@@ -235,7 +217,11 @@ const CREATE_REFERENCE_RESOURCE: InstalledHttpContract<
authProfileId: "REFERENCE_EXTERNAL_BEARER",
diagnosticsOperation: "reference.create",
}),
});
}) satisfies InstalledHttpContract<
z.output<typeof createReferenceResourceCommandSchema>,
z.output<typeof referenceResourceDtoSchema>,
ReferenceProblem
>;
export const REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION: InstalledContractContribution =
Object.freeze({
@@ -247,9 +233,9 @@ export const REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION: InstalledContractContribut
revision: 1 as const,
}),
http: Object.freeze([
LIST_REFERENCE_RESOURCES,
GET_REFERENCE_RESOURCE,
CREATE_REFERENCE_RESOURCE,
LIST_REFERENCE_RESOURCES_CONTRACT,
GET_REFERENCE_RESOURCE_CONTRACT,
CREATE_REFERENCE_RESOURCE_CONTRACT,
]) as readonly InstalledHttpContract<unknown, unknown, unknown>[],
events: Object.freeze([]),
});
@@ -8,6 +8,7 @@ import {
type InstalledBoundaryMapper,
type MappingResult,
} from "../../../contracts/boundary-mapper.ts";
import type { ReferenceResourceDto } from "./reference-schemas.ts";
export type ReferenceResourceView = Readonly<{
resourceId: string;
@@ -16,22 +17,19 @@ export type ReferenceResourceView = Readonly<{
optimistic?: boolean;
}>;
/**
* Operation-specific mapper. The HTTP contract has already validated the wire
* shape, so this mapper owns only wire -> domain construction.
*/
export function mapReferenceResourcePayload(
value: unknown,
dto: ReferenceResourceDto,
): MappingResult<ReferenceResource> {
if (!value || typeof value !== "object") {
return mappingFailure("MAPPING_INVARIANT_REJECTED");
}
const dto = value as Record<string, unknown>;
if (typeof dto.id !== "string" || typeof dto.name !== "string") {
return mappingFailure("MAPPING_INVARIANT_REJECTED");
}
try {
return mappingSuccess(
createReferenceResource({
id: dto.id,
displayName: dto.name,
createdAt: typeof dto.createdAt === "string" ? dto.createdAt : null,
createdAt: dto.createdAt ?? null,
}),
);
} catch {
@@ -40,14 +38,8 @@ export function mapReferenceResourcePayload(
}
export function mapReferenceResourceListPayload(
payload: unknown,
payload: readonly ReferenceResourceDto[],
): MappingResult<readonly ReferenceResource[]> {
if (!Array.isArray(payload)) {
return mappingFailure("MAPPING_INVARIANT_REJECTED");
}
if (payload.length > 100) {
return mappingFailure("OUTPUT_LIMIT_EXCEEDED");
}
const output: ReferenceResource[] = [];
for (const item of payload) {
const mapped = mapReferenceResourcePayload(item);
@@ -57,22 +49,58 @@ export function mapReferenceResourceListPayload(
return mappingSuccess(Object.freeze(output));
}
function isReferenceResourceDto(value: unknown): value is ReferenceResourceDto {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const dto = value as Record<string, unknown>;
return (
typeof dto.id === "string" &&
typeof dto.name === "string" &&
(dto.createdAt === undefined || typeof dto.createdAt === "string")
);
}
function mapUnknownReferenceResourcePayload(
value: unknown,
): MappingResult<ReferenceResource> {
return isReferenceResourceDto(value)
? mapReferenceResourcePayload(value)
: mappingFailure("MAPPING_INVARIANT_REJECTED");
}
function mapUnknownReferenceResourceListPayload(
value: unknown,
): MappingResult<readonly ReferenceResource[]> {
if (
!Array.isArray(value) ||
value.length > 100 ||
!value.every(isReferenceResourceDto)
) {
return mappingFailure(
Array.isArray(value) && value.length > 100
? "OUTPUT_LIMIT_EXCEEDED"
: "MAPPING_INVARIANT_REJECTED",
);
}
return mapReferenceResourceListPayload(value);
}
/**
* Registry-facing compatibility projection. Feature adapters should prefer the
* operation-specific mapper functions above so their result type is exact.
* Registry-facing compatibility projection. The registry is intentionally
* untyped, so its adapter performs the defensive shape check. Feature HTTP
* bindings use the typed operation-specific mappers above.
*/
export function mapReferenceOperation(
operationId: string,
payload: unknown,
): MappingResult<ReferenceResource | readonly ReferenceResource[]> {
if (operationId === "LIST_REFERENCE_RESOURCES") {
return mapReferenceResourceListPayload(payload);
return mapUnknownReferenceResourceListPayload(payload);
}
if (
operationId === "CREATE_REFERENCE_RESOURCE" ||
operationId === "GET_REFERENCE_RESOURCE"
) {
return mapReferenceResourcePayload(payload);
return mapUnknownReferenceResourcePayload(payload);
}
return mappingFailure("MAPPING_INVARIANT_REJECTED");
}
@@ -85,7 +113,7 @@ export const REFERENCE_BOUNDARY_MAPPERS = Object.freeze({
outputContractId: "ReferenceResourceList",
owner: "feature-frontend-reference-feature-vertical-slice",
maxOutputItems: 100,
map: mapReferenceResourceListPayload,
map: mapUnknownReferenceResourceListPayload,
}),
ReferenceResourceMapper: Object.freeze({
mapperId: "ReferenceResourceMapper",
@@ -94,7 +122,7 @@ export const REFERENCE_BOUNDARY_MAPPERS = Object.freeze({
outputContractId: "ReferenceResource",
owner: "feature-frontend-reference-feature-vertical-slice",
maxOutputItems: 1,
map: mapReferenceResourcePayload,
map: mapUnknownReferenceResourcePayload,
}),
} satisfies Readonly<Record<string, InstalledBoundaryMapper>>);
@@ -13,7 +13,7 @@ export const referenceResourceListQuerySchema = z
: Array.isArray(value)
? value
: [value],
z.array(z.string().trim().min(1)),
z.array(z.string().trim().min(1)).readonly(),
)
.optional(),
})
@@ -25,7 +25,7 @@ export const referenceResourceParamsSchema = z
})
.strict();
const referenceResourceDtoSchema = z
export const referenceResourceDtoSchema = z
.object({
id: z.string().min(1).max(120),
name: z.string().min(1).max(240),
@@ -33,6 +33,28 @@ const referenceResourceDtoSchema = z
})
.strip();
export type ReferenceResourceDto = z.output<
typeof referenceResourceDtoSchema
>;
export const referenceProblemSchema = z
.object({
type: z.string().min(1).max(512),
title: z.string().min(1).max(240),
status: z.int().min(100).max(599),
code: z.string().min(1).max(120).optional(),
})
.strip();
export type ReferenceProblem = z.output<typeof referenceProblemSchema>;
export const createReferenceResourceCommandSchema = z
.object({
name: z.string().trim().min(1).max(120),
note: z.string().trim().max(500).optional(),
})
.strict();
const payloadSchemas = {
ReferenceResourceListPayload: z.array(referenceResourceDtoSchema).max(100),
ReferenceResourcePayload: referenceResourceDtoSchema,
@@ -42,12 +64,7 @@ const requestSchemas = {
ReferenceResourceParams: referenceResourceParamsSchema,
ReferenceResourceListQuery: referenceResourceListQuerySchema,
NoRequest: z.object({}).strict(),
CreateReferenceResourceCommand: z
.object({
name: z.string().trim().min(1).max(120),
note: z.string().trim().max(500).optional(),
})
.strict(),
CreateReferenceResourceCommand: createReferenceResourceCommandSchema,
} satisfies Record<string, z.ZodType>;
function project(result: z.ZodSafeParseResult<unknown>) {