486 lines
17 KiB
Markdown
486 lines
17 KiB
Markdown
# 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 |
|
|
|
|
```kotlin
|
|
// 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.
|
|
|
|
```kotlin
|
|
// 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.
|
|
|
|
```kotlin
|
|
// 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" |
|
|
|
|
```kotlin
|
|
// 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.
|
|
|
|
```kotlin
|
|
// 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`.
|
|
|
|
```kotlin
|
|
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.
|
|
|
|
```kotlin
|
|
// 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.
|
|
|
|
```kotlin
|
|
// 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.
|
|
|
|
```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
|
|
)
|
|
```
|
|
|
|
| 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 |
|
|
|
|
```kotlin
|
|
// 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.
|
|
|
|
```kotlin
|
|
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.
|
|
|
|
```kotlin
|
|
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. |
|