본문 바로가기
개발 (ENG)

Spring Boot (Kotlin) — ep.2 Designing the API Response Format

by 새싹 아빠 2025. 11. 24.

📚 Spring Boot (Kotlin) Server Setup — Series Overview

    1. Why Multi-Module Architecture? (Architecture Philosophy & System Design)
    2. Designing the API Response Format ← You are here
    3. Global Exception Handling (GlobalExceptionHandler)
    4. Swagger (OpenAPI) Configuration
    5. Security (JWT) Fundamentals
    6. JWT TokenProvider
    7. Redis Configuration
    8. Validation Setup
    9. Logging + MDC (traceId)
    10. application.yml Profile Separation (local/dev/prod)
    11. Multi-Module + JPA Base Structure
    12. Final Project Template git

 

One of the first challenges when starting API development is the question: “How should we design the response format?”
In the early stages, returning raw data or defining responses differently per controller may not feel problematic.
However, as the project grows — more APIs, more exceptions, more authentication/authorization logic — response format consistency becomes a critical requirement.

 

📌 Why Should We Design the API Response Format Early?

  • Consistency — If all APIs share the same structure, clients (Flutter, React, iOS, etc.) can reuse their parsing logic.
  • Extensibility — When errors, validations, or auth failures occur, we don’t need to reinvent new rules.
  • Better Debugging & Monitoring — Standardized failure cases make exception tracking far easier.
  • Security Advantages — Prevents exposing internal server messages and lets us control what the client sees.
  • Team Collaboration — Backend, frontend, and QA can communicate using a shared convention.

In short, defining the API response format is not just a formatting task — it's about establishing a core system rule for the entire service.

 

📌 Base Response Structure

This structure clearly separates success from failure and ensures every response follows the same format.

data class ApiResponse<T>(
    val success: Boolean,
    val data: T? = null,
    val error: ApiError? = null
) {
    companion object {
        fun <T> ok(data: T): ApiResponse<T> =
            ApiResponse(success = true, data = data)

        fun <T> empty(): ApiResponse<T> =
            ApiResponse(success = true, data = null)

        fun fail(
            code: String,
            messageKey: String,
            detail: Any? = null
        ): ApiResponse<Unit> =
            ApiResponse(
                success = false,
                error = ApiError(code, messageKey, detail)
            )
    }
}

data class ApiError(
    val code: String,
    val message: String,
    val detail: Any? = null
)

 

✅ Key Design Points

1️⃣ Explicit success flag

Even if the HTTP status is 200 OK, the business logic may still represent a failure.
That’s why the response explicitly uses success = true/false to indicate the result.

2️⃣ data only used for successful responses

On success, the actual payload is returned in data.
For failures, data is unused — instead, the error object communicates the cause.

3️⃣ A dedicated error object

Errors are structured into code, message, and detail:

  • code — System-level identifiable error code
  • message — User-facing message or message key
  • detail — Additional debugging information

This design becomes extremely useful when dealing with ValidationException, AuthenticationException, AuthorizationException, etc.

 

📌 Why messageKey instead of message?

Sending raw text messages causes several problems:

  • Changing text requires modifying server code
  • No support for internationalization (multiple languages)
  • Risk of exposing internal system messages

So the server only returns a message key, while the actual user-facing text can be managed by the client or a separate message service.

 

📌 Where Should ApiResponse Be Located? (Which Module?)

In a multi-module architecture, each module has a clear responsibility:

  • api module — Controllers, request/response DTOs, authentication filters, and anything that directly handles HTTP.
  • application module — Core business logic, service layer, exceptions, and shared application rules.

ApiResponse<T> is not just a convenience class for controllers — it represents a system-wide contract for how the application communicates with the outside world. For that reason, placing it in the application module is the most appropriate choice.

  • 1. To keep dependency direction clean
    The natural dependency flow should be api → application.
    If ApiResponse is placed in the api module and the application layer needs it, the dependency direction would reverse (application → api), breaking the architectural rule.
  • 2. Shared rules belong in the core
    The response format isn’t limited to HTTP controllers. It may also be reused later for:
    • internal admin APIs,
    • batch services exposing endpoints,
    • other adapters like messaging or gRPC.
    Because it’s a shared contract, it belongs in the core application module.
  • 3. It integrates with GlobalExceptionHandler
    The handler lives in the api module, but the response type itself should remain in the application module, ensuring that:
    ✅ the api module uses the rule
    ✅ the application module defines the rule

In summary, ApiResponse acts as a communication contract rather than a simple HTTP DTO, so placing it in the application module keeps the architecture clean, scalable, and future-proof.

 

📌 Failure Response Example

{
  "success": false,
  "error": {
    "code": "AUTH_001",
    "message": "error.auth.invalid_token",
    "detail": {
      "expiredAt": "2025-01-01T10:00:00Z"
    }
  }
}

The key point: the structure is always the same, even on failure.

 

📌 Connection to Global Exception Handling

This response format integrates naturally with the upcoming GlobalExceptionHandler:

  • All exceptions return a unified response format
  • Logging and tracing become easier
  • Works perfectly with MDC(traceId) for faster issue analysis

So this is not just a response wrapper — it’s the foundation of the project’s standardization.

 

📌 Security Benefits

  • Prevents exposure of internal system messages
  • Hides stack traces and raw exception messages
  • Allows returning only what the client needs

In other words, we deliver a response that fails safely.

 

✅ Conclusion

Designing the API response format early is about defining the architectural baseline.
This single rule enables consistent implementation of future features like exception handling, JWT security, validation, logging, and Redis.

As the project grows, standardization becomes a necessity — not an option.

 

📌 Next Episode

Episode 3 — Global Exception Handling (GlobalExceptionHandler)

📌 We will explore how this response format is actually used in real exception scenarios.

 

https://jaemoi8.tistory.com/33

 

Spring Boot (Kotlin) — ep.3 Global Exception Handling (GlobalExceptionHandler)

📚 Spring Boot(Kotlin) Server Setup — Series OverviewWhy Multi-Module Architecture? (Architecture Philosophy & System Design)Designing the API Response FormatGlobal Exception Handling (GlobalExceptionHandler) ← Current EpisodeSwagger (OpenAPI) Config

jaemoi8.tistory.com