init: 클린 아키텍처 백엔드

This commit is contained in:
DongHyeonka
2026-07-24 14:29:36 +09:00
parent 9eed16d097
commit 821fe00c32
971 changed files with 74769 additions and 1 deletions
+198
View File
@@ -0,0 +1,198 @@
# adapter:inbound:web — inbound HTTP adapter
## Registered identity
- Module ID: `adapter-inbound-web`
- Gradle path: `:adapter:inbound:web`
- Focused test: `./gradlew :adapter:inbound:web:test --console=plain`
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
- Registry SSOT: `.harness/project/modules.yaml`.
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.
## 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
```bash
cd src
./gradlew :adapter:inbound:web:test --console=plain
```