Spring Boot Pre-Interview Guide Part 3: Documentation & AOP — Swagger · MDC · Aspect

Spring Boot Pre-Interview Guide Part 3: Documentation & AOP — Swagger · MDC · Aspect


Introduction

Documentation, logging, and AOP are where pre-interview submissions visibly diverge. A working Swagger UI, request IDs in every log line, and AOP-separated cross-cutting concerns signal operational awareness. Stale Springfox dependencies, blanket-INFO logging, or AOP misused for business branching signal the opposite.

Parts 1 and 2 covered the four-layer architecture and database/testing strategy. Part 3 covers the observability and operational layer on top — the signals that tell an evaluator “this developer understands production.”

Target reader: a junior backend developer who knows Spring but isn’t sure how evaluators read this area.

See the previous post for Database & Testing.


TL;DR

  • Minimal SpringDoc is enough to score@Tag, @Operation(summary), and key @ApiResponse annotations cover the evaluator’s needs. Filling every field with @Schema wastes task time.
  • Drop the logging-performance myths{} placeholders are the default. isDebugEnabled() guards are only needed around expensive toString() calls or logging inside loops.
  • MDC handles single-app request tracing — drop a request ID into thread-local and Logback’s %X{key} pattern stamps it on every log line automatically. Distributed tracing is out of scope for pre-interview tasks.
  • AOP belongs on cross-cutting concerns only — logging, transactions, and retry are correct targets. Using AOP for business branching makes the code untraceable.
  • Know the self-invocation trap — calling a @Retry method from within the same class bypasses the proxy, so the advice never fires. The fix is to move the dependency to a separate bean.

1. API Documentation — SpringDoc/Swagger

1.1 How SpringDoc Works and Basic Setup

SpringDoc OpenAPI scans annotations at startup, builds an OpenAPI JSON spec, and serves it so Swagger UI can render a live playground.

At runtime, SpringDoc scans @Tag, @Operation, and @Schema, produces the spec at /v3/api-docs (or a custom path), and Swagger UI at /swagger-ui.html consumes that JSON to render the interactive UI.

flowchart LR
    A["Controller annotations\n@Tag · @Operation · @Schema"] --> B["SpringDoc scanner\n(runtime)"]
    B --> C["OpenAPI spec JSON\n/v3/api-docs"]
    C --> D["Swagger UI\n/swagger-ui.html"]
    D --> E["Browser playground"]

Note: Springfox has compatibility issues with Spring Boot 2.6+ and is no longer maintained. SpringDoc OpenAPI is the current standard.

Versions — pick the SpringDoc line that matches your Spring Boot

SpringDoc ships separate lines per Spring Boot major. Check the Spring Boot version your task is built on first, then pick the matching SpringDoc line.

Spring BootSpringDoc lineLatest (May 2026)Notes
4.x3.x3.0.3Java 17+, Jakarta EE 9, OpenAPI 3.1 support
3.x (LTS)2.x2.8.17BOM available since 2.8.7, still actively maintained
2.x1.x1.8.0Legacy — unlikely on a new task

Dependency (Kotlin DSL)

// build.gradle.kts
dependencies {
    // Spring Boot 4.x
    implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3")

    // Spring Boot 3.x (LTS) — when the codebase is pinned to LTS
    // implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.17")
}
More detail — Groovy DSL dependency
// build.gradle
dependencies {
    // Spring Boot 4.x
    implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3'

    // Spring Boot 3.x (LTS)
    // implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.17'
}

Note: For WebFlux, swap in springdoc-openapi-starter-webflux-ui. Only the artifact name changes — same line, same version.

Core application.yml options

SettingDescriptionDefault
api-docs.pathOpenAPI JSON spec path/v3/api-docs
swagger-ui.pathSwagger UI path/swagger-ui.html
tags-sorterController sort (alpha, declaration order)Declaration order
operations-sorterAPI sort (alpha, method)Declaration order
springdoc:
  api-docs:
    path: /api-docs
  swagger-ui:
    path: /swagger-ui.html
    tags-sorter: alpha
    operations-sorter: alpha
  default-consumes-media-type: application/json
  default-produces-media-type: application/json

OpenApiConfig (Kotlin)

A single bean that collects the API metadata — title, version, contact, server URL — in one place. Once registered, SpringDoc picks it up automatically and embeds it in the generated OpenAPI spec. SpringDoc works without this bean, but a Swagger UI with an empty title and version reads as “left at defaults,” so it’s worth wiring up this one config.

@Configuration
class OpenApiConfig {

    @Bean
    fun openAPI(): OpenAPI {
        return OpenAPI()
            .info(
                Info()
                    .title("Product API")
                    .description("Product Management API Documentation")
                    .version("v1.0.0")
                    .contact(Contact().name("Developer").email("dev@example.com"))
            )
            .servers(listOf(Server().url("http://localhost:8080").description("Local Server")))
    }
}
More detail — OpenApiConfig (Java)
@Configuration
public class OpenApiConfig {

    @Bean
    public OpenAPI openAPI() {
        return new OpenAPI()
            .info(new Info()
                .title("Product API")
                .description("Product Management API Documentation")
                .version("v1.0.0")
                .contact(new Contact().name("Developer").email("dev@example.com")))
            .servers(List.of(
                new Server().url("http://localhost:8080").description("Local Server")
            ));
    }
}

1.2 Controller and DTO Annotations — Minimal vs Excessive

Verdict: @Tag, @Operation(summary), and key @ApiResponse annotations are sufficient for evaluation. Every minute spent filling out @Schema on every field is a minute not spent on core feature completeness.

AreaMinimal (recommended)Excessive (not recommended)
API description@Operation(summary = "…")description, every error case in @ApiResponse
Parameters@Parameter on path variables onlyFull description on every query param
DTO fieldsClass-level @Schema(description)Per-field @Schema(example, minimum, maxLength)

Controller example (Kotlin)

This is the “Minimum” column from the table above, applied directly. One @Tag on the class, one @Operation(summary) per method with two or three key @ApiResponse entries, and a single-line @Parameter on each path variable. That much already gives Swagger UI clean grouping, summaries, and response codes.

@Tag(name = "Product", description = "Product Management API")
@RestController
@RequestMapping("/api/v1/products")
class ProductController(private val productService: ProductService) {

    @Operation(summary = "Get product details")
    @ApiResponses(
        ApiResponse(responseCode = "200", description = "Successfully retrieved"),
        ApiResponse(responseCode = "404", description = "Product not found")
    )
    @GetMapping("/{productId}")
    fun findProductDetail(
        @Parameter(description = "Product ID", example = "1")
        @PathVariable productId: Long
    ): CommonResponse<FindProductDetailResponse> {
        return CommonResponse.success(productService.findProductDetail(productId))
    }

    @Operation(summary = "Register product")
    @ApiResponses(
        ApiResponse(responseCode = "201", description = "Successfully registered"),
        ApiResponse(responseCode = "400", description = "Bad request")
    )
    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    fun registerProduct(@RequestBody request: RegisterProductRequest): CommonResponse<Long> {
        return CommonResponse.success(productService.registerProduct(request))
    }
}
More detail — Controller example (Java)
@Tag(name = "Product", description = "Product Management API")
@RestController
@RequestMapping("/api/v1/products")
@RequiredArgsConstructor
public class ProductController {

    private final ProductService productService;

    @Operation(summary = "Get product details")
    @ApiResponses({
        @ApiResponse(responseCode = "200", description = "Successfully retrieved"),
        @ApiResponse(responseCode = "404", description = "Product not found")
    })
    @GetMapping("/{productId}")
    public CommonResponse<FindProductDetailResponse> findProductDetail(
            @Parameter(description = "Product ID", example = "1")
            @PathVariable Long productId) {
        return CommonResponse.success(productService.findProductDetail(productId));
    }

    @Operation(summary = "Register product")
    @ApiResponses({
        @ApiResponse(responseCode = "201", description = "Successfully registered"),
        @ApiResponse(responseCode = "400", description = "Bad request")
    })
    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public CommonResponse<Long> registerProduct(@RequestBody RegisterProductRequest request) {
        return CommonResponse.success(productService.registerProduct(request));
    }
}

Price field type tradeoffs

TypeProsConsUse when
Int/LongSimple, fastNo decimals, overflow riskCounts, IDs
BigDecimalPrecision guaranteed, decimal supportVerbose operations, serialization carePrices, amounts, ratios

The DTO below shows the BigDecimal choice from the table, with @Schema applied at §1.2’s “Minimum” level — one annotation on the class and one short one per field. Filling in example, minimum, maxLength for every field was deliberately skipped.

@Schema(description = "Product registration request")
data class RegisterProductRequest(
    @field:NotBlank
    @field:Size(max = 100)
    @Schema(description = "Product name", example = "Fresh Apple")
    val name: String?,

    @field:NotNull
    @field:DecimalMin(value = "0", inclusive = false)
    @Schema(description = "Price", example = "10000.00")
    val price: BigDecimal?,

    @field:NotNull
    @Schema(description = "Category", example = "FOOD")
    val category: ProductCategoryType?
)

1.3 Allowing Swagger Paths in Spring Security

When SecurityConfig is present, /swagger-ui/** and /v3/api-docs/** return 401/403. If the evaluator cannot reach Swagger UI, the documentation work is invisible.

@Configuration
@EnableWebSecurity
class SecurityConfig {

    @Bean
    fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
        return http
            .csrf { it.disable() }
            .authorizeHttpRequests { auth ->
                auth
                    .requestMatchers(
                        "/swagger-ui/**",
                        "/swagger-ui.html",
                        "/api-docs/**",
                        "/v3/api-docs/**"
                    ).permitAll()
                    .anyRequest().authenticated()
            }
            .build()
    }
}
More detail — Adding bearerAuth scheme for JWT environments

In a JWT-secured app, add a bearerAuth security scheme so the evaluator can paste a token directly in Swagger UI.

@Configuration
class OpenApiConfig {

    @Bean
    fun openAPI(): OpenAPI {
        val securityScheme = SecurityScheme()
            .type(SecurityScheme.Type.HTTP)
            .scheme("bearer")
            .bearerFormat("JWT")
            .`in`(SecurityScheme.In.HEADER)
            .name("Authorization")

        val securityRequirement = SecurityRequirement().addList("bearerAuth")

        return OpenAPI()
            .info(Info().title("Product API").version("v1.0.0"))
            .addSecurityItem(securityRequirement)
            .components(Components().addSecuritySchemes("bearerAuth", securityScheme))
    }
}

1.4 Aside: SpringDoc vs REST Docs — Decision Criteria

For a pre-interview task, SpringDoc is the right call. Setup is minimal and the Try it out feature lets the evaluator test APIs immediately without curl.

REST Docs generates documentation only when tests pass, enforcing code-doc synchronization. That guarantee matters in financial and public-sector APIs where accuracy is the core requirement.

CriterionSpringDoc (Swagger)REST Docs
Doc generationAnnotation-basedTest-based
Code-doc syncManualEnforced by failing tests
Runtime dependencyYes (ships in production)No (build-time only)
Try it outBuilt-inRequires separate setup
Learning curveLowHigh
Production code intrusionAnnotations requiredNone (test code only)
More detail — REST Docs dependencies, test code, and AsciiDoc template

Dependencies (build.gradle.kts)

plugins {
    id("org.asciidoctor.jvm.convert") version "4.0.5"
}

val asciidoctorExt: Configuration by configurations.creating
val snippetsDir by extra { file("build/generated-snippets") }

dependencies {
    asciidoctorExt("org.springframework.restdocs:spring-restdocs-asciidoctor")
    testImplementation("org.springframework.restdocs:spring-restdocs-mockmvc")
}

tasks.test { outputs.dir(snippetsDir) }

tasks.asciidoctor {
    inputs.dir(snippetsDir)
    configurations(asciidoctorExt.name)
    dependsOn(tasks.test)
}

tasks.register<Copy>("copyDocument") {
    dependsOn(tasks.asciidoctor)
    from(file("build/docs/asciidoc"))
    into(file("src/main/resources/static/docs"))
}

tasks.build { dependsOn("copyDocument") }

Test code (Kotlin)

@WebMvcTest(ProductController::class)
@AutoConfigureRestDocs
class ProductControllerDocsTest {

    @Autowired private lateinit var mockMvc: MockMvc
    @MockkBean private lateinit var productService: ProductService

    @Test
    @DisplayName("Get product detail API")
    fun findProductDetail() {
        val response = FindProductDetailResponse(1L, "Fresh Apple", 10000, ProductCategoryType.FOOD, true, LocalDateTime.now())
        every { productService.findProductDetail(1L) } returns response

        mockMvc.perform(get("/api/v1/products/{productId}", 1L).accept(MediaType.APPLICATION_JSON))
            .andExpect(status().isOk)
            .andDo(document("product-detail",
                pathParameters(parameterWithName("productId").description("Product ID")),
                responseFields(
                    fieldWithPath("code").description("Response code"),
                    fieldWithPath("message").description("Response message"),
                    fieldWithPath("data.id").description("Product ID"),
                    fieldWithPath("data.name").description("Product name"),
                    fieldWithPath("data.price").description("Price"),
                    fieldWithPath("data.category").description("Category"),
                    fieldWithPath("data.enabled").description("Enabled status"),
                    fieldWithPath("data.createdAt").description("Created at")
                )
            ))
    }
}

AsciiDoc template (src/docs/asciidoc/index.adoc)

= Product API Documentation
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 2

== Product API

=== Get Product Details

operation::product-detail[snippets='path-parameters,response-fields,curl-request,http-response']

2. Logging Strategy — SLF4J · MDC · Masking

2.1 Logback Configuration and Profile Splitting

Spring Boot’s default logging implementation is Logback — it ships with spring-boot-starter, no extra dependency needed.

For quick setup, configure levels and pattern in application.yml. Add logback-spring.xml when you need profile branching and rolling file policies.

The design carries three intentions. (1) <springProfile> splits log levels per environment so prod isn’t drowned in DEBUG noise. (2) The CONSOLE appender writes to stdout, letting the container log collector (Docker/Kubernetes) handle the rest. (3) The FILE appender rolls every 100 MB and keeps 30 days, capping disk usage. Together they pre-empt the two operational failures you actually run into: noisy production logs and a full disk.

# application.yml — basic logging
logging:
  level:
    root: INFO
    com.example.app: DEBUG
    org.hibernate.SQL: DEBUG
    org.hibernate.orm.jdbc.bind: TRACE
  pattern:
    console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{requestId}] %-5level %logger{36} - %msg%n"
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <springProfile name="local">
        <property name="LOG_LEVEL" value="DEBUG"/>
    </springProfile>
    <springProfile name="prod">
        <property name="LOG_LEVEL" value="INFO"/>
    </springProfile>

    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{requestId}] %-5level %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>

    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>logs/application.log</file>
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <fileNamePattern>logs/application.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
            <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
                <maxFileSize>100MB</maxFileSize>
            </timeBasedFileNamingAndTriggeringPolicy>
            <maxHistory>30</maxHistory>
        </rollingPolicy>
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{requestId}] %-5level %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>

    <root level="${LOG_LEVEL:-INFO}">
        <appender-ref ref="CONSOLE"/>
        <appender-ref ref="FILE"/>
    </root>

    <logger name="com.example.app" level="DEBUG"/>
    <logger name="org.hibernate.SQL" level="DEBUG"/>
</configuration>

Structured JSON logs — native support since Spring Boot 3.4

In production, anything feeding ELK/Loki/etc. wants JSON-formatted logs. Previously you wired in logstash-logback-encoder (or a similar encoder) inside logback-spring.xml. Since Spring Boot 3.4 you can flip it on with a single property — no extra dependency. Spring Boot 4.0 expanded the format options further.

logging:
  structured:
    format:
      console: logstash   # also: ecs, gelf — pick what your collector expects

You don’t need this for the task itself, but mentioning in the README that you considered the log-collection pipeline scores easy bonus points.

2.2 Log Level Selection

One-line rule: INFO for business events, DEBUG for debugging detail, ERROR only for things that need immediate action.

LevelPurposeExamples
ERRORNeeds immediate responseDB connection failure, external API outage
WARNPotential issue, monitorRetry triggered, threshold approaching
INFOKey business eventsOrder placed, payment succeeded
DEBUGDevelopment/debugging detailMethod entry, parameter values
TRACEVery fine-grained detailPer-iteration values inside loops

Applied per method, the table fixes a natural pattern — log parameters with debug right after entry, log the completed business event with info, and use warn for normal-but-attention branches like not-found. The ProductService below follows that pattern.

import org.springframework.data.repository.findByIdOrNull

@Service
class ProductService(private val productRepository: ProductRepository) {
    private val log = LoggerFactory.getLogger(javaClass)

    @Transactional
    fun registerProduct(command: RegisterProductCommand): Long {
        log.debug("Register product request: name={}, price={}", command.name, command.price)

        val saved = productRepository.save(Product(command.name, command.price, command.category))
        log.info("Product registered: productId={}", saved.id)

        return saved.id!!
    }

    fun findProductDetail(productId: Long): FindProductDetailResponse {
        log.debug("Find product: productId={}", productId)

        val product = productRepository.findByIdOrNull(productId)
            ?: run {
                log.warn("Product not found: productId={}", productId)
                throw NotFoundException()
            }

        return FindProductDetailResponse.from(product)
    }
}

Note: JpaRepository.findById(id) returns Optional<T>, so ?: run { … } won’t fire. Spring Data ships a Kotlin extension findByIdOrNull(id) that returns T?, which composes naturally with the elvis operator (?:). If you’d rather keep the Optional, use .orElseThrow { … } like the Java example.

More detail — ProductService logging example (Java)
@Slf4j
@Service
@RequiredArgsConstructor
public class ProductService {

    private final ProductRepository productRepository;

    @Transactional
    public Long registerProduct(RegisterProductCommand command) {
        log.debug("Register product request: name={}, price={}", command.name(), command.price());

        Product saved = productRepository.save(
            new Product(command.name(), command.price(), command.category()));
        log.info("Product registered: productId={}", saved.getId());

        return saved.getId();
    }

    public FindProductDetailResponse findProductDetail(Long productId) {
        log.debug("Find product: productId={}", productId);

        Product product = productRepository.findById(productId)
            .orElseThrow(() -> {
                log.warn("Product not found: productId={}", productId);
                return new NotFoundException();
            });

        return FindProductDetailResponse.from(product);
    }
}

2.3 MDC for Request Tracing

MDC (Mapped Diagnostic Context) = store key-value pairs in thread-local storage; Logback’s %X{key} pattern then stamps them on every log line automatically.

Assign a unique request ID per request, and you can grep all logs for a single request in one pass.

sequenceDiagram
    participant Client
    participant MdcFilter
    participant MDC
    participant Controller
    participant Service
    participant Logger

    Client->>MdcFilter: HTTP request (X-Request-ID: abc123)
    MdcFilter->>MDC: put("requestId", "abc123")
    MdcFilter->>Controller: filterChain.doFilter()
    Controller->>Service: business call
    Service->>Logger: log.info("Product registered: …")
    Logger-->>Client: [abc123] Product registered: … (auto-stamped)
    Note over MdcFilter: finally MDC.clear()

The MdcFilter below is the second box in that diagram. Extending OncePerRequestFilter guarantees one execution per request, and @Order(HIGHEST_PRECEDENCE) puts it ahead of every other filter so every downstream log line carries the requestId. The try-finally is the critical part: the response must call MDC.clear() — otherwise the next request reused on the same thread inherits a stale ID and writes it under the wrong correlation tag.

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
class MdcFilter : OncePerRequestFilter() {

    companion object {
        const val REQUEST_ID = "requestId"
    }

    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain
    ) {
        val requestId = request.getHeader("X-Request-ID")
            ?: UUID.randomUUID().toString().substring(0, 8)

        try {
            MDC.put(REQUEST_ID, requestId)
            response.setHeader("X-Request-ID", requestId)
            filterChain.doFilter(request, response)
        } finally {
            MDC.clear()
        }
    }
}
More detail — MdcFilter (Java)
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class MdcFilter extends OncePerRequestFilter {

    public static final String REQUEST_ID = "requestId";

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {

        String requestId = request.getHeader("X-Request-ID");
        if (requestId == null || requestId.isBlank()) {
            requestId = UUID.randomUUID().toString().substring(0, 8);
        }

        try {
            MDC.put(REQUEST_ID, requestId);
            response.setHeader("X-Request-ID", requestId);
            filterChain.doFilter(request, response);
        } finally {
            MDC.clear();
        }
    }
}

Note: MDC handles tracing within a single application. Cross-service distributed tracing (Zipkin, Jaeger, AWS X-Ray) requires infrastructure setup and is out of scope for pre-interview tasks.

2.4 Sensitive Data Masking

Rule: mask only at log output time. Business logic always operates on the original data.

object MaskingUtils {

    fun maskEmail(email: String?): String {
        if (email.isNullOrBlank()) return "***"
        val atIndex = email.indexOf('@')
        if (atIndex <= 1) return "***"
        return email.substring(0, 2) + "***" + email.substring(atIndex)
    }

    fun maskPhone(phone: String?): String {
        if (phone.isNullOrBlank() || phone.length < 4) return "***"
        return phone.substring(0, 3) + "****" + phone.takeLast(4)
    }

    fun maskCardNumber(cardNumber: String?): String {
        if (cardNumber.isNullOrBlank() || cardNumber.length < 4) return "***"
        return "*".repeat(cardNumber.length - 4) + cardNumber.takeLast(4)
    }
}

At the call site, business logic still works against the raw member object — only the arguments going into log.info(...) are wrapped through MaskingUtils. The example below shows that shape.

// Usage
fun findMemberDetail(memberId: Long): MemberDetailResponse {
    val member = memberRepository.findById(memberId)
        .orElseThrow { NotFoundException("Member not found") }

    log.info(
        "Member lookup complete: memberId={}, email={}, phone={}",
        member.id,
        MaskingUtils.maskEmail(member.email),   // ho***@example.com
        MaskingUtils.maskPhone(member.phone)    // 010****1234
    )

    return MemberDetailResponse.from(member)
}

2.5 Aside: Logging Performance Myths

Myth 1 — String concatenation equals {} placeholders

// Anti-pattern — concatenation runs even when DEBUG is off
log.debug("User " + userId + " requested " + itemCount + " items")

// Correct — concatenation is skipped when DEBUG is disabled
log.debug("User {} requested {} items", userId, itemCount)

Myth 2 — isDebugEnabled() guards are always needed

Guards are only necessary in two situations.

// Case 1 — expensive toString()
if (log.isDebugEnabled) {
    log.debug("Request details: {}", expensiveJsonSerialization(request))
}

// Case 2 — logging inside a loop
items.forEach { item ->
    if (log.isDebugEnabled) {
        log.debug("Processing item: {}", item.toDetailString())
    }
}

// Unnecessary — {} defers evaluation already
if (log.isDebugEnabled) {
    log.debug("User {} logged in", userId)  // guard adds no value here
}

3. AOP — Separating Cross-Cutting Concerns

AOP (Aspect-Oriented Programming) is a technique for pulling non-business code — logging, transactions, retries, authorization checks — that wraps around business methods into separate objects. The “code that wraps around many methods” is called a cross-cutting concern.

The pain point is how often this code shows up. Adding entry/exit logs to a Service with 30 methods means 30 log.info(...) lines at each method’s start and end — 60 lines of boilerplate that have nothing to do with the actual business logic. Changing the log format means editing 30 places at once.

// Without AOP — the same pattern repeats in every method
fun registerProduct(...): Long {
    log.info("[START] registerProduct")                       // ← boilerplate
    val start = System.currentTimeMillis()                    // ← boilerplate
    try {
        val saved = repository.save(...)                      // ← actual business logic
        log.info("[END] registerProduct - {}ms", elapsed())   // ← boilerplate
        return saved.id!!
    } catch (e: Exception) {
        log.error("[ERROR] registerProduct - {}", e.message)  // ← boilerplate
        throw e
    }
}

// With AOP — only the business logic remains
@Loggable   // ← one annotation handles start/end/error logs across all 30 methods
fun registerProduct(...): Long {
    val saved = repository.save(...)
    return saved.id!!
}

@Loggable is a hypothetical annotation here, but the real Aspects we’ll build in §3.2–§3.5 (RequestLoggingAspect, @ExecutionTime, @Retry) follow exactly this shape. Changing the log format means editing the Aspect once.

Core terms

TermDefinition
AspectA class collecting cross-cutting concerns (declared with @Aspect)
JoinPointA point where Advice can run. In Spring AOP, the only JoinPoint is method execution
PointcutAn expression that picks which JoinPoints get Advice (@annotation(...), within(...), etc.)
AdviceThe action to run at a JoinPoint: @Before, @Around, @AfterReturning, @AfterThrowing
WeavingWiring Aspects into target beans. Spring weaves at runtime by generating proxies

Spring AOP is runtime-proxy based. When the context starts, Spring wraps each target bean in either a CGLIB subclass (for classes) or a JDK Dynamic Proxy (for interfaces), and hands those proxies — not the originals — to other beans during injection. Method calls that match a Pointcut are intercepted by the proxy, which runs the Advice and then delegates to the original method. This “must go through the proxy” rule is the root cause of the self-invocation trap in §3.4.

3.1 Filter vs Interceptor vs AOP — Decision Criteria

All three handle cross-cutting logic, but their application boundaries differ. Putting logic in the wrong layer creates dependency leaks or loses access to Spring context.

flowchart TB
    Client([Client])

    subgraph Servlet["Servlet Container"]
        Filter["Filter Chain\n(encoding, CORS, MDC, auth)"]
    end

    subgraph Spring["Spring MVC"]
        DS["DispatcherServlet"]
        Inter["Interceptor\n(preHandle / postHandle)"]
        Ctrl["Controller\n(@RestController)"]
        AOP["AOP Advice\n(@Around, @Before, @AfterReturning)"]
        Svc["Service / Repository"]
    end

    Client --> Filter --> DS --> Inter --> Ctrl
    Ctrl --> AOP --> Svc
TypeScopeFires whenTypical use
FilterServletBefore/after DispatcherServletEncoding, CORS, MDC, token parsing
InterceptorSpring MVCBefore/after ControllerAuth checks, common response headers
AOPSpring BeanBefore/after methodTransactions, execution timing, retry

Decision rules in one line each:

  • Dealing with the raw HTTP request/response → Filter
  • Pre/post processing around the Controller → Interceptor
  • Logic inside Service or Repository → AOP

3.2 Request/Response Logging Aspect

MDC already stamps the request ID, so RequestLoggingAspect only needs to capture method name, URI, and duration on a single line. [REQUEST]/[RESPONSE]/[ERROR] prefixes make grep straightforward.

@Aspect
@Component
class RequestLoggingAspect {

    private val log = LoggerFactory.getLogger(javaClass)
    private val jsonWriter = ObjectMapper().apply {
        registerModule(JavaTimeModule())
        configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false)
    }

    @Pointcut("within(@org.springframework.web.bind.annotation.RestController *)")
    fun restController() {}

    @Around("restController()")
    fun logAround(joinPoint: ProceedingJoinPoint): Any? {
        val request = (RequestContextHolder.getRequestAttributes() as? ServletRequestAttributes)?.request
        val className = joinPoint.target.javaClass.simpleName
        val methodName = joinPoint.signature.name

        log.info("[REQUEST] {} {} - {}.{}", request?.method, request?.requestURI, className, methodName)

        if (log.isDebugEnabled) {
            val args = joinPoint.args.filterNotNull()
                .filter { it !is HttpServletRequest && it !is HttpServletResponse }
            if (args.isNotEmpty()) log.debug("[REQUEST BODY] {}", toJson(args))
        }

        val startTime = System.currentTimeMillis()

        return try {
            val result = joinPoint.proceed()
            val duration = System.currentTimeMillis() - startTime
            log.info("[RESPONSE] {} {} - {}ms", request?.method, request?.requestURI, duration)
            if (log.isDebugEnabled && result != null) log.debug("[RESPONSE BODY] {}", toJson(result))
            result
        } catch (e: Exception) {
            val duration = System.currentTimeMillis() - startTime
            log.error("[ERROR] {} {} - {}ms - {}", request?.method, request?.requestURI, duration, e.message)
            throw e
        }
    }

    private fun toJson(obj: Any): String = try {
        jsonWriter.writeValueAsString(obj)
    } catch (e: Exception) {
        obj.toString()
    }
}
More detail — RequestLoggingAspect (Java)
@Aspect
@Component
@Slf4j
public class RequestLoggingAspect {

    private final ObjectMapper jsonWriter;

    public RequestLoggingAspect() {
        this.jsonWriter = new ObjectMapper();
        this.jsonWriter.registerModule(new JavaTimeModule());
        this.jsonWriter.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false);
    }

    @Pointcut("within(@org.springframework.web.bind.annotation.RestController *)")
    public void restController() {}

    @Around("restController()")
    public Object logAround(ProceedingJoinPoint joinPoint) throws Throwable {
        HttpServletRequest request = ((ServletRequestAttributes)
            RequestContextHolder.getRequestAttributes()).getRequest();
        String className = joinPoint.getTarget().getClass().getSimpleName();
        String methodName = joinPoint.getSignature().getName();

        log.info("[REQUEST] {} {} - {}.{}",
            request.getMethod(), request.getRequestURI(), className, methodName);

        long startTime = System.currentTimeMillis();

        try {
            Object result = joinPoint.proceed();
            long duration = System.currentTimeMillis() - startTime;
            log.info("[RESPONSE] {} {} - {}ms",
                request.getMethod(), request.getRequestURI(), duration);
            return result;
        } catch (Exception e) {
            long duration = System.currentTimeMillis() - startTime;
            log.error("[ERROR] {} {} - {}ms - {}",
                request.getMethod(), request.getRequestURI(), duration, e.getMessage());
            throw e;
        }
    }
}

3.3 Execution Time Aspect

Mark any method with @ExecutionTime to log its runtime. When duration exceeds 1000ms, a WARN fires so slow methods surface immediately.

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class ExecutionTime
@Aspect
@Component
class ExecutionTimeAspect {

    private val log = LoggerFactory.getLogger(javaClass)

    @Around("@annotation(com.example.app.common.annotation.ExecutionTime)")
    fun measureExecutionTime(joinPoint: ProceedingJoinPoint): Any? {
        val className = joinPoint.target.javaClass.simpleName
        val methodName = joinPoint.signature.name
        val startTime = System.currentTimeMillis()

        return try {
            joinPoint.proceed()
        } finally {
            val duration = System.currentTimeMillis() - startTime
            log.info("[EXECUTION TIME] {}.{} - {}ms", className, methodName, duration)
            if (duration > 1000) {
                log.warn("[SLOW EXECUTION] {}.{} took {}ms", className, methodName, duration)
            }
        }
    }
}
// Usage
@Service
class ProductService(private val productRepository: ProductRepository) {

    @ExecutionTime
    fun findAllProducts(): List<FindProductResponse> {
        return productRepository.findAll().map { FindProductResponse.from(it) }
    }
}

3.4 Retry Aspect and the Self-Invocation Trap

External API calls and transient network failures are the classic “fails once, succeeds the second time” scenario. Embedding the retry logic inside business methods as try-catch blocks repeats the same pattern across every callsite and buries the real logic. Pulling it out behind an annotation is one of AOP’s most natural fits.

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class Retry(
    val maxAttempts: Int = 3,
    val delay: Long = 1000
)
@Aspect
@Component
class RetryAspect {

    private val log = LoggerFactory.getLogger(javaClass)

    @Around("@annotation(retry)")
    fun retry(joinPoint: ProceedingJoinPoint, retry: Retry): Any? {
        val methodName = joinPoint.signature.name
        var lastException: Exception? = null

        repeat(retry.maxAttempts) { attempt ->
            try {
                if (attempt > 0) {
                    log.info("[RETRY] {} - attempt {}/{}", methodName, attempt + 1, retry.maxAttempts)
                }
                return joinPoint.proceed()
            } catch (e: Exception) {
                lastException = e
                log.warn("[RETRY FAILED] {} - attempt {}/{} - {}",
                    methodName, attempt + 1, retry.maxAttempts, e.message)
                if (attempt < retry.maxAttempts - 1) Thread.sleep(retry.delay)
            }
        }

        log.error("[RETRY EXHAUSTED] {} after {} attempts", methodName, retry.maxAttempts)
        throw lastException!!
    }
}

The self-invocation trap — this is a common interview question

Spring AOP is proxy-based. When a method calls another method on the same class, it bypasses the proxy, so the advice never fires.

@Service
class ExternalApiService {

    fun callWithFallback() {
        callApi()  // AOP NOT applied — direct call, no proxy
    }

    @Retry(maxAttempts = 3)
    fun callApi() {
        // external API call
    }
}

Three workarounds:

ApproachExampleRecommended
Extract to a separate beanCreate an ApiCaller bean and inject itYes — cleanest
AopContext.currentProxy()(AopContext.currentProxy() as ExternalApiService).callApi()No — pollutes code
Self-injection@Autowired private lateinit var self: ExternalApiServiceUse with care (same pattern as @Transactional)

In production code, prefer Spring Retry (@Retryable) or Resilience4j over a hand-rolled RetryAspect — they provide exception-type filtering, backoff strategies, and Circuit Breaker integration out of the box.

3.5 Aside: Transaction Logging Aspect and @Transactional Limits

@Transactional opens, commits, and rolls back transactions, but it doesn’t record any of that in the log. In production, when you need to confirm “did this request open a transaction, did it commit, did it roll back?”, you have to add the logs yourself. An Aspect that wraps @Transactional methods with START/COMMIT/ROLLBACK markers gives you that visibility.

Combining @Before, @AfterReturning, and @AfterThrowing lets you trace each transaction boundary:

@Aspect
@Component
class TransactionLoggingAspect {

    private val log = LoggerFactory.getLogger(javaClass)

    @Before("@annotation(transactional)")
    fun logTransactionStart(joinPoint: JoinPoint, transactional: Transactional) {
        val readOnly = if (transactional.readOnly) "(readOnly)" else ""
        log.debug("[TX START{}] {}", readOnly, joinPoint.signature.name)
    }

    @AfterReturning("@annotation(org.springframework.transaction.annotation.Transactional)")
    fun logTransactionCommit(joinPoint: JoinPoint) {
        log.debug("[TX COMMIT] {}", joinPoint.signature.name)
    }

    @AfterThrowing(
        pointcut = "@annotation(org.springframework.transaction.annotation.Transactional)",
        throwing = "ex"
    )
    fun logTransactionRollback(joinPoint: JoinPoint, ex: Exception) {
        log.warn("[TX ROLLBACK] {} - {}", joinPoint.signature.name, ex.message)
    }
}

Note: This Aspect wraps the annotated method, not the actual DB transaction boundary. Spring’s TransactionInterceptor decides the real commit/rollback, so these log lines don’t always align exactly with the DB transaction. For precise TX event hooks, use TransactionSynchronizationManager.


Recap

  • Minimal Swagger is the goal@Tag, @Operation(summary), and Security path allowance so the evaluator can test immediately. Nothing more is needed to score well.
  • Profile-split Logback is the baseline — local runs DEBUG, prod runs INFO. <springProfile> in logback-spring.xml handles the switch without code changes.
  • MDC completes single-app request tracingMdcFilter sets the request ID; %X{requestId} stamps it on every line. Distributed tracing is out of scope.
  • AOP for cross-cutting only — logging, execution timing, and retry are the right targets. Business branching in AOP produces code that cannot be traced or debugged.
  • Self-invocation is a proxy question — the answer is always “the call bypasses the proxy.” The fix is to extract the behavior into a separate Spring bean.

The next parts cover authentication/authorization, validation, and back-end performance topics. The observability layer established here — structured logging, request tracing, and aspect-based instrumentation — makes the performance and security arguments much more credible when they arrive.


Appendix

Pre-Submission Quick Checklist

  • Is Swagger UI reachable at /swagger-ui.html?
  • If using Spring Security, are /swagger-ui/** and /v3/api-docs/** permitted?
  • Do logs include a request ID for tracing?
  • Are sensitive fields (passwords, card numbers, etc.) masked before logging?
  • Are log levels used correctly across ERROR / WARN / INFO / DEBUG?
  • If AOP is used, is it explained in the README?
  • Are there any self-invocation cases? If so, is the dependency extracted to a separate bean?

What Scores Points vs What to Skip

ItemEffectWorth doing
Swagger UI accessibleEvaluator can test immediatelyYes
Request ID logging (MDC)Traceable logsYes
Execution time logging AOPDemonstrates performance awarenessYes
API versioning (/v1/)Shows scalability thinkingYes
Detailed @Schema on every fieldLow ROI for time spentNo
REST Docs setupOverkill for task scopeNo
Distributed tracing (Zipkin, etc.)Infrastructure required, out of scopeNo
100% test coverageCore feature completeness matters moreNo

File Structure Example

src/main/kotlin/com/example/app/
├── common/
│   ├── annotation/
│   │   ├── ExecutionTime.kt
│   │   └── Retry.kt
│   ├── aop/
│   │   ├── RequestLoggingAspect.kt
│   │   ├── ExecutionTimeAspect.kt
│   │   ├── RetryAspect.kt
│   │   └── TransactionLoggingAspect.kt
│   ├── filter/
│   │   └── MdcFilter.kt
│   └── util/
│       └── MaskingUtils.kt
├── config/
│   ├── OpenApiConfig.kt
│   └── SecurityConfig.kt
└── ...
Shop on Amazon

As an Amazon Associate, I earn from qualifying purchases.