takt/builtins/en/knowledge/backend.md

17 KiB

Backend Expertise

Hexagonal Architecture (Ports and Adapters)

Dependency direction flows from outer to inner layers. Reverse dependencies are prohibited.

adapter (external) → application (use cases) → domain (business logic)

Directory structure:

{domain-name}/
├── domain/                  # Domain layer (framework-independent)
│   ├── model/
│   │   └── aggregate/       # Aggregate roots, value objects
│   └── service/             # Domain services
├── application/             # Application layer (use cases)
│   ├── usecase/             # Orchestration
│   └── query/               # Query handlers
├── adapter/                 # Adapter layer (external connections)
│   ├── inbound/             # Input adapters
│   │   └── rest/            # REST Controller, Request/Response DTOs
│   └── outbound/            # Output adapters
│       └── persistence/     # Entity, Repository implementations
└── api/                     # Public interface (referenceable by other domains)
    └── events/              # Domain events

Layer responsibilities:

Layer Responsibility May Depend On Must Not Depend On
domain Business logic, invariants Standard library only Frameworks, DB, external APIs
application Use case orchestration domain Concrete adapter implementations
adapter/inbound HTTP request handling, DTO conversion application, domain outbound adapter
adapter/outbound DB persistence, external API calls domain (interfaces) application
// CORRECT - Domain layer is framework-independent
data class Order(val orderId: String, val status: OrderStatus) {
    fun confirm(confirmedBy: String): OrderConfirmedEvent {
        require(status == OrderStatus.PENDING)
        return OrderConfirmedEvent(orderId, confirmedBy)
    }
}

// WRONG - Spring annotations in domain layer
@Entity
data class Order(
    @Id val orderId: String,
    @Enumerated(EnumType.STRING) val status: OrderStatus
) {
    fun confirm(confirmedBy: String) { ... }
}
Criteria Judgment
Framework dependencies in domain layer (@Entity, @Component, etc.) REJECT
Controller directly referencing Repository REJECT. Must go through UseCase layer
Outward dependencies from domain layer (DB, HTTP, etc.) REJECT
Direct dependencies between adapters (inbound → outbound) REJECT

API Layer Design (Controller)

Keep Controllers thin. Their only job: receive request → delegate to UseCase → return response.

// CORRECT - Thin Controller
@RestController
@RequestMapping("/api/orders")
class OrdersController(
    private val placeOrderUseCase: PlaceOrderUseCase,
    private val queryGateway: QueryGateway
) {
    // Command: state change
    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    fun post(@Valid @RequestBody request: OrderPostRequest): OrderPostResponse {
        val output = placeOrderUseCase.execute(request.toInput())
        return OrderPostResponse(output.orderId)
    }

    // Query: read
    @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 - Business logic in Controller
@PostMapping
fun post(@RequestBody request: OrderPostRequest): ResponseEntity<Any> {
    // Validation, stock check, calculation... should NOT be in Controller
    val stock = inventoryRepository.findByProductId(request.productId)
    if (stock.quantity < request.quantity) {
        return ResponseEntity.badRequest().body("Insufficient stock")
    }
    val total = request.quantity * request.unitPrice * 1.1  // Tax calculation
    orderRepository.save(OrderEntity(...))
    return ResponseEntity.ok(...)
}

Request/Response DTO Design

Define Request and Response as separate types. Never expose domain models directly via API.

// Request: validation annotations + init block
data class OrderPostRequest(
    @field:NotBlank val customerId: String,
    @field:NotNull val items: List<OrderItemRequest>
) {
    init {
        require(items.isNotEmpty()) { "Order must contain at least one item" }
    }

    fun toInput() = PlaceOrderInput(customerId = customerId, items = items.map { it.toItem() })
}

// Response: factory method from() for conversion
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
        )
    }
}
Criteria Judgment
Returning domain model directly as response REJECT
Business logic in Request DTO REJECT. Only validation is allowed
Domain logic (calculations, etc.) in Response DTO REJECT
Same type for Request and Response REJECT

RESTful Action Design

Express state transitions as verb sub-resources.

POST   /api/orders              → Create order
GET    /api/orders/{id}         → Get order
GET    /api/orders              → List orders
POST   /api/orders/{id}/approve → Approve (state transition)
POST   /api/orders/{id}/cancel  → Cancel (state transition)
Criteria Judgment
PUT/PATCH for domain operations (approve, cancel, etc.) REJECT. Use POST + verb sub-resource
Single endpoint branching into multiple operations REJECT. Separate endpoints per operation
DELETE for soft deletion REJECT. Use POST + explicit operation like cancel

Validation Strategy

Validation has different roles at each layer. Do not centralize everything in one place.

Layer Responsibility Mechanism Example
API layer Structural validation @NotBlank, init block Required fields, types, format
UseCase layer Business rule verification Read Model queries Duplicate checks, precondition existence
Domain layer State transition invariants require "Cannot approve unless PENDING"
// API layer: "Is the input structurally correct?"
data class OrderPostRequest(
    @field:NotBlank val customerId: String,
    val from: LocalDateTime,
    val to: LocalDateTime
) {
    init {
        require(!to.isBefore(from)) { "End date must be on or after start date" }
    }
}

// UseCase layer: "Is this business-wise allowed?" (Read Model reference)
fun execute(input: PlaceOrderInput) {
    customerRepository.findById(input.customerId)
        ?: throw CustomerNotFoundException("Customer does not exist")
    validateNoOverlapping(input)  // Duplicate check
    commandGateway.send(buildCommand(input))
}

// Domain layer: "Is this operation allowed in current state?"
fun confirm(confirmedBy: String): OrderConfirmedEvent {
    require(status == OrderStatus.PENDING) { "Cannot confirm in current state" }
    return OrderConfirmedEvent(orderId, confirmedBy)
}
Criteria Judgment
Domain state transition rules in API layer REJECT
Business rule verification in Controller REJECT. Belongs in UseCase layer
Structural validation (@NotBlank, etc.) in domain REJECT. Belongs in API layer
UseCase-level validation inside Aggregate REJECT. Read Model queries belong in UseCase layer

Error Handling

Exception Hierarchy Design

Domain exceptions are hierarchized using sealed classes. HTTP status code mapping is done at the Controller layer.

// Domain exceptions: sealed class ensures exhaustiveness
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 layer maps to HTTP status codes
@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))
}
Criteria Judgment
HTTP status codes in domain exceptions REJECT. Domain must not know about HTTP
Throwing generic Exception or RuntimeException REJECT. Use specific exception types
Empty try-catch blocks REJECT
Controller swallowing exceptions and returning 200 REJECT

Domain Model Design

Immutable + require

Domain models are designed as data class (immutable), with invariants enforced via init blocks and require.

data class Order(
    val orderId: String,
    val status: OrderStatus = OrderStatus.PENDING
) {
    // Static factory method via companion object
    companion object {
        fun place(orderId: String, customerId: String): OrderPlacedEvent {
            require(customerId.isNotBlank()) { "Customer ID cannot be blank" }
            return OrderPlacedEvent(orderId, customerId)
        }
    }

    // Instance method for state transition → returns event
    fun confirm(confirmedBy: String): OrderConfirmedEvent {
        require(status == OrderStatus.PENDING) { "Cannot confirm in current state" }
        return OrderConfirmedEvent(orderId, confirmedBy, LocalDateTime.now())
    }

    // Immutable state update
    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)
    }
}
Criteria Judgment
var fields in domain model REJECT. Use copy() for immutable updates
Factory without validation REJECT. Enforce invariants with require
Domain model calling external services REJECT. Pure functions only
Direct field mutation via setters REJECT

Value Objects

Wrap primitive types (String, Int) with domain meaning.

// ID types: prevent mix-ups via type safety
data class OrderId(@get:JsonValue val value: String) {
    init { require(value.isNotBlank()) { "Order ID cannot be blank" } }
    override fun toString(): String = value
}

// Range types: enforce compound invariants
data class DateRange(val from: LocalDateTime, val to: LocalDateTime) {
    init { require(!to.isBefore(from)) { "End date must be on or after start date" } }
}

// Metadata types: ancillary information in event payloads
data class ApprovalInfo(val approvedBy: String, val approvalTime: LocalDateTime)
Criteria Judgment
Same-typed IDs that can be mixed up (orderId and customerId both String) Consider wrapping in value objects
Same field combinations (from/to, etc.) appearing in multiple places Extract to value object
Value object without init block REJECT. Enforce invariants

Repository Pattern

Define interface in domain layer, implement in adapter/outbound.

// domain/: Interface (port)
interface OrderRepository {
    fun findById(orderId: String): Order?
    fun save(order: Order)
}

// adapter/outbound/persistence/: Implementation (adapter)
@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 Entity (JPA Entity)

Read Model JPA Entities are defined separately from domain models. var (mutable) fields are acceptable here.

@Entity
@Table(name = "orders")
data class OrderEntity(
    @Id val orderId: String,
    var customerId: String,
    @Enumerated(EnumType.STRING) var status: OrderStatus,
    var metadata: String? = null
)
Criteria Judgment
Domain model doubling as JPA Entity REJECT. Separate them
Business logic in Entity REJECT. Entity is data structure only
Repository implementation in domain layer REJECT. Belongs in adapter/outbound

Authentication & Authorization Placement

Authentication and authorization are cross-cutting concerns handled at the appropriate layer.

Concern Placement Mechanism
Authentication (who) Filter / Interceptor layer JWT verification, session validation
Authorization (permissions) Controller layer @PreAuthorize("hasRole('ADMIN')")
Data access control (own data only) UseCase layer Verified as business rule
// Controller layer: role-based authorization
@PostMapping("/{id}/approve")
@PreAuthorize("hasRole('FACILITY_ADMIN')")
fun approve(@PathVariable id: String, @RequestBody request: ApproveRequest) { ... }

// UseCase layer: data access control
fun execute(input: DeleteInput, currentUserId: String) {
    val entity = repository.findById(input.id)
        ?: throw NotFoundException("Not found")
    require(entity.ownerId == currentUserId) { "Cannot operate on another user's data" }
    // ...
}
Criteria Judgment
Authorization logic in UseCase or domain layer REJECT. Belongs in Controller layer
Data access control in Controller REJECT. Belongs in UseCase layer
Authentication processing inside Controller REJECT. Belongs in Filter/Interceptor

Test Strategy

Test Pyramid

        ┌─────────────┐
        │   E2E Test  │  ← Few: verify full API flow
        ├─────────────┤
        │ Integration │  ← Repository, Controller integration verification
        ├─────────────┤
        │  Unit Test  │  ← Many: independent tests for domain models, UseCases
        └─────────────┘

Domain Model Testing

Domain models are framework-independent, enabling pure unit tests.

class OrderTest {
    // Helper: build aggregate in specific state
    private fun pendingOrder(): Order {
        val event = Order.place("order-1", "customer-1")
        return Order.from(event)
    }

    @Nested
    inner class Confirm {
        @Test
        fun `can confirm from PENDING state`() {
            val order = pendingOrder()
            val event = order.confirm("admin-1")
            assertEquals("order-1", event.orderId)
        }

        @Test
        fun `cannot confirm from CONFIRMED state`() {
            val order = pendingOrder().let { it.apply(it.confirm("admin-1")) }
            assertThrows<IllegalArgumentException> {
                order.confirm("admin-2")
            }
        }
    }
}

Testing rules:

  • Build state transitions via helper methods (each test is independent)
  • Group by operation using @Nested
  • Test both happy path and error cases (invalid state transitions)
  • Verify exception types with assertThrows

UseCase Testing

Test UseCases with mocks. Inject external dependencies.

class PlaceOrderUseCaseTest {
    private val commandGateway = mockk<CommandGateway>()
    private val customerRepository = mockk<CustomerRepository>()
    private val useCase = PlaceOrderUseCase(commandGateway, customerRepository)

    @Test
    fun `throws error when customer does not exist`() {
        every { customerRepository.findById("unknown") } returns null

        assertThrows<CustomerNotFoundException> {
            useCase.execute(PlaceOrderInput(customerId = "unknown", items = listOf(...)))
        }
    }
}
Criteria Judgment
Using mocks for domain model tests REJECT. Test domain purely
UseCase tests connecting to real DB REJECT. Use mocks
Tests requiring framework startup REJECT for unit tests
Missing error case tests for state transitions REJECT

Anti-Pattern Detection

REJECT when these patterns are found:

Anti-Pattern Problem
Smart Controller Business logic concentrated in Controller
Anemic Domain Model Domain model is just a data structure with setters/getters
God Service All operations concentrated in a single Service class
Direct Repository Access Controller directly referencing Repository
Domain Leakage Domain logic leaking into adapter layer
Entity Reuse JPA Entity reused as domain model
Swallowed Exceptions Empty catch blocks
Magic Strings Hardcoded status strings, etc.