takt/builtins/ja/knowledge/backend.md

486 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# バックエンド専門知識
## ヘキサゴナルアーキテクチャ(ポートとアダプター)
依存方向は外側から内側へ。逆方向の依存は禁止。
```
adapter外部 → applicationユースケース → domainビジネスロジック
```
ディレクトリ構成:
```
{domain-name}/
├── domain/ # ドメイン層(フレームワーク非依存)
│ ├── model/
│ │ └── aggregate/ # 集約ルート、値オブジェクト
│ └── service/ # ドメインサービス
├── application/ # アプリケーション層(ユースケース)
│ ├── usecase/ # オーケストレーション
│ └── query/ # クエリハンドラ
├── adapter/ # アダプター層(外部接続)
│ ├── inbound/ # 入力アダプター
│ │ └── rest/ # REST Controller, Request/Response DTO
│ └── outbound/ # 出力アダプター
│ └── persistence/ # Entity, Repository実装
└── api/ # 公開インターフェース(他ドメインから参照可能)
└── events/ # ドメインイベント
```
各層の責務:
| 層 | 責務 | 依存してよいもの | 依存してはいけないもの |
|----|------|----------------|---------------------|
| domain | ビジネスロジック、不変条件 | 標準ライブラリのみ | フレームワーク、DB、外部API |
| application | ユースケースのオーケストレーション | domain | adapter の具体実装 |
| adapter/inbound | HTTPリクエスト受信、DTO変換 | application, domain | outbound adapter |
| adapter/outbound | DB永続化、外部API呼び出し | domainインターフェース | application |
```kotlin
// CORRECT - ドメイン層はフレームワーク非依存
data class Order(val orderId: String, val status: OrderStatus) {
fun confirm(confirmedBy: String): OrderConfirmedEvent {
require(status == OrderStatus.PENDING)
return OrderConfirmedEvent(orderId, confirmedBy)
}
}
// WRONG - ドメイン層にSpringアテーション
@Entity
data class Order(
@Id val orderId: String,
@Enumerated(EnumType.STRING) val status: OrderStatus
) {
fun confirm(confirmedBy: String) { ... }
}
```
| 基準 | 判定 |
|------|------|
| ドメイン層にフレームワーク依存(@Entity, @Component等 | REJECT |
| Controller から Repository を直接参照 | REJECT。UseCase層を経由 |
| ドメイン層から外向きの依存DB, HTTP等 | REJECT |
| adapter 間の直接依存inbound → outbound | REJECT |
## API層設計Controller
Controller は薄く保つ。リクエスト受信 → UseCase委譲 → レスポンス返却のみ。
```kotlin
// CORRECT - Controller は薄い
@RestController
@RequestMapping("/api/orders")
class OrdersController(
private val placeOrderUseCase: PlaceOrderUseCase,
private val queryGateway: QueryGateway
) {
// Command: 状態変更
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun post(@Valid @RequestBody request: OrderPostRequest): OrderPostResponse {
val output = placeOrderUseCase.execute(request.toInput())
return OrderPostResponse(output.orderId)
}
// Query: 参照
@GetMapping("/{id}")
fun get(@PathVariable id: String): ResponseEntity<OrderGetResponse> {
val detail = queryGateway.query(FindOrderQuery(id), OrderDetail::class.java).join()
?: return ResponseEntity.notFound().build()
return ResponseEntity.ok(OrderGetResponse.from(detail))
}
}
// WRONG - Controller にビジネスロジック
@PostMapping
fun post(@RequestBody request: OrderPostRequest): ResponseEntity<Any> {
// バリデーション、在庫チェック、計算... Controller に書いてはいけない
val stock = inventoryRepository.findByProductId(request.productId)
if (stock.quantity < request.quantity) {
return ResponseEntity.badRequest().body("在庫不足")
}
val total = request.quantity * request.unitPrice * 1.1 // 税計算
orderRepository.save(OrderEntity(...))
return ResponseEntity.ok(...)
}
```
### Request/Response DTO 設計
Request と Response は別の型として定義する。ドメインモデルをそのままAPIに露出しない。
```kotlin
// Request: バリデーションアノテーション + init ブロック
data class OrderPostRequest(
@field:NotBlank val customerId: String,
@field:NotNull val items: List<OrderItemRequest>
) {
init {
require(items.isNotEmpty()) { "注文には1つ以上の商品が必要です" }
}
fun toInput() = PlaceOrderInput(customerId = customerId, items = items.map { it.toItem() })
}
// Response: ファクトリメソッド from() で変換
data class OrderGetResponse(
val orderId: String,
val status: String,
val customerName: String
) {
companion object {
fun from(detail: OrderDetail) = OrderGetResponse(
orderId = detail.orderId,
status = detail.status.name,
customerName = detail.customerName
)
}
}
```
| 基準 | 判定 |
|------|------|
| ドメインモデルをそのままレスポンスに返す | REJECT |
| Request DTOにビジネスロジック | REJECT。バリデーションのみ許容 |
| Response DTOにドメインロジック計算等 | REJECT |
| Request/Responseが同一の型 | REJECT |
### RESTful なアクション設計
状態遷移は動詞をサブリソースとして表現する。
```
POST /api/orders → 注文作成
GET /api/orders/{id} → 注文取得
GET /api/orders → 注文一覧
POST /api/orders/{id}/approve → 承認(状態遷移)
POST /api/orders/{id}/cancel → キャンセル(状態遷移)
```
| 基準 | 判定 |
|------|------|
| PUT/PATCH でドメイン操作approve, cancel等 | REJECT。POST + 動詞サブリソース |
| 1つのエンドポイントで複数の操作を分岐 | REJECT。操作ごとにエンドポイントを分ける |
| DELETE で論理削除 | REJECT。POST + cancel 等の明示的操作 |
## バリデーション戦略
バリデーションは層ごとに役割が異なる。すべてを1箇所に集めない。
| 層 | 責務 | 手段 | 例 |
|----|------|------|-----|
| API層 | 構造的バリデーション | `@NotBlank`, `init` ブロック | 必須項目、型、フォーマット |
| UseCase層 | ビジネスルール検証 | Read Modelへの問い合わせ | 重複チェック、前提条件の存在確認 |
| ドメイン層 | 状態遷移の不変条件 | `require` | 「PENDINGでないと承認できない」 |
```kotlin
// API層: 「入力の形が正しいか」
data class OrderPostRequest(
@field:NotBlank val customerId: String,
val from: LocalDateTime,
val to: LocalDateTime
) {
init {
require(!to.isBefore(from)) { "終了日時は開始日時以降でなければなりません" }
}
}
// UseCase層: 「ビジネス的に許可されるか」Read Model参照
fun execute(input: PlaceOrderInput) {
customerRepository.findById(input.customerId)
?: throw CustomerNotFoundException("顧客が存在しません")
validateNoOverlapping(input) // 重複チェック
commandGateway.send(buildCommand(input))
}
// ドメイン層: 「今の状態でこの操作は許されるか」
fun confirm(confirmedBy: String): OrderConfirmedEvent {
require(status == OrderStatus.PENDING) { "確定できる状態ではありません" }
return OrderConfirmedEvent(orderId, confirmedBy)
}
```
| 基準 | 判定 |
|------|------|
| ドメインの状態遷移ルールがAPI層にある | REJECT |
| ビジネスルール検証がControllerにある | REJECT。UseCase層に |
| 構造バリデーション(@NotBlank等)がドメインにある | REJECT。API層で |
| UseCase層のバリデーションがAggregate内にある | REJECT。Read Model参照はUseCase層 |
## エラーハンドリング
### 例外階層設計
ドメイン例外は sealed class で階層化する。HTTP ステータスコードへのマッピングは Controller 層で行う。
```kotlin
// ドメイン例外: sealed class で網羅性を保証
sealed class OrderException(message: String) : RuntimeException(message)
class OrderNotFoundException(message: String) : OrderException(message)
class InvalidOrderStateException(message: String) : OrderException(message)
class InsufficientStockException(message: String) : OrderException(message)
// Controller 層でHTTPステータスにマッピング
@RestControllerAdvice
class OrderExceptionHandler {
@ExceptionHandler(OrderNotFoundException::class)
fun handleNotFound(e: OrderNotFoundException) =
ResponseEntity.status(HttpStatus.NOT_FOUND).body(ErrorResponse(e.message))
@ExceptionHandler(InvalidOrderStateException::class)
fun handleInvalidState(e: InvalidOrderStateException) =
ResponseEntity.status(HttpStatus.CONFLICT).body(ErrorResponse(e.message))
@ExceptionHandler(InsufficientStockException::class)
fun handleInsufficientStock(e: InsufficientStockException) =
ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).body(ErrorResponse(e.message))
}
```
| 基準 | 判定 |
|------|------|
| ドメイン例外にHTTPステータスコードが含まれる | REJECT。ドメインはHTTPを知らない |
| 汎用的な Exception や RuntimeException を throw | REJECT。具体的な例外型を使う |
| try-catch の空 catch | REJECT |
| Controller 内で例外を握りつぶして 200 を返す | REJECT |
## ドメインモデル設計
### イミュータブル + require
ドメインモデルは `data class`(イミュータブル)で設計し、`init` ブロックと `require` で不変条件を保証する。
```kotlin
data class Order(
val orderId: String,
val status: OrderStatus = OrderStatus.PENDING
) {
// companion object の static メソッドで生成
companion object {
fun place(orderId: String, customerId: String): OrderPlacedEvent {
require(customerId.isNotBlank()) { "Customer ID cannot be blank" }
return OrderPlacedEvent(orderId, customerId)
}
}
// インスタンスメソッドで状態遷移 → イベント返却
fun confirm(confirmedBy: String): OrderConfirmedEvent {
require(status == OrderStatus.PENDING) { "確定できる状態ではありません" }
return OrderConfirmedEvent(orderId, confirmedBy, LocalDateTime.now())
}
// イミュータブルな状態更新
fun apply(event: OrderEvent): Order = when (event) {
is OrderPlacedEvent -> Order(orderId = event.orderId)
is OrderConfirmedEvent -> copy(status = OrderStatus.CONFIRMED)
is OrderCancelledEvent -> copy(status = OrderStatus.CANCELLED)
}
}
```
| 基準 | 判定 |
|------|------|
| ドメインモデルに var フィールド | REJECT。`copy()` でイミュータブルに更新 |
| バリデーションなしのファクトリ | REJECT。`require` で不変条件を保証 |
| ドメインモデルが外部サービスを呼ぶ | REJECT。純粋な関数のみ |
| setter でフィールドを直接変更 | REJECT |
### 値オブジェクト
プリミティブ型String, Intをドメインの意味でラップする。
```kotlin
// ID系: 型で取り違えを防止
data class OrderId(@get:JsonValue val value: String) {
init { require(value.isNotBlank()) { "Order ID cannot be blank" } }
override fun toString(): String = value
}
// 範囲系: 複合的な不変条件を保証
data class DateRange(val from: LocalDateTime, val to: LocalDateTime) {
init { require(!to.isBefore(from)) { "終了日は開始日以降でなければなりません" } }
}
// メタ情報系: イベントペイロード内の付随情報
data class ApprovalInfo(val approvedBy: String, val approvalTime: LocalDateTime)
```
| 基準 | 判定 |
|------|------|
| 同じ型のIDが取り違えられるorderId と customerId が両方 String | 値オブジェクト化を検討 |
| 同じフィールドの組み合わせfrom/to等が複数箇所に | 値オブジェクトに抽出 |
| 値オブジェクトに init ブロックがない | REJECT。不変条件を保証する |
## リポジトリパターン
ドメイン層でインターフェースを定義し、adapter/outbound で実装する。
```kotlin
// domain/: インターフェース(ポート)
interface OrderRepository {
fun findById(orderId: String): Order?
fun save(order: Order)
}
// adapter/outbound/persistence/: 実装(アダプター)
@Repository
class JpaOrderRepository(
private val jpaRepository: OrderJpaRepository
) : OrderRepository {
override fun findById(orderId: String): Order? {
return jpaRepository.findById(orderId).orElse(null)?.toDomain()
}
override fun save(order: Order) {
jpaRepository.save(OrderEntity.from(order))
}
}
```
### Read Model EntityJPA Entity
Read Model 用の JPA Entity はドメインモデルとは別に定義する。varmutableが許容される。
```kotlin
@Entity
@Table(name = "orders")
data class OrderEntity(
@Id val orderId: String,
var customerId: String,
@Enumerated(EnumType.STRING) var status: OrderStatus,
var metadata: String? = null
)
```
| 基準 | 判定 |
|------|------|
| ドメインモデルを JPA Entity として兼用 | REJECT。分離する |
| Entity に ビジネスロジック | REJECT。Entity はデータ構造のみ |
| Repository 実装がドメイン層にある | REJECT。adapter/outbound に |
## 認証・認可の配置
認証・認可は横断的関心事として適切な層で処理する。
| 関心事 | 配置 | 手段 |
|-------|------|------|
| 認証(誰か) | Filter / Interceptor層 | JWT検証、セッション確認 |
| 認可(権限) | Controller層 | `@PreAuthorize("hasRole('ADMIN')")` |
| データアクセス制御(自分のデータのみ) | UseCase層 | ビジネスルールとして検証 |
```kotlin
// Controller層: ロールベースの認可
@PostMapping("/{id}/approve")
@PreAuthorize("hasRole('FACILITY_ADMIN')")
fun approve(@PathVariable id: String, @RequestBody request: ApproveRequest) { ... }
// UseCase層: データアクセス制御
fun execute(input: DeleteInput, currentUserId: String) {
val entity = repository.findById(input.id)
?: throw NotFoundException("見つかりません")
require(entity.ownerId == currentUserId) { "他のユーザーのデータは操作できません" }
// ...
}
```
| 基準 | 判定 |
|------|------|
| 認可ロジックが UseCase 層やドメイン層にある | REJECT。Controller層で |
| データアクセス制御が Controller にある | REJECT。UseCase層で |
| 認証処理が Controller 内にある | REJECT。Filter/Interceptor で |
## テスト戦略
### テストピラミッド
```
┌─────────────┐
│ E2E Test │ ← 少数: API全体フロー確認
├─────────────┤
│ Integration │ ← Repository, Controller の統合確認
├─────────────┤
│ Unit Test │ ← 多数: ドメインモデル、UseCase の独立テスト
└─────────────┘
```
### ドメインモデルのテスト
ドメインモデルはフレームワーク非依存なので、純粋なユニットテストが書ける。
```kotlin
class OrderTest {
// ヘルパー: 特定の状態の集約を構築
private fun pendingOrder(): Order {
val event = Order.place("order-1", "customer-1")
return Order.from(event)
}
@Nested
inner class Confirm {
@Test
fun `PENDING状態から確定できる`() {
val order = pendingOrder()
val event = order.confirm("admin-1")
assertEquals("order-1", event.orderId)
}
@Test
fun `CONFIRMED状態からは確定できない`() {
val order = pendingOrder().let { it.apply(it.confirm("admin-1")) }
assertThrows<IllegalArgumentException> {
order.confirm("admin-2")
}
}
}
}
```
テストのルール:
- 状態遷移をヘルパーメソッドで構築(テストごとに独立)
- `@Nested` で操作単位にグループ化
- 正常系と異常系(不正な状態遷移)を両方テスト
- `assertThrows` で例外の型を検証
### UseCase のテスト
UseCase はモックを使ってテスト。外部依存を注入する。
```kotlin
class PlaceOrderUseCaseTest {
private val commandGateway = mockk<CommandGateway>()
private val customerRepository = mockk<CustomerRepository>()
private val useCase = PlaceOrderUseCase(commandGateway, customerRepository)
@Test
fun `顧客が存在しない場合はエラー`() {
every { customerRepository.findById("unknown") } returns null
assertThrows<CustomerNotFoundException> {
useCase.execute(PlaceOrderInput(customerId = "unknown", items = listOf(...)))
}
}
}
```
| 基準 | 判定 |
|------|------|
| ドメインモデルのテストにモックを使用 | REJECT。ドメインは純粋にテスト |
| UseCase テストで実DBに接続 | REJECT。モックを使う |
| テストがフレームワークの起動を必要とする | ユニットテストなら REJECT |
| 状態遷移の異常系テストがない | REJECT |
## アンチパターン検出
以下を見つけたら REJECT:
| アンチパターン | 問題 |
|---------------|------|
| Smart Controller | Controller にビジネスロジックが集中 |
| Anemic Domain Model | ドメインモデルが setter/getter だけのデータ構造 |
| God Service | 1つの Service クラスに全操作が集中 |
| Repository直叩き | Controller が Repository を直接参照 |
| ドメイン漏洩 | adapter 層にドメインロジックが漏れる |
| Entity兼用 | JPA Entity をドメインモデルとして使い回す |
| 例外握りつぶし | 空の catch ブロック |
| Magic String | ハードコードされたステータス文字列等 |