Spring Boot Pre-Interview Guide Part 1: Core Application Layer — Spring Boot 4 · Kotlin Four-Layer Design

Spring Boot Pre-Interview Guide Part 1: Core Application Layer — Spring Boot 4 · Kotlin Four-Layer Design


Introduction

“How well does my Spring Boot pre-interview assignment have to be done to actually impress?”

After submitting and reviewing pre-interview assignments many times, the same feedback points keep coming up. Most of them don’t start with “the feature works” — they start with “the layer responsibilities are mixed up.” This series collects those points from an evaluator’s perspective.

Part 1 starts where it should: the Controller · Service · Repository · Domain four-layer architecture. We cover how to divide responsibilities, why Request DTOs should not be passed directly to the Service but converted into Commands, what @Transactional(readOnly = true) actually does, and why GlobalExceptionHandler is more than a simple catch-all.

The target reader is a junior backend engineer who knows Spring but isn’t sure where evaluators are looking. After reading, you should no longer hesitate about what belongs in which layer.


TL;DR

  • Half of the evaluation is layer-responsibility separation — Controller does HTTP only, Service owns transactions and business logic, Repository owns queries, Domain owns state and invariants. Mixed responsibilities cost points.
  • Request DTO ≠ Service input — Request DTOs live only in the Controller; convert them to Commands before passing to the Service. The point is to keep web dependencies out of Service tests.
  • @Transactional(readOnly = true) is not “no transaction” — A transaction does start, but Dirty Checking and auto-flush are off, and it can serve as a Read Replica routing hint. The standard pattern: declare it at the class level and override only on write methods.
  • Entities expose business methods, not settersupdate(name, category) instead of setName(). This blocks indiscriminate state changes and makes invariants visible in code. The kotlin-jpa plugin synthesizes the no-arg constructor automatically.
  • GlobalExceptionHandler is three-tieredCommonException (intentional business errors) → MethodArgumentNotValidException (validation failures) → Exception (fallback). Without the fallback, the Whitelabel page leaks — which is itself a deduction.

1. Four Layers at a Glance

1.1 Request Flow

The path a single request takes from arrival to response, and where each layer sits, looks like this.

flowchart TB
    Client([Client])

    subgraph App["Spring Boot Application"]
        Controller["Controller<br/>@RestController"]
        Service["Service<br/>@Service · @Transactional"]
        Repository["Repository<br/>JpaRepository · Querydsl"]
        Entity["Entity<br/>Domain Model"]
        Handler["GlobalExceptionHandler<br/>@RestControllerAdvice"]
    end

    DB[("Database")]

    Client -->|HTTP Request| Controller
    Controller -->|Command| Service
    Service -->|use| Repository
    Service -->|operate on| Entity
    Repository --> DB
    Service -.throws.-> Handler
    Handler -.JSON.-> Client
    Controller -.JSON.-> Client

The arrows point in one direction for a reason. The Controller knows only the Service, the Service knows the Repository and Entity, and the Repository knows only the DB. Reverse dependencies — a Service that knows HTTP, an Entity that knows DTOs — are anti-patterns.

Note — Spring Boot 4 + Kotlin 2.3 project setup: This post is written against Spring Boot 4 + Kotlin 2.3. Spring Boot 4 recommends Java 21, but that matters less on a Kotlin project. The two Kotlin plugins are the key: kotlin-spring automatically marks all Spring-annotated classes as open (required when JPA and Spring Security generate proxies), and kotlin-jpa synthesizes a no-arg constructor on JPA entities. Add kotlin("plugin.spring") version "2.3" and kotlin("plugin.jpa") version "2.3" to build.gradle.kts and you’re done — the Kotlin 2.x line is binary-compatible across 2.0–2.3, so the code below also runs on earlier point releases. Lombok has no place here — data classes, val, and var handle everything Lombok used to.

1.2 Responsibility per Layer

LayerResponsibilityWhat it must never do
ControllerHTTP mapping, validation, DTO ↔ Command conversionBusiness logic, transactions
ServiceTransactions, business rules, DTO conversionDepend on HTTP annotations
RepositoryQueries, paginationBusiness branching
DomainState and invariants, business methodsExpose setters

Note: Don’t collapse layers just because the assignment is small. “The Service has only one line, why not move it to the Controller?” is a trap — evaluators are looking at the separation itself.


2. Presentation Layer (Controller)

2.1 CRUD and HTTP Method Mapping

You can distinguish PUT for full updates and PATCH for partial updates, but the better move is to commit to one approach instead of mixing.

OperationHTTP Method
CreatePOST
ReadGET
UpdatePUT / PATCH
DeleteDELETE
PUT vs PATCH Debate

REST principle distinction

  • PUT: Replaces the entire resource (guarantees idempotency)
  • PATCH: Modifies only part of the resource

Reality in practice

In most real-world projects, teams either use only PATCH or only PUT.

  • PATCH only: Most modifications are partial updates, and full replacements are rarely needed.
  • PUT only: The team convention standardizes on PUT, or the frontend always sends the complete payload.

Recommendation for assignments

Stick with one approach and explain your reasoning in the README. Mixing both without a clear rule actually hurts the evaluation.

2.2 URI Design Principles

  • Plural nouns: /orders, /users, /products
  • Ownership: /users/{userId}/orders
  • Actions: /orders/{orderId}/cancel

Tip: Action URIs like cancel may or may not be acceptable depending on the domain. For simple CRUD assignments, consider expressing state changes via PATCH instead.

How to fill the URL when expressing an action as PATCH

The rule: don’t put verbs in the URL — keep the resource path as-is and let the body carry the intent.

# Action URI style (RPC-ish)
POST /api/v1/orders/{orderId}/cancel

# PATCH style — verb disappears from the URL, intent moves to body
PATCH /api/v1/orders/{orderId}
Content-Type: application/json

{ "status": "CANCELLED" }

Three variants seen in the field

PatternURLBodyWhen
Action URIPOST /orders/{id}/cancelempty / smallWhen the action itself is the core operation (payment, refund, approval)
Resource PATCHPATCH /orders/{id}{"status":"CANCELLED"}Simple state machine — recommended for assignments
Sub-resourcePUT /orders/{id}/status{"value":"CANCELLED"}Modeling status itself as a resource

Kotlin + Spring Boot shape

// Controller — no "cancel" in the URL
@PatchMapping("/{orderId}")
fun modifyOrder(
    @PathVariable orderId: Long,
    @Valid @RequestBody request: ModifyOrderRequest,
): CommonResponse<Long> {
    return CommonResponse.success(orderService.modifyOrder(orderId, request.toCommand()))
}

// Service — delegate state transition to the domain method
@Transactional
fun modifyOrder(orderId: Long, command: ModifyOrderCommand): Long {
    val order = orderRepository.findById(orderId)
        ?: throw NotFoundException()

    if (command.status == OrderStatus.CANCELLED) {
        order.cancel()   // transition validity is the Entity's job
    }
    return order.id!!
}

// Entity — same business-method + invariant pattern as §5
fun cancel() {
    if (this.status == OrderStatus.COMPLETED) {
        throw BadRequestException(ErrorCode.ORDER_ALREADY_COMPLETED)
    }
    this.status = OrderStatus.CANCELLED
    this.cancelledAt = LocalDateTime.now()
}

Pitfalls when standardizing on PATCH

  • Risk of accepting fields beyond the transition — if ModifyOrderRequest accepts status, name, and address all together, a cancel call can accidentally rewrite other fields. Either split DTOs per action or expose a dedicated status endpoint.
  • Intent ambiguity{"status":"CANCELLED"} doesn’t reveal whether it’s “user cancellation” or “admin force-change” in audit logs.
  • Actions with bundled side effects — when cancellation triggers “refund + stock restoration + notification,” PATCH feels too lightweight. POST /orders/{id}/cancel is more honest here.

In short: simple state changes go through resource PATCH; workflows where the domain verb is the main event use the action URI. Don’t mix the two — just document the choice in the README.

2.3 Avoiding Hardcoded URIs

Manage frequently used URIs as constants.

object ApiPaths {
    const val API = "/api"
    const val V1 = "/v1"
    const val PRODUCTS = "/products"
}

2.4 Common Response Class

Typically composed of a response code, response message, and data section.

  • HTTP Status: Protocol semantics (200, 400, 500, etc.)
  • code: Business error classification (ERR001, ERR002, etc.)

Exceptions: File downloads, streaming APIs, and HealthCheck endpoints should not be wrapped in the common response.

Is a common response class really necessary?

For

  • Clients can predict the response format, making parsing easier
  • Business errors can be subdivided through error codes
  • Provides a consistent interface for frontend collaboration

Against

  • HTTP status codes alone are usually enough to distinguish errors
  • Unnecessary wrapping increases response size
  • Per REST principles, HTTP status should already indicate success/failure

Practical tip

Most teams in the field do use a common response class. It’s especially useful for legacy systems or when supporting multiple clients (web, mobile, external integrations).

For assignments, if not specified in the requirements, using a common response class is the safer choice. Just make sure to set appropriate HTTP status codes too (e.g., 201 Created, 404 Not Found).

data class CommonResponse<T>(
    val code: String = CODE_SUCCESS,
    val message: String = MSG_SUCCESS,
    val data: T? = null,
) {
    companion object {
        const val CODE_SUCCESS = "SUC200"
        const val MSG_SUCCESS = "success"

        fun <T> success(data: T? = null): CommonResponse<T> =
            CommonResponse(CODE_SUCCESS, MSG_SUCCESS, data)

        fun <T> error(code: String, message: String, data: T? = null): CommonResponse<T> =
            CommonResponse(code, message, data)
    }
}

2.5 DTO Validation and Command Conversion

  • Use @Valid, @NotBlank, @Size, @NotNull, etc.
  • Apply @Valid to nested DTOs as well
  • Handle validation exceptions in ExceptionHandler
  • Request DTOs only live in the Controller; convert them to Command objects before passing to the Service

Tip: Passing Request DTOs directly to the Service tightly couples the Presentation Layer to the Business Layer. Using Command objects clearly separates layer responsibilities and lets Service tests run without web-related dependencies.

Note — Kotlin Bean Validation requires @field: prefix: A val in a primary constructor is treated as a property by default. To apply a Bean Validation annotation at the field level, write @field:NotBlank explicitly. Writing @NotBlank val name without the prefix attaches the annotation to the property getter, so validation silently does nothing.

Is the Command pattern always necessary?

For

  • Clear separation of dependencies between layers
  • No web annotation dependencies in Service tests
  • Changes to Request DTOs don’t ripple into the Service
  • Multiple Controllers can call the same Service method differently

Against

  • Over-engineering for simple CRUD
  • Conversion code adds boilerplate
  • Request and Command often look almost identical
  • Unnecessary complexity for small projects like assignments

Practical tip

  • Large-scale projects: Use the Command pattern. Especially when domain logic is complex or the same logic is invoked from multiple channels (API, batch, message queue).
  • Small projects / assignments: Passing Request DTOs directly is fine — just be consistent with one approach.

Recommendation for assignments

If you have time, the Command pattern signals that you understand layer separation. If not, using Request DTOs directly isn’t a deduction.

// Request DTO — used for validation in the Controller
data class RegisterProductRequest(
    @field:NotBlank
    @field:Size(max = 100)
    val name: String?,

    @field:Size(min = 1)
    @field:Valid
    val details: List<ProductDetailDto>?,
) {
    fun toCommand() = RegisterProductCommand(
        name = name!!,
        details = details!!.map { it.toCommand() },
    )
}

data class ProductDetailDto(
    @field:NotNull
    val type: ProductCategoryType?,

    @field:NotBlank
    val name: String?,
) {
    fun toCommand() = ProductDetailCommand(
        type = type!!,
        name = name!!,
    )
}

data class ModifyProductRequest(
    @field:NotBlank
    @field:Size(max = 100)
    val name: String?,

    @field:NotNull
    val category: ProductCategoryType?,
) {
    fun toCommand() = ModifyProductCommand(
        name = name!!,
        category = category!!,
    )
}

// Command — pure data object used in the Service Layer
data class RegisterProductCommand(
    val name: String,
    val details: List<ProductDetailCommand>,
)

data class ProductDetailCommand(
    val type: ProductCategoryType,
    val name: String,
)

data class ModifyProductCommand(
    val name: String,
    val category: ProductCategoryType,
)

enum class ProductCategoryType {
    FOOD, HOTEL
}

2.6 Controller Implementation

The Controller contains no business logic. Convert the Request DTO to a Command and delegate to the Service — that’s the whole job.

@RestController
@RequestMapping(API + V1 + PRODUCTS)
class ProductController(
    private val productService: ProductService,
) {
    @GetMapping("/{productId}")
    fun findProductDetail(
        @PathVariable productId: Long,
    ): CommonResponse<FindProductDetailResponse> {
        return CommonResponse.success(productService.findProductDetail(productId))
    }

    @GetMapping
    fun findProducts(
        @Valid @ModelAttribute request: FindProductRequest,
        @PageableDefault(page = 0, size = 20) pageable: Pageable,
    ): CommonResponse<Page<FindProductResponse>> {
        return CommonResponse.success(productService.findProducts(request.toCommand(), pageable))
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    fun registerProduct(
        @Valid @RequestBody request: RegisterProductRequest,
    ): CommonResponse<Long> {
        return CommonResponse.success(productService.registerProduct(request.toCommand()))
    }

    @PutMapping("/{productId}")
    fun modifyProduct(
        @PathVariable productId: Long,
        @Valid @RequestBody request: ModifyProductRequest,
    ): CommonResponse<Long> {
        return CommonResponse.success(productService.modifyProduct(productId, request.toCommand()))
    }

    @DeleteMapping
    fun deleteProducts(
        @Valid @Size(min = 1) @RequestParam productIds: Set<Long>,
    ): CommonResponse<Unit> {
        productService.deleteProducts(productIds)
        return CommonResponse.success()
    }
}

3. Business Layer (Service)

3.1 Transaction Management

  • Separate read transactions with readOnly = true to prevent unnecessary Dirty Checking
  • Declare @Transactional(readOnly = true) at the class level and override write methods with @Transactional
  • Verify transaction behavior through logging configuration
Actual effects of readOnly = true

How it works

  1. Dirty Checking disabled: No entity change detection, saving snapshot storage cost
  2. Flush mode changed: Set to FlushMode.MANUAL, preventing automatic flushes
  3. DB hint propagation: Some databases (e.g., MySQL Read Replica routing) use the read-only hint

Caveats

  • Even with readOnly = true, a transaction is still started — it’s not “no transaction”
  • Modifying an entity is silently ignored without throwing an exception (be careful)
  • If OSIV is enabled, lazy loading still works

FlushMode types

ModeDescriptionUse case
AUTOAutomatic flush before query execution and before commit (default)Normal transactions
COMMITFlush only on commitBulk read operations
MANUALOnly on explicit flush() callSet automatically when readOnly = true
ALWAYSFlush before every queryRarely used

OSIV (Open Session In View)

OSIV extends the lifecycle of the persistence context to cover the entire HTTP request.

# Spring Boot default: true
spring:
  jpa:
    open-in-view: true  # OSIV enabled (default)
OSIV statePersistence context scopeProsCons
true (default)Request start ~ response completeLazy loading available in ControllerDB connection held for a long time
falseWithin transaction scopeFaster connection releaseLazyInitializationException possible in Controller

Recommendation: In production, set open-in-view: false and pre-load required data in the Service layer.

Standard pattern

@Service
@Transactional(readOnly = true)  // default: read-only
class ProductService(
    private val productRepository: ProductRepository,
) {
    fun findById(id: Long): Product { ... }  // readOnly = true applied

    @Transactional  // write operation: overrides to readOnly = false
    fun save(product: Product): Long { ... }
}
Transaction Logging Level (application.yml)
logging:
  level:
    org.springframework.orm.jpa: DEBUG
    org.springframework.transaction: DEBUG
    org.hibernate.SQL: DEBUG
    org.hibernate.orm.jdbc.bind: DEBUG

3.2 Custom Exception Definition

Throw business-rule violations as Custom Exceptions and pin the HTTP status code and error code into each exception. This lets the handler map them straight into a response without branching.

enum class ErrorCode(
    val code: String,
    val message: String,
) {
    ERR000("ERR000", "A temporary error occurred. Please try again later."),
    ERR001("ERR001", "Invalid request."),
    ERR002("ERR002", "Product not found."),
}

open class CommonException(
    val statusCode: HttpStatus,
    val errorCode: ErrorCode,
) : RuntimeException(errorCode.message)

class BadRequestException(errorCode: ErrorCode = ErrorCode.ERR001)
    : CommonException(HttpStatus.BAD_REQUEST, errorCode)

class NotFoundException(errorCode: ErrorCode = ErrorCode.ERR002)
    : CommonException(HttpStatus.NOT_FOUND, errorCode)

3.3 Nullable Handling

Kotlin handles nullability with ?: (Elvis operator) and nullable types — no Optional needed.

@Service
@Transactional(readOnly = true)
class ProductService(
    private val productRepository: ProductRepository,
) {
    fun findProductDetail(productId: Long): FindProductDetailResponse {
        val product = productRepository.findById(productId)
            ?: throw NotFoundException()

        return FindProductDetailResponse.from(product)
    }
}

3.4 Service Implementation Principles

  • Don’t return Domain Models directly; convert them to response-specific DTOs
  • Use scope functions (map, let, run) for concise repetitive logic
  • Accept Command objects as parameters, not Request DTOs
Single-entity deletion — 4 approaches and the deleteById myth

Two approaches usually come to mind for deleting a single entity — deleteById(id) and findById + delete(entity). Between them sits a common myth, plus two lesser-known variants worth knowing.

Myth — “deleteById skips the SELECT and is faster”

Almost untrue. Spring Data’s SimpleJpaRepository.deleteById() actually does this:

// SimpleJpaRepository internals (JVM bytecode level)
override fun deleteById(id: ID) {
    findById(id).ifPresent { delete(it) }   // ← it calls findById internally
}

JPA needs the entity in the persistence context to honor cascade and @PreRemove/@PostRemove, so it can’t just fire a single DELETE. SELECT + DELETE — two queries — happens regardless.

Comparison of the four approaches

ApproachQueriesMissing-entity behaviorBusiness validationcascade · @PreRemoveWhen
1. deleteById(id)2 (SELECT + DELETE)silently ignoredalmost never
2. findById + delete2throws an exceptionindustry standard
3. @Modifying @Query DELETE1returns affected rowsbulk only — wrong for single
4. Domain method2 (SELECT + UPDATE)throws an exceptionsoft delete

Approach 2 — the standard (findById + delete)

@Transactional
fun deleteProduct(productId: Long) {
    val product = productRepository.findById(productId)
        ?: throw NotFoundException()

    // domain validation or event publishing fits here
    productRepository.delete(product)
}

Approach 3 — a shortcut with serious traps

@Modifying(clearAutomatically = true)   // ← omit this and the persistence context goes stale
@Query("DELETE FROM Product p WHERE p.id = :id")
fun deleteByIdInBulk(@Param("id") id: Long): Int

A single DELETE fires, but @PreRemove/@PostRemove are skipped, cascade is bypassed, and the persistence context can go stale. Not worth using for a single entity.

Approach 4 — the canonical soft delete

// Service
@Transactional
fun deleteProduct(productId: Long) {
    val product = productRepository.findById(productId)
        ?: throw NotFoundException()
    product.softDelete()   // Dirty Checking emits the UPDATE
}

// Entity
fun softDelete() {
    check(this.deletedAt == null) { "Already deleted" }
    this.deletedAt = LocalDateTime.now()
}

Choosing by requirement

RequirementRecommended approach
Plain hard deleteApproach 2 (findById + delete)
Hard delete with validation/auditApproach 2 + a domain method
Soft deleteApproach 4 (domain method)
Tens of thousands at once (batch)Approach 3 (@Modifying)

“deleteById is almost never used” is the takeaway. It’s short, but its silent success is consistently the door to silent bugs.

deleteAll() vs deleteAllInBatch()

deleteAll()

  • Queries and deletes entities one by one (N+1 query issue)
  • JPA callbacks like @PreRemove, @PostRemove are executed
  • Cascade deletion works

deleteAllInBatch()

  • Bulk deletion with a single DELETE query
  • JPA callbacks are not executed
  • Cascade deletion does not work (potential FK constraint violations)

Practical tip

  • Use deleteAll() when there are related entities or deletion callbacks are needed
  • Use deleteAllInBatch() for bulk deletion without relationships
  • For assignments, deleteAll() is the safe choice
Soft Delete vs Hard Delete

Hard Delete

  • Actually removes the data
  • Simple and straightforward implementation
  • Saves storage space

Soft Delete

  • Logical deletion using a deletedAt (timestamp, nullable) column — null means alive, a value means deleted at that moment
  • Data recovery possible, easier auditing
  • Always requires a deletion-status condition in queries (@Where, @SQLRestriction)

Column choice: deletedAt (timestamp) is the standard, not deleted (boolean). It packs “when was it deleted” into the same column, and the method name reads naturally — findByIdAndDeletedAtIsNull (“the one whose deletedAt is null” = “the live one”). A boolean deleted produces awkward names like findByIdAndDeletedFalse and forces a separate column for the deletion timestamp.

In practice

Most production projects use Soft Delete. Especially when:

  • Legal data retention is required (finance, healthcare, etc.)
  • Undo-deletion functionality is needed
  • Deleted data is used for statistics/analytics

Recommendation for assignments

If not specified in the requirements, Hard Delete is fine. If you implement Soft Delete, don’t forget to filter out deleted data in the query logic.

// Example query method when implementing Soft Delete (deletedAt column)
fun findByIdAndDeletedAtIsNull(id: Long): Product?
Soft Delete performance myths and 5 alternative patterns

At scale, a simple deletedAt flag isn’t always enough. Let’s bust a common myth first, then walk through real-world patterns.

Myth: “deletedAt is faster than boolean”

Almost untrue. Both columns have cardinality of effectively 2 (alive vs deleted), and in production alive rows make up ~99% of the table. Indexing this column alone amounts to “scan most of the table” from the optimizer’s view — so by itself it doesn’t help much.

The real performance differentiator is partial / filtered indexes:

-- PostgreSQL — index only the alive rows
CREATE INDEX idx_products_alive ON products (category)
WHERE deleted_at IS NULL;

Partial indexes work with both boolean and timestamp, but IS NULL reads more naturally in the index definition. Even so, the gap is small — when real performance pressure arrives, you move on to archive tables.

Five-pattern comparison

PatternCore structureWhen
A. deletedAt flagNullable timestamp on the live tableAssignments, small-to-mid scale (default)
B. status enumMultiple lifecycle statesOrders, content with several states
C. Archive tableMove rows to a separate table on deleteLive table at tens of millions to hundreds of millions of rows
D. Hard delete + audit logActually delete + record in a separate audit tableUser PII, GDPR/CCPA
E. Event sourcingEvery change is an append-only eventFinance, healthcare, regulated audit trail

Pattern B — status enum

When deletion isn’t a single boolean but part of a lifecycle:

enum class OrderStatus { PENDING, PAID, CANCELLED, REFUNDED, DELETED }

@Entity
class Order(
    @Enumerated(EnumType.STRING)   // ORDINAL breaks when the enum is reordered or extended
    @Column(nullable = false)
    var status: OrderStatus = OrderStatus.PENDING,
) : BaseEntity()

Query as status != 'DELETED' or IN (...). Always use EnumType.STRING — with ORDINAL, adding or reordering enum values silently invalidates existing rows.

Pattern C — archive table

-- Inside the delete transaction
INSERT INTO products_archive
SELECT *, NOW() AS archived_at FROM products WHERE id = ?;

DELETE FROM products WHERE id = ?;

The live table stays lean and indexes remain meaningful. To restore, copy back from the archive. Standard at large-scale SaaS.

Pattern D — hard delete + audit log

When GDPR “right to be forgotten” applies:

@Transactional
fun deleteUser(userId: Long) {
    val user = userRepository.findById(userId) ?: throw NotFoundException()

    auditLogRepository.save(AuditLog.of(user, "DELETE"))  // trace stays in audit
    userRepository.delete(user)                            // row actually goes
}

Live table stays smallest, the audit trail lives in a dedicated table. Effectively required for any domain holding personal data.

How to phrase it in an interview

“I went with a deletedAt timestamp. It carries more information than a boolean and pairs naturally with partial indexes. At higher scale the standard move is an archive table, and for user PII a hard delete with an audit log is more appropriate due to GDPR.”

That level of nuance signals “this person actually understands soft-delete patterns at scale.”

Here is the full Service example — write methods override with @Transactional, and ?: throw NotFoundException() makes null handling explicit.

@Service
@Transactional(readOnly = true)
class ProductService(
    private val productRepository: ProductRepository,
) {
    @Transactional
    fun modifyProduct(productId: Long, command: ModifyProductCommand): Long {
        val product = productRepository.findById(productId)
            ?: throw NotFoundException()

        product.update(
            name = command.name,
            category = command.category,
        )

        return product.id!!
    }

    @Transactional
    fun deleteProduct(productId: Long) {
        val product = productRepository.findById(productId)
            ?: throw NotFoundException()

        productRepository.delete(product)
    }

    @Transactional
    fun deleteProducts(productIds: Set<Long>) {
        val products = productRepository.findAllById(productIds)

        if (products.size != productIds.size) {
            throw NotFoundException()
        }

        productRepository.deleteAll(products)
    }
}

3.5 Response DTO Conversion Patterns

The Service doesn’t return Entities directly — it converts them into Response DTOs. Where the conversion code lives splits into three patterns.

PatternWhere the conversion livesBest for
Static factoryfrom(entity) method on the DTORecommended for assignments
Constructor conversionDTO constructor takes the EntityWhen mapping is one line
MapStructAuto-generated separate MapperMany DTOs or deep object graphs

The key principle: conversion responsibility belongs to the DTO side. If the Entity knows the DTO shape, the domain gets dragged around by the presentation layer.

data class FindProductDetailResponse(
    val id: Long,
    val name: String,
    val category: ProductCategoryType,
    val enabled: Boolean,
    val createdAt: LocalDateTime,
) {
    companion object {
        fun from(product: Product) = FindProductDetailResponse(
            id = product.id!!,
            name = product.name,
            category = product.category,
            enabled = product.enabled,
            createdAt = product.createdAt,
        )
    }
}

// In the Service
fun findProductDetail(productId: Long): FindProductDetailResponse {
    val product = productRepository.findById(productId)
        ?: throw NotFoundException()
    return FindProductDetailResponse.from(product)
}

For collection responses, use entities.map { FindProductDetailResponse.from(it) }. For paged responses, use page.map { FindProductDetailResponse.from(it) } — Spring Data’s Page supports map directly.

MapStruct alternative

When you have many DTOs or deep graphs (Order → OrderItems → Product), MapStruct cuts the boilerplate substantially.

@Mapper(componentModel = "spring")
interface ProductMapper {
    fun toDetailResponse(product: Product): FindProductDetailResponse
    fun toListResponse(products: List<Product>): List<FindProductResponse>
}

Pros: compile-time mapping verification and runtime performance. Cons: extra dependency and learning curve. For a small assignment domain, the static factory is faster to write and easier for the evaluator to read.


4. Data Access Layer (Repository)

4.1 Basic Principles

  • Nullable handling: Kotlin nullable types (Product?)
  • Simple queries: use JPA Query Methods
  • Complex queries: use Querydsl
  • When using Querydsl: explicitly declare @Transactional

4.2 Pagination

PageableExecutionUtils.getPage() provides a performance benefit by skipping the count query on the last page. And one easy-to-forget detail — Spring Data accepts ?size=10000 as-is unless you cap it. Without a global ceiling in application.yml, that becomes a backdoor for memory and DB pressure.

Pagination global config (application.yml)
spring:
  data:
    web:
      pageable:
        default-page-size: 20    # used when @PageableDefault is missing
        max-page-size: 100       # ceiling — bigger size requests are clamped

max-page-size applies automatically to every endpoint that receives a Pageable. It’s a safety net that’s easy to forget because it doesn’t need per-Controller code.

interface ProductRepository : JpaRepository<Product, Long>, ProductRepositoryCustom {
    fun findByIdAndDeletedAtIsNull(id: Long): Product?
    fun findAllByIdIn(ids: Collection<Long>): List<Product>
}

interface ProductRepositoryCustom {
    fun findProducts(
        name: String?,
        enabled: Boolean?,
        pageable: Pageable,
    ): Page<Product>
}

class ProductRepositoryImpl(
    private val queryFactory: JPAQueryFactory,
) : ProductRepositoryCustom {

    override fun findProducts(
        name: String?,
        enabled: Boolean?,
        pageable: Pageable,
    ): Page<Product> {
        val product = QProduct.product

        val results = queryFactory
            .selectFrom(product)
            .where(
                nameContains(name),
                enabledEq(enabled),
            )
            .offset(pageable.offset)
            .limit(pageable.pageSize.toLong())
            .orderBy(product.id.desc())
            .fetch()

        val countQuery = queryFactory
            .select(product.count())
            .from(product)
            .where(
                nameContains(name),
                enabledEq(enabled),
            )

        return PageableExecutionUtils.getPage(results, pageable) {
            countQuery.fetchOne() ?: 0L
        }
    }

    private fun nameContains(name: String?): BooleanExpression? =
        name?.let { QProduct.product.name.containsIgnoreCase(it) }

    private fun enabledEq(enabled: Boolean?): BooleanExpression? =
        enabled?.let { QProduct.product.enabled.eq(it) }
}

Note: Pagination depth (Page vs Slice, cursor-based pagination) is covered in Part 4 — Performance; Querydsl dependencies and setup live in Part 2 — Database & Testing.


5. Domain Layer (Entity)

5.1 Design Principles

  • Business methods instead of setters: updateName(), activate(), etc.
  • The kotlin-jpa plugin synthesizes the no-arg constructor automatically — no need to write a protected constructor manually
  • Separate related entities: split child entities when needed
  • Fixed values: use Enums
Why protected — JPA spec, proxies, and encapsulation

Strictly speaking, protected isn’t mandatory. The JPA 2.1 spec (§2.1) states that the no-arg constructor must be “public or protected”, so public also works. Yet protected became the standard because three pressures all converge on it.

1. Why not public — preventing incomplete objects

Entities are designed to remove setters and have state changed only via constructors and business methods. A public no-arg constructor allows creating a zombie object with name = null, which won’t be caught until the DB rejects the NULL.

2. Why not private — Hibernate proxies need to call the parent constructor

For lazy loading, Hibernate generates subclass proxies of your Entity at runtime. The proxy must call the parent’s (your Entity’s) no-arg constructor, which means the child class must be able to see it.

Access modifierProxy can call?
public
protected✓ (proxy is a subclass)
package-private△ (only within the same package)
private

3. Kotlin handles this through the kotlin-jpa plugin

// build.gradle.kts
plugins {
    kotlin("plugin.jpa") version "2.3"     // synthesizes no-arg ctor on @Entity
    kotlin("plugin.spring") version "2.3"  // opens @Entity classes for proxying
}

The kotlin-jpa plugin synthesizes a no-arg constructor on @Entity classes at compile time, so you rarely write protected explicitly in Kotlin. That’s why the Kotlin examples in Part 1 work without an explicit protected constructor.

Kotlin Entity without Lombok — what replaces what

Kotlin needs no Lombok at all. What Lombok annotations did in Java, Kotlin handles at the language level.

Java LombokKotlin equivalent
@Getter·@Setterval/var auto-accessors
@RequiredArgsConstructorprimary constructor
@AllArgsConstructorprimary constructor (all fields)
@NoArgsConstructorkotlin-jpa plugin synthesizes it
@Buildernamed arguments + default values
@Datadata class
@Slf4jprivate val log = LoggerFactory.getLogger(this::class.java)

Note: don’t use data class for JPA entities. Hibernate generates subclasses (proxies) for lazy loading, and data class’s copy(), equals(), and hashCode() can produce unexpected behavior.

5.2 BaseEntity

Separate common fields like creation time and modification time into a BaseEntity.

@MappedSuperclass
@EntityListeners(AuditingEntityListener::class)
abstract class BaseEntity {

    @CreatedDate
    @Column(updatable = false)
    var createdAt: LocalDateTime = LocalDateTime.now()
        protected set

    @LastModifiedDate
    @Column
    var updatedAt: LocalDateTime = LocalDateTime.now()
        protected set
}

@MappedSuperclass
abstract class BaseEntityWithAuditor : BaseEntity() {

    @CreatedBy
    @Column(updatable = false)
    var createdBy: Long? = null
        protected set

    @LastModifiedBy
    @Column
    var updatedBy: Long? = null
        protected set
}

5.3 Entity Implementation

@Entity
@Table(name = "products")
class Product(
    @Column(nullable = false)
    var name: String,

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    var category: ProductCategoryType,

    @Column(nullable = false)
    var enabled: Boolean = true,
) : BaseEntity() {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null
        protected set

    fun update(name: String, category: ProductCategoryType) {
        this.name = name
        this.category = category
    }

    fun enable() {
        this.enabled = true
    }

    fun disable() {
        this.enabled = false
    }
}

5.4 Associations

Assignment domains almost always involve associations (order-product, user-order, etc.). The two things evaluators flag most are missing fetch type and overuse of bidirectional mapping.

Three core principles:

  • Always declare fetch as LAZY — the default for @ManyToOne/@OneToOne is EAGER, and forgetting to override it makes you a regular customer of the N+1 problem.
  • Bidirectional only when truly needed — unless you must traverse from both sides, unidirectional (just @ManyToOne on one side) is safer.
  • Avoid Cascade ALL; opt in to specific ones — only declare PERSIST/REMOVE when the child’s lifecycle truly matches the parent’s.
Unidirectional @ManyToOne (Kotlin) — the safe default
@Entity
@Table(name = "orders")
class Order(
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    val user: User,

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    var status: OrderStatus = OrderStatus.PENDING,
) : BaseEntity() {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null
        protected set
}

Specifying fetch = LAZY is almost a magic incantation — without it, every Order query drags a User SELECT along.

Bidirectional @OneToMany (Kotlin) — only when truly needed
@Entity
class Order(
    // ...
) : BaseEntity() {

    @OneToMany(mappedBy = "order", cascade = [CascadeType.ALL], orphanRemoval = true)
    val items: MutableList<OrderItem> = mutableListOf()

    // Convenience method — keeping both sides in sync is the domain's job
    fun addItem(item: OrderItem) {
        items.add(item)
        item.assignTo(this)
    }
}

@Entity
@Table(name = "order_items")
class OrderItem(
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "order_id", nullable = false)
    var order: Order,
) : BaseEntity() {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null
        protected set

    fun assignTo(order: Order) {
        this.order = order
    }
}

In a bidirectional mapping, one side is the “owner of the relationship” (usually the @ManyToOne side), and the other declares mappedBy to mark itself read-only. Cascade is only justified when the child’s lifecycle matches the parent’s (OrderItem is meaningless without Order).

fetch / cascade quick reference
AnnotationDefault fetchRecommendation
@ManyToOneEAGERDeclare LAZY
@OneToOneEAGERDeclare LAZY
@OneToManyLAZYKeep LAZY
@ManyToManyLAZYResolve into a join entity
CascadeMeaningWhen
PERSISTSave child when parent is savedChild can’t exist independently
REMOVEDelete child when parent is deletedLifecycle matches
ALLActivate every cascadeAlmost never use
(none)Explicit save requiredDefault — the safest

@ManyToMany is mostly a trap — resolving it into a join entity (like OrderItem) lets you naturally add columns like quantity and price.

Note: N+1 problems caused by associations, fetch joins, and @EntityGraph are covered in Part 4 — Performance.


6. Global Exception Handling

Use @RestControllerAdvice to handle exceptions consistently across the entire application. It’s not a generic catch-all — different exception types branch to different response shapes.

6.1 Handler Priority

Spring matches the most specific handler first based on the exception class hierarchy.

PriorityHandlerTarget
1CommonException::classExceptions intentionally thrown from business logic
2MethodArgumentNotValidException::classExceptions thrown when @Valid validation fails
3Exception::classAll unhandled exceptions (Fallback)

6.2 Role of Each Handler

  • CommonException handler — handles exceptions explicitly thrown from service logic (NotFoundException, BadRequestException, etc.). Responds with the HTTP status code and error code defined in the exception.
  • MethodArgumentNotValidException handler — fires when @Valid validation fails in the Controller. Extracts which field failed and why, then sends that message to the client.
  • Exception handler (Fallback) — the last line of defense for everything the prior two handlers don’t catch. Internal information (NPE messages, DB connection errors, etc.) must never leak to the client; only the server log gets the full stack trace.

Note: Without a fallback handler, Spring’s default error page (Whitelabel Error Page) or a stack trace will be exposed. Surfacing that during evaluation is itself a deduction for insufficient exception handling.

6.3 GlobalExceptionHandler Implementation

@RestControllerAdvice
class GlobalExceptionHandler {

    private val log = LoggerFactory.getLogger(this::class.java)

    /**
     * Business exception handler
     * - Handles exceptions intentionally thrown from the service
     * - Uses the HTTP status code and error code defined in the exception as-is
     */
    @ExceptionHandler(CommonException::class)
    fun handleCommonException(e: CommonException): ResponseEntity<CommonResponse<Unit>> {
        val response = CommonResponse.error<Unit>(
            e.errorCode.code,
            e.errorCode.message,
        )
        return ResponseEntity(response, e.statusCode)
    }

    /**
     * Validation exception handler
     * - Triggered when @Valid validation fails
     * - Extracts the failed field name and message for the response
     */
    @ExceptionHandler(MethodArgumentNotValidException::class)
    fun handleValidationException(
        e: MethodArgumentNotValidException,
    ): ResponseEntity<CommonResponse<Unit>> {
        val fieldError = e.bindingResult.fieldErrors.firstOrNull()
        val message = fieldError?.let { "${it.field}: ${it.defaultMessage}" }
            ?: "Validation failed"

        val response = CommonResponse.error<Unit>(ErrorCode.ERR001.code, message)
        return ResponseEntity(response, HttpStatus.BAD_REQUEST)
    }

    /**
     * Unexpected exception handler (Fallback)
     * - Catches all exceptions not handled by the above handlers
     * - Returns a generic message to prevent internal information exposure
     * - Records the full stack trace in server logs for debugging
     */
    @ExceptionHandler(Exception::class)
    fun handleException(e: Exception): ResponseEntity<CommonResponse<Unit>> {
        log.error("Unexpected error occurred", e)

        val response = CommonResponse.error<Unit>(
            ErrorCode.ERR000.code,
            ErrorCode.ERR000.message,
        )
        return ResponseEntity(response, HttpStatus.INTERNAL_SERVER_ERROR)
    }
}

Note: How to integrate Spring Security’s authentication/authorization exceptions (AuthenticationException, AccessDeniedException) into the same handler shape is covered in Part 5 — Security.


Recap

  • The four layers depend in one direction — Controller → Service → Repository → DB. Reverse dependencies are anti-patterns, and that’s the first thing evaluators look at.
  • Request DTOs die in the Controller — only Commands enter the Service. The signal is that Service tests must run as unit tests without MockMvc.
  • Separate read and write transactions@Transactional(readOnly = true) on the class, @Transactional on write methods. One line, two wins: lower Dirty Checking cost and Read Replica routing potential.
  • Entities speak through state-changing methodsupdate(), enable(), disable() instead of setters. What can change must be visible in code.
  • Exceptions become responses in the handlerCommonException → intended error response, validation → field message, fallback → hide internals and return a generic message. A missing fallback is itself a deduction.

Checklist by Layer

LayerCheck Points
ControllerHTTP method mapping, URI design, validation, common response, Request → Command conversion
ServiceTransaction split, exception handling, Response DTO static factory, Command input
RepositoryNullable handling, pagination, Querydsl usage
DomainBusiness methods, BaseEntity, kotlin-jpa plugin, associations with fetch=LAZY
Exception HandlerThree-tier priority, fallback that blocks internal exposure
Pre-submission Quick Checklist
  • Are CRUD operations correctly mapped to HTTP methods?
  • Do URIs clearly represent resources?
  • Is validation applied to DTOs? (using @field:NotBlank form?)
  • Are Request DTOs converted to Commands before being passed to the Service?
  • Is readOnly = true set for read transactions?
  • Does Entity → Response DTO conversion use a from() static factory?
  • Are @ManyToOne / @OneToOne declared with fetch = LAZY?
  • Is bidirectional mapping used only when truly necessary?
  • Are exceptions handled consistently in the GlobalExceptionHandler?
  • Do entities have business methods instead of setters?
  • Is the fallback handler (Exception::class) defined?

The next part is Database & Testing. With the four layers drawn in code, we now look at the database those layers live on and how that code gets verified. Profile-based H2/MySQL separation, JPA mapping pitfalls, and how to split unit, slice, and integration tests so the evaluator walks away thinking, “this person actually writes tests.”

Next: Part 2 — Database & Testing

Shop on Amazon

As an Amazon Associate, I earn from qualifying purchases.