init: 클린 아키텍처 백엔드
This commit is contained in:
@@ -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
|
||||
```
|
||||
Reference in New Issue
Block a user