# 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](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`) 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`) or `Optional` 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` + `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` (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 ```bash cd src ./gradlew :adapter:inbound:web:test --console=plain ```