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.detailsastype: objectwithout leaking Swagger dependencies intoshared-contract.
Allowed
:application-core:domain-core:shared-contract- Spring Web/Security/Validation dependencies.
Forbidden
- Direct dependency on
adapter-persistenceoradapter-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.deserializationpinsfail-on-unknown-properties,fail-on-null-for-primitives,fail-on-ignored-propertiestotrueandread-unknown-enum-values-as-nulltofalse. Do NOT undo this per-DTO with class-level@JsonIgnoreProperties(ignoreUnknown = true)— ArchUnit rulerequest_dtos_do_not_silence_unknown_fieldsblocks it. Use wrapper types (Integer,Long,Boolean,Optional<T>) in request records so JSONnullcannot become primitive0. - 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). Useorg.openapitools:jackson-databind-nullable(JsonNullable<T>) orOptional<T>wrappers on request records. - B3 — Mapper-internal failures. Map record canonical-constructor
IllegalArgumentException, MapStruct generated NPE, ACL normalization failures, etc. by throwingMappingException(sample implementation insample-portfolio); the global handler routes it toMAPPING_FAILED(HTTP 400), never toBAD_PARAMETERorINTERNAL_ERROR. PlainIllegalArgumentExceptionremainsBAD_PARAMETERfor 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@Validcascade depth ≤ 3. - B5 — Polymorphic deserialization. Calling
ObjectMapper.enableDefaultTyping()/activateDefaultTyping()or referencingLaissezFaireSubTypeValidatoris the CVE-2019-14379 RCE entry point and is blocked by ArchUnit (no_jackson_laissez_faire_subtype_validator,no_jackson_enable_default_typing_call). SealedCommandtypes must use@JsonTypeInfo(use = NAME)+@JsonSubTypes, or aBasicPolymorphicTypeValidatorallowlist. - B6 — Virtual thread context propagation. With
spring.threads.virtual.enabled=true, do not useInheritableThreadLocal(ArchUnit ruleno_inheritable_thread_local). Filters and interceptors must propagaterequestId/traceIdvia SLF4J 2.0+ MDC orRequestContextHolder. - B7 — Outbound ACL mapper scope. Outbound HTTP / messaging adapter
responses must pass through an ACL mapper (normalization, masking, public
field selection) before reaching
application-coreordomain-core— the same boundary contract as inbound. Raw external response types must not leak intodomain-core. - B8 — Bulk endpoint partial success. Envelope
success = trueonly when every item succeeded. Partial failure responds withsuccess = 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=falseis pinned, sojava.timevalues serialize as ISO-8601 strings viaJavaTimeModule(OffsetDateTime→"...Z",LocalDate→"YYYY-MM-DD"), never a numeric epoch or[y,m,d,...]array. Server timezone is UTC: emit instants asOffsetDateTime/Instantwith aZoffset. UseLocalDateonly for date-only calendar fields. Do not put timezone-lessLocalDateTimeon a response DTO — it serializes without an offset and breaks the contract. - S2 — Money / BigDecimal (D3). Default scale 2, rounding
HALF_UPunless the domain documents otherwise (KRW/JPY = scale 0 with a schema note).WRITE_BIGDECIMAL_AS_PLAIN=trueis 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. Thedouble/floatconstructors capture binary floating-point error (new BigDecimal(0.1)≠0.1). Build from aString(new BigDecimal("0.1")) orBigDecimal.valueOf(double). Enforced by theno_bigdecimal_double_constructorArchUnit rule (D3 / SBMS-C3). - S4 — Enum / null·empty·missing. Request-side unknown enum →
VALIDATION_FAILED(B1read-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 withPatch<T>(B2);domain-coreandapplication-corereceive 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-fieldsvs markdown catalog) isneeds-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 inadapter:inbound:web.domain-coreandapplication-coreexpress invariants and policy as plain Java. Thevalidation_constraints_stay_at_web_boundaryArchUnit 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/CONFLICTwith a client-safe message only. The raw SQL, constraint/index name, SQLState code, exception class, and stack frame must never reacherror.messageorerror.details. The baseGlobalExceptionHandlercatch-all already replaces the message with"Internal server error"and emitsnulldetails; the category-correct mapping (23505 →CONFLICT/DB_UNIQUE_VIOLATION, …) is owned byfeature-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-confirmationuntil 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