Files
clean-architecture-backend-…/src/adapter/inbound/web/CLAUDE.md
T

12 KiB

adapter:inbound:web — inbound HTTP adapter

Registered identity

  • Module ID: adapter-inbound-web
  • Gradle path: :adapter:inbound:web
  • Focused test (derived from Gradle path): ./gradlew :adapter:inbound:web:test --console=plain
  • Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
  • Registry SSOT: src/config/architecture/modules.json.

Package root: dev.caskeleton.adapter.inbound.web.

코드 주석에서 덜어낸 설계 결정의 근거README.md 가 모아둔다 (이 문서는 모듈 규칙 SSOT).

Responsibility

  • HTTP controllers.
  • Request/response DTOs.
  • Request DTO to application command mapping.
  • Authentication, validation, error mapping, filters, and web/security settings.
  • Sanitized request correlation context exposed through application-owned CorrelationIdPort.
  • Transport-owned OpenAPI customization that keeps ApiError.details as type: object without leaking Swagger dependencies into shared-contract.

Allowed

  • :application-core
  • :domain-core
  • :shared-contract
  • Spring Web/Security/Validation dependencies.

Forbidden

  • Direct dependency on adapter-persistence or adapter-outbound.
  • Direct repository or JPA entity access from controllers.
  • Core business rules in controller, filter, config, mapper, or settings code.
  • DTO leakage into application or domain.

Boundary validation & mapper contract

feature-boundary-validation-mapping-contract (LLM Wiki branch note) fixes the behaviour at this layer's boundaries. The repo-level guardrails (ArchUnit + Jackson config + handler) only catch the static violations — the contract below also drives the runtime patterns reference implementations must follow.

  • B1 — Jackson policy at the request boundary. spring.jackson.deserialization pins fail-on-unknown-properties, fail-on-null-for-primitives, fail-on-ignored-properties to true and read-unknown-enum-values-as-null to false. Do NOT undo this per-DTO with class-level @JsonIgnoreProperties(ignoreUnknown = true) — ArchUnit rule request_dtos_do_not_silence_unknown_fields blocks it. Use wrapper types (Integer, Long, Boolean, Optional<T>) in request records so JSON null cannot become primitive 0.
  • B2 — PATCH semantics. Do not adopt RFC 7396 application/merge-patch+json (null = deletion). PATCH endpoints must distinguish absent (no change), explicit null (clear field), and value (replace). Use org.openapitools:jackson-databind-nullable (JsonNullable<T>) or Optional<T> wrappers on request records.
  • B3 — Mapper-internal failures. Map record canonical-constructor IllegalArgumentException, MapStruct generated NPE, ACL normalization failures, etc. by throwing MappingException (sample implementation in sample-portfolio); the global handler routes it to MAPPING_FAILED (HTTP 400), never to BAD_PARAMETER or INTERNAL_ERROR. Plain IllegalArgumentException remains BAD_PARAMETER for non-mapper callers.
  • B4 — Validation layering. Class-level Bean Validation constraints belong to the syntax layer (request DTO). Domain invariants belong to application-core / domain-core. Use @GroupSequence(...) to short-circuit invariant evaluation when syntax fails. Keep @Valid cascade depth ≤ 3.
  • B5 — Polymorphic deserialization. Calling ObjectMapper.enableDefaultTyping() / activateDefaultTyping() or referencing LaissezFaireSubTypeValidator is the CVE-2019-14379 RCE entry point and is blocked by ArchUnit (no_jackson_laissez_faire_subtype_validator, no_jackson_enable_default_typing_call). Sealed Command types must use @JsonTypeInfo(use = NAME) + @JsonSubTypes, or a BasicPolymorphicTypeValidator allowlist.
  • B6 — Virtual thread context propagation. With spring.threads.virtual.enabled=true, do not use InheritableThreadLocal (ArchUnit rule no_inheritable_thread_local). Filters and interceptors must propagate requestId / traceId via SLF4J 2.0+ MDC or RequestContextHolder.
  • B7 — Outbound ACL mapper scope. Outbound HTTP / messaging adapter responses must pass through an ACL mapper (normalization, masking, public field selection) before reaching application-core or domain-core — the same boundary contract as inbound. Raw external response types must not leak into domain-core.
  • B8 — Bulk endpoint partial success. Envelope success = true only when every item succeeded. Partial failure responds with success = false + error.code = BATCH_PARTIAL_FAILURE + error.details[] (per-item array) — a different shape from the single-item endpoint. Document the shape divergence in the OpenAPI spec.

Domain @RestControllerAdvice in a consuming module must be annotated @Order(Ordered.HIGHEST_PRECEDENCE) (or otherwise ordered ahead of this module's base GlobalExceptionHandler), because the base handler's catch-all @ExceptionHandler(Exception.class) would otherwise resolve domain exceptions to INTERNAL_ERROR. See sample-portfolio's DomainExceptionHandler for the pattern.

The base operational handler (error/GlobalExceptionHandler), the error-code contract (dev.caskeleton.shared.error.ApiErrorCode + OperationalError), the error/ErrorResponseFactory, and the envelope/EnvelopeBodyAdvice now live in production modules (adapter:inbound:web / shared-contract), so the running application provides them without depending on sample-portfolio. Domain-specific exception handlers and error codes live in the consuming module (see sample's DomainExceptionHandler / PortfolioErrorCode).

Schema / serialization contract

feature-schema-serialization-contract (LLM Wiki branch note) fixes the response producer side of the wire contract — the sibling of the B1 request consumer policy above. The deserialization switches (B1) and the null/empty/missing 3-state (Patch<T> + JsonNullable, B2) already cover the inbound side; the rules below cover the outbound side. The Jackson properties live in app-bootstrap (application.yml spring.jackson.serialization.* / spring.jackson.generator.*); ArchUnit + effective-config tests live in app-bootstrap (JacksonSerializationPolicyTest, no_bigdecimal_double_constructor).

  • S1 — Date / time / timezone (D2). WRITE_DATES_AS_TIMESTAMPS=false is pinned, so java.time values serialize as ISO-8601 strings via JavaTimeModule (OffsetDateTime"...Z", LocalDate"YYYY-MM-DD"), never a numeric epoch or [y,m,d,...] array. Server timezone is UTC: emit instants as OffsetDateTime/Instant with a Z offset. Use LocalDate only for date-only calendar fields. Do not put timezone-less LocalDateTime on a response DTO — it serializes without an offset and breaks the contract.
  • S2 — Money / BigDecimal (D3). Default scale 2, rounding HALF_UP unless the domain documents otherwise (KRW/JPY = scale 0 with a schema note). WRITE_BIGDECIMAL_AS_PLAIN=true is pinned so values never serialize in scientific notation. Pick one JSON representation per API and state it in the OpenAPI schema: string (@JsonSerialize(using = ToStringSerializer.class)) for public / financial endpoints (client parses, no precision loss), or number + plain for internal service-to-service endpoints. Never rely on the default — decide at endpoint design time.
  • S3 — new BigDecimal(double) is banned. The double/float constructors capture binary floating-point error (new BigDecimal(0.1)0.1). Build from a String (new BigDecimal("0.1")) or BigDecimal.valueOf(double). Enforced by the no_bigdecimal_double_constructor ArchUnit rule (D3 / SBMS-C3).
  • S4 — Enum / null·empty·missing. Request-side unknown enum → VALIDATION_FAILED (B1 read-unknown-enum-values-as-null=false); legacy values map through an explicit adapter, never a silent fallback. The absent / explicit-null / value distinction is owned by the inbound web mapper (Controller DTO → Command), expressed with Patch<T> (B2); domain-core and application-core receive the already-resolved 3-state, never a wire type.
  • S5 — Out of this branch's scope. OpenAPI drift enforcement (D5) is owned by the verification suite / api-contract-baseline; removed-field-reuse ban tooling (D6, x-removed-fields vs markdown catalog) is needs-confirmation; Avro Schema Registry for outbox/event (D7) and response field rename/versioning (feature-api-compatibility-deprecation-contract) are separate branches.

Business rule validation contract

feature-business-rule-validation-contract (LLM Wiki branch note) fixes which rule is validated at which boundary, so "validation" does not collapse into the controller DTO or a DB constraint. It sits on top of the boundary/mapping contract above and is enforced by ArchUnit + contract tests (not new runtime mechanism).

Layer Owner Validates error.category Enforced by
syntax / shape adapter:inbound:web request DTO (@Valid / jakarta.validation) request shape, types, required fields VALIDATION validation_constraints_stay_at_web_boundary ArchUnit rule
use case policy application-core authorization, cross-aggregate policy, state preconditions AUTHZ / CONFLICT BusinessRuleValidationContractTest
domain invariant domain-core model / value object constructor business invariants (e.g. end ≥ start) CONFLICT / VALIDATION domain unit tests (e.g. PeriodTest) — constructor is the sole, immutable construction path
persistence integrity adapter-persistence (translator owned by feature-persistence-failure-baseline) unique / FK / check / serialization DATA_INTEGRITY / CONFLICT BusinessRuleValidationContractTest + leak test
  • C1 — Validation annotations stay at the web boundary. jakarta.validation (@NotNull, @Valid, …) must appear only in adapter:inbound:web. domain-core and application-core express invariants and policy as plain Java. The validation_constraints_stay_at_web_boundary ArchUnit rule fails the build if a Bean Validation annotation leaks into ..domain.. or ..application...
  • C2 — Business invariants live in the domain, un-bypassable. Enforce invariants in the value-object / entity constructor (the sole construction path) and keep the type immutable, so no application-service or persistence path can hand out an invariant-violating instance. A DB constraint is a backstop, never the only check (Forbidden: "DB constraint as only invariant").
  • C3 / C7 / D9 — Persistence integrity maps to an operational error, leak-free. A unique/FK/check/serialization failure maps to DATA_INTEGRITY / CONFLICT with a client-safe message only. The raw SQL, constraint/index name, SQLState code, exception class, and stack frame must never reach error.message or error.details. The base GlobalExceptionHandler catch-all already replaces the message with "Internal server error" and emits null details; the category-correct mapping (23505 → CONFLICT/DB_UNIQUE_VIOLATION, …) is owned by feature-persistence-failure-baseline's persistence-adapter translator.
  • C8 — Duplicate validation needs a canonical owner. The same rule MAY be pre-checked at another layer for UX / performance (e.g. an application pre-check mirroring a DB unique constraint), but the canonical owner of the rule must be named in a code comment or the relevant CLAUDE.md. A duplicate validator with no documented owner is a review failure (silent contradiction risk). This is a process gate (PR review), not an automated rule — needs-confirmation until an owner-marker annotation is justified.

Out of this branch's scope (cross-referenced, not re-implemented here): the SQLState→code 9-row matrix and the DataAccessException translator (feature-persistence-failure-baseline); the Jackson B1/B2 request-boundary switches and mapper sentinel (feature-boundary-validation-mapping-contract); the envelope, Category enum, and OperationalError codes (feature-operational-error-observability-foundation).

Test

cd src
./gradlew :adapter:inbound:web:test --console=plain