Spring Boot Pre-Interview Guide Part 1: Core Application Layer — Controller, Service, Repository, Domain

Spring Boot Pre-Interview Guide Part 1: Core Application Layer — Controller, Service, Repository, Domain


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. Default constructor stays protected.
  • 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.

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

Spring Boot shape (Java)

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

// Service — delegate state transition to the domain method
@Transactional
public Long modifyOrder(Long orderId, ModifyOrderCommand command) {
    Order order = orderRepository.findById(orderId)
        .orElseThrow(NotFoundException::new);

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

// Entity — same business-method + invariant pattern as Section 5
public void cancel() {
    if (this.status == OrderStatus.COMPLETED) {
        throw new 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.

ApiPaths (Kotlin)
object ApiPaths {
    const val API = "/api"
    const val V1 = "/v1"
    const val PRODUCTS = "/products"
}
ApiPaths (Java)
public final class ApiPaths {
    public static final String API = "/api";
    public static final String V1 = "/v1";
    public static final String PRODUCTS = "/products";

    private ApiPaths() {}
}

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).

CommonResponse (Kotlin)
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> {
            return CommonResponse(CODE_SUCCESS, MSG_SUCCESS, data)
        }

        fun <T> error(code: String, message: String, data: T? = null): CommonResponse<T> {
            return CommonResponse(code, message, data)
        }
    }
}
CommonResponse (Java)
public record CommonResponse<T>(
    String code,
    String message,
    T data
) {
    public static final String CODE_SUCCESS = "SUC200";
    public static final String MSG_SUCCESS = "success";

    public static <T> CommonResponse<T> success() {
        return new CommonResponse<>(CODE_SUCCESS, MSG_SUCCESS, null);
    }

    public static <T> CommonResponse<T> success(T data) {
        return new CommonResponse<>(CODE_SUCCESS, MSG_SUCCESS, data);
    }

    public static <T> CommonResponse<T> error(String code, String message) {
        return new CommonResponse<>(code, message, null);
    }
}

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.

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 & Command (Kotlin)
// 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
}
Request DTO & Command (Java)
// Request DTO - used for validation in the Controller
public record RegisterProductRequest(
    @NotBlank
    @Size(max = 100)
    String name,

    @Size(min = 1)
    @Valid
    List<ProductDetailDto> details
) {
    public RegisterProductCommand toCommand() {
        return new RegisterProductCommand(
            name,
            details.stream()
                .map(ProductDetailDto::toCommand)
                .toList()
        );
    }
}

public record ProductDetailDto(
    @NotNull
    ProductCategoryType type,

    @NotBlank
    String name
) {
    public ProductDetailCommand toCommand() {
        return new ProductDetailCommand(type, name);
    }
}

public record ModifyProductRequest(
    @NotBlank
    @Size(max = 100)
    String name,

    @NotNull
    ProductCategoryType category
) {
    public ModifyProductCommand toCommand() {
        return new ModifyProductCommand(name, category);
    }
}

// Command - pure data object used in the Service Layer
public record RegisterProductCommand(
    String name,
    List<ProductDetailCommand> details
) {}

public record ProductDetailCommand(
    ProductCategoryType type,
    String name
) {}

public record ModifyProductCommand(
    String name,
    ProductCategoryType category
) {}

public enum 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.

Controller (Kotlin)
@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()
    }
}
Controller (Java)
@RestController
@RequestMapping(API + V1 + PRODUCTS)   // import static com.example.config.ApiPaths.*;
@RequiredArgsConstructor
public class ProductController {

    private final ProductService productService;

    @GetMapping("/{productId}")
    public CommonResponse<FindProductDetailResponse> findProductDetail(
            @PathVariable Long productId) {
        return CommonResponse.success(productService.findProductDetail(productId));
    }

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

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

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

    @DeleteMapping
    public CommonResponse<Void> deleteProducts(
            @Valid @Size(min = 1) @RequestParam Set<Long> productIds) {
        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
public class ProductService {

    public Product findById(Long id) { ... }  // readOnly = true applied

    @Transactional  // write operation: overrides to readOnly = false
    public Long save(Product product) { ... }
}
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.

Custom Exception (Kotlin)
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)
Custom Exception (Java)
@Getter
@RequiredArgsConstructor
public enum ErrorCode {
    ERR000("ERR000", "A temporary error occurred. Please try again later."),
    ERR001("ERR001", "Invalid request."),
    ERR002("ERR002", "Product not found.");

    private final String code;
    private final String message;
}

@Getter
public class CommonException extends RuntimeException {
    private final HttpStatus statusCode;
    private final ErrorCode errorCode;

    public CommonException(HttpStatus statusCode, ErrorCode errorCode) {
        super(errorCode.getMessage());
        this.statusCode = statusCode;
        this.errorCode = errorCode;
    }
}

public class NotFoundException extends CommonException {
    public NotFoundException() {
        super(HttpStatus.NOT_FOUND, ErrorCode.ERR002);
    }

    public NotFoundException(ErrorCode errorCode) {
        super(HttpStatus.NOT_FOUND, errorCode);
    }
}

3.3 Nullable Handling

  • Kotlin: use ?: (Elvis operator) and nullable types
  • Java: use Optional and orElseThrow()
Service Query (Kotlin)
@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)
    }
}
Service Query (Java)
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ProductService {

    private final ProductRepository productRepository;

    public FindProductDetailResponse findProductDetail(Long productId) {
        Product product = productRepository.findById(productId)
            .orElseThrow(NotFoundException::new);

        return FindProductDetailResponse.from(product);
    }
}

3.4 Service Implementation Principles

  • Don’t return Domain Models directly; convert them to response-specific DTOs
  • Use Streams for repetitive logic while preserving readability
  • 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:

@Override
@Transactional
public void deleteById(ID id) {
    Assert.notNull(id, "...");
    findById(id).ifPresent(this::delete);   // ← 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 1 — why deleteById is rarely used

productRepository.deleteById(productId);   // one line, but...

In modern Spring Data, calling it with a non-existent ID throws nothing. The caller can’t tell whether anything was actually deleted, which becomes a silent entry point for bugs. It also leaves no room for pre-deletion checks (“can’t delete an order that’s already paid”) or audit logging.

Approach 2 — the standard (findById + delete)

@Transactional
public void deleteProduct(Long productId) {
    Product product = productRepository.findById(productId)
        .orElseThrow(NotFoundException::new);

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

Same query count as deleteById, but with an explicit 404 and a place to insert validation. It also lines up with the rest of Part 1’s pattern of “the Service pulls the domain object and operates on it.”

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")
int deleteByIdInBulk(@Param("id") Long id);

A single DELETE is fired, but:

  • @PreRemove·@PostRemove are skipped
  • cascade is bypassed → child entities can remain and violate FK constraints
  • persistence context goes stale → re-querying within the same transaction can return cached data

Not worth using for a single entity. Only useful for WHERE id IN (...) style bulk deletes of tens of thousands of rows.

Approach 4 — the canonical soft delete

// Service
@Transactional
public void deleteProduct(Long productId) {
    Product product = productRepository.findById(productId)
        .orElseThrow(NotFoundException::new);
    product.softDelete();   // Dirty Checking emits the UPDATE
}

// Entity
public void softDelete() {
    if (this.deletedAt != null) {
        throw new BadRequestException(ErrorCode.ALREADY_DELETED);
    }
    this.deletedAt = LocalDateTime.now();
}

The domain owns the “can this be deleted now?” rule, and the UPDATE is emitted by Dirty Checking — no explicit save call needed.

Choosing by requirement

Requirement ──┬── plain hard delete                     → Approach 2 (findById + delete)
              ├── hard delete with validation/audit     → Approach 2 + a domain method
              ├── soft delete                           → Approach 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)
Optional<Product> findByIdAndDeletedAtIsNull(Long id);
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:

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

@Entity
public class Order {
    @Enumerated(EnumType.STRING)   // ORDINAL breaks when the enum is reordered or extended
    @Column(nullable = false)
    private OrderStatus status;
}

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
public void deleteUser(Long userId) {
    User user = userRepository.findById(userId).orElseThrow(NotFoundException::new);

    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.

Pattern E — event sourcing

“Delete” becomes an event written to an append-only log; current state is derived by replay. Used in finance, healthcare, and regulated industries — but it’s a complexity step up and almost never makes sense for a pre-interview assignment.

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.”

Service (Kotlin)
@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)
    }
}
Service (Java)
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ProductService {

    private final ProductRepository productRepository;

    @Transactional
    public Long modifyProduct(Long productId, ModifyProductCommand command) {
        Product product = productRepository.findById(productId)
            .orElseThrow(NotFoundException::new);

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

        return product.getId();
    }

    @Transactional
    public void deleteProduct(Long productId) {
        Product product = productRepository.findById(productId)
            .orElseThrow(NotFoundException::new);

        productRepository.delete(product);
    }

    @Transactional
    public void deleteProducts(Set<Long> productIds) {
        List<Product> products = productRepository.findAllById(productIds);

        if (products.size() != productIds.size()) {
            throw new 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.

Static factory (Java)
public record FindProductDetailResponse(
    Long id,
    String name,
    ProductCategoryType category,
    boolean enabled,
    LocalDateTime createdAt
) {
    public static FindProductDetailResponse from(Product product) {
        return new FindProductDetailResponse(
            product.getId(),
            product.getName(),
            product.getCategory(),
            product.isEnabled(),
            product.getCreatedAt()
        );
    }
}

// In the Service
public FindProductDetailResponse findProductDetail(Long productId) {
    Product product = productRepository.findById(productId)
        .orElseThrow(NotFoundException::new);
    return FindProductDetailResponse.from(product);
}

For collection responses, use entities.stream().map(Response::from).toList(). For paged responses, use page.map(Response::from) — Spring Data’s Page supports map directly.

Static factory (Kotlin)
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
        )
    }
}

An extension function (fun Product.toResponse()) is also possible, but the static factory stays more consistent with the rest of the code style.

MapStruct alternative

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

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

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: Java uses Optional, Kotlin uses Nullable
  • 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.

Repository (Kotlin)
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? {
        return name?.let { QProduct.product.name.containsIgnoreCase(it) }
    }

    private fun enabledEq(enabled: Boolean?): BooleanExpression? {
        return enabled?.let { QProduct.product.enabled.eq(it) }
    }
}
Repository (Java)
public interface ProductRepository extends JpaRepository<Product, Long>,
        ProductRepositoryCustom {

    Optional<Product> findByIdAndDeletedAtIsNull(Long id);
    List<Product> findAllByIdIn(Collection<Long> ids);
}

public interface ProductRepositoryCustom {
    Page<Product> findProducts(String name, Boolean enabled, Pageable pageable);
}

@RequiredArgsConstructor
public class ProductRepositoryImpl implements ProductRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    @Override
    public Page<Product> findProducts(String name, Boolean enabled, Pageable pageable) {
        QProduct product = QProduct.product;

        List<Product> results = queryFactory
            .selectFrom(product)
            .where(
                nameContains(name),
                enabledEq(enabled)
            )
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .orderBy(product.id.desc())
            .fetch();

        JPAQuery<Long> countQuery = queryFactory
            .select(product.count())
            .from(product)
            .where(
                nameContains(name),
                enabledEq(enabled)
            );

        return PageableExecutionUtils.getPage(results, pageable, countQuery::fetchOne);
    }

    private BooleanExpression nameContains(String name) {
        return name != null ? QProduct.product.name.containsIgnoreCase(name) : null;
    }

    private BooleanExpression enabledEq(Boolean enabled) {
        return enabled != null ? QProduct.product.enabled.eq(enabled) : null;
    }
}

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.
  • Default constructor should be protected: satisfies the JPA spec and prevents indiscriminate object creation
  • 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

@Entity
@NoArgsConstructor  // defaults to public
public class Product extends BaseEntity {
    @Column(nullable = false)
    private String name;

    public Product(String name) { this.name = name; }
}

// Anyone can do this
Product p = new Product();    // a zombie object with name = null
productRepository.save(p);    // you won't notice until DB rejects NULL

Entities are designed to remove setters and have state changed only via constructors and business methods. A public no-arg constructor breaks that principle the moment it exists.

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

Private can be worked around via reflection, but it violates the JPA spec and breaks under certain bytecode-enhancement setups.

3. protected sits at the intersection of both pressures

  • Visible enough for JPA / Hibernate (spec-compliant, proxy-able)
  • Invisible to application code (new Product() is blocked)
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)  // ← the standard one-liner
public class Product extends BaseEntity {
    public Product(String name) { this.name = name; }
}

new Product();   // compile error — protected access

Kotlin is different

// build.gradle.kts
plugins {
    kotlin("plugin.jpa") version "..."     // synthesizes no-arg ctor on @Entity
    kotlin("plugin.allopen") version "..." // 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.

Using Lombok in entities — is it safe?

Annotations that require caution

AnnotationRiskReason
@DataHighIncludes @EqualsAndHashCode — infinite loop with bidirectional relationships
@EqualsAndHashCodeHighStackOverflow when including related entities
@ToStringMediumForces lazy-loading proxy initialization, infinite loop
@AllArgsConstructorMediumBugs possible when field order changes
@SetterLowUnintended state changes possible
@GetterSafeGenerally no issues
@NoArgsConstructorSafeRecommended with access = PROTECTED
@BuilderSafeBut be careful when combined with @AllArgsConstructor

@Builder + @AllArgsConstructor combination caution

❌ Potentially problematic pattern

@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Product {
    @Id @GeneratedValue
    private Long id;
    private String name;
    private int price;
}

// Builder calls AllArgsConstructor
// If field order changes, values may be assigned incorrectly
Product product = Product.builder()
    .name("Product")
    .price(1000)
    .build();

✅ Recommended pattern — apply @Builder directly to a constructor

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Product {
    @Id @GeneratedValue
    private Long id;
    private String name;
    private int price;

    @Builder
    private Product(String name, int price) {
        this.name = name;
        this.price = price;
    }
}

Applying @Builder to the constructor lets you specify only the required fields and is safe against field-order changes.

Recommended production pattern

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Product {
    // No @Setter — change state through business methods
    // @ToString — if needed, implement manually excluding related entities
    // @EqualsAndHashCode — implement ID-based manually or don't use it
}

Recommendation for assignments

Use only @Getter and @NoArgsConstructor(access = PROTECTED), and implement everything else manually. Never use @Data.

5.2 BaseEntity

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

BaseEntity (Kotlin)
@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
}
BaseEntity (Java)
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@Getter
public abstract class BaseEntity {

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    @Column
    private LocalDateTime updatedAt;
}

@MappedSuperclass
@Getter
public abstract class BaseEntityWithAuditor extends BaseEntity {

    @CreatedBy
    @Column(updatable = false)
    private Long createdBy;

    @LastModifiedBy
    @Column
    private Long updatedBy;
}

5.3 Entity Implementation

Entity (Kotlin)
@Entity
@Table(name = "products")
class Product(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null,

    @Column(nullable = false)
    var name: String,

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

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

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

    fun enable() {
        this.enabled = true
    }

    fun disable() {
        this.enabled = false
    }
}
Entity (Java)
@Entity
@Table(name = "products")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Product extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private Boolean enabled = true;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private ProductCategoryType category;

    public Product(String name, ProductCategoryType category) {
        this.name = name;
        this.category = category;
    }

    public void update(String name, ProductCategoryType category) {
        this.name = name;
        this.category = category;
    }

    public void enable() {
        this.enabled = true;
    }

    public void 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 (Java) — the safe default
@Entity
@Table(name = "orders")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private OrderStatus status = OrderStatus.PENDING;

    public Order(User user) {
        this.user = user;
    }
}

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

Bidirectional @OneToMany (Java) — only when truly needed
@Entity
public class Order extends BaseEntity {
    // ...
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<OrderItem> items = new ArrayList<>();

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

@Entity
@Table(name = "order_items")
public class OrderItem {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "order_id", nullable = false)
    private Order order;

    void 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

GlobalExceptionHandler (Kotlin)
@RestControllerAdvice
class GlobalExceptionHandler {

    private val log = LoggerFactory.getLogger(javaClass)

    /**
     * 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)
    }
}
GlobalExceptionHandler (Java)
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    /**
     * Business exception handler
     */
    @ExceptionHandler(CommonException.class)
    public ResponseEntity<CommonResponse<Void>> handleCommonException(CommonException e) {
        CommonResponse<Void> response = CommonResponse.error(
            e.getErrorCode().getCode(),
            e.getErrorCode().getMessage()
        );
        return ResponseEntity.status(e.getStatusCode()).body(response);
    }

    /**
     * Validation exception handler
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<CommonResponse<Void>> handleValidationException(
            MethodArgumentNotValidException e) {
        FieldError fieldError = e.getBindingResult().getFieldErrors().stream()
            .findFirst()
            .orElse(null);

        String message = fieldError != null
            ? fieldError.getField() + ": " + fieldError.getDefaultMessage()
            : "Validation failed";

        CommonResponse<Void> response = CommonResponse.error(
            ErrorCode.ERR001.getCode(),
            message
        );
        return ResponseEntity.badRequest().body(response);
    }

    /**
     * Fallback
     */
    @ExceptionHandler(Exception.class)
    public ResponseEntity<CommonResponse<Void>> handleException(Exception e) {
        log.error("Unexpected error occurred", e);

        CommonResponse<Void> response = CommonResponse.error(
            ErrorCode.ERR000.getCode(),
            ErrorCode.ERR000.getMessage()
        );
        return ResponseEntity.internalServerError().body(response);
    }
}

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, protected constructor, 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?
  • 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.