soda docs v17.0.1

Web Layer

soda-core contains no Web code at all — it is completely oblivious to Spring MVC. The way to wire in the Web layer: you write the Controllers yourself, wrap responses with soda's Result / PageResult, and use one @Aspect aspect to carry identity information from request headers into the Command / Query.

This section is about that aspect — usually called ControllerAroundHandler.

Constants: request header names

soda defines every request header name it uses in com.hibuka.soda.foundation.constant.BaseConstant:

java
public class BaseConstant {
    public static final String X_REQUEST_ID = "X-Request-Id";
    public static final String SODA_REQUEST_ID = "Soda-Request-Id";
    public static final String X_UID = "X-UID";
    public static final String X_USER_NAME = "X-User-Name";
    public static final String SODA_USER_NAME = "Soda-User-Name";
    public static final String X_AUTHORITIES = "X-Authorities";
    public static final String X_JTI = "X-Jti";
    public static final String X_CALLER_UID = "X-Caller-Uid";
    public static final String SCADA = "scada";
    public static final String X_TENANT_ID = "X-Tenant-Id";
}

The mapping:

ConstantHeaderMeaning
SODA_REQUEST_IDSoda-Request-Idsoda's own trace ID, injected by the caller or gateway
SODA_USER_NAMESoda-User-Namesoda's own user name header
X_REQUEST_IDX-Request-Idgeneric trace ID
X_UIDX-UIDuser UID
X_USER_NAMEX-User-Nameuser name
X_AUTHORITIESX-Authoritiesauthorities string
X_JTIX-JtiJWT token id
X_CALLER_UIDX-Caller-Uidcaller UID
X_TENANT_IDX-Tenant-Idtenant ID
SCADAscadaa constant value, not a header
Tip

The two Soda--prefixed headers are soda's convention for headers "consumed by the framework itself" — the gateway injects them and business code needn't care. The X--prefixed ones are generic convention; you can fill them uniformly in your own gateway / interceptor.

Uniform responses: Result and PageResult

Every Controller returns Result<T> or PageResult<T>.

Result<T> fields:

FieldTypeDefaultDescription
requestIdStringnulltrace ID
codeInteger200response code
messageString""response message; empty string means success
dataTnullbusiness data
successBooleantruesuccess flag
errorDataMap<String, Object>empty Maperror details, present only on failure

Result factory methods:

MethodDescription
Result.success(data)success, message is empty string
Result.success(data, message)success with a message
Result.success(data, code, message)success with a custom code
Result.successWithRequestId(data, requestId)success with requestId
Result.successWithRequestId(data, message, requestId)success with message and requestId
Result.successWithRequestId(data, code, message, requestId)success, everything specified
Result.fail(code, message)failure, no data
Result.fail(data, code, message)failure with data
Result.failWithRequestId(data, code, message, requestId)failure with requestId
Result.fail(errorCode)failure, error code is an IErrorCode
Result.fail(data, errorCode)failure with data
Result.failWithRequestId(data, errorCode, requestId)failure with requestId

Result.setRequestId(...) returns this, so it chains.

PageResult<T> extends Result<T>, adding eight paging fields: total, pageNo, pageSize, pages, hasNext, hasPrevious, startIndex, endIndex.

PageResult.success takes parameters in the order (data, total, pageNo, pageSize):

java
PageResult.success(dto.getList(), dto.getTotal(), query.getPageNumberWithDefault(), query.getPageSizeWithDefault());

Controller

A Controller does only three things: bind parameters, call the application service, wrap the response.

java
package com.example.demo.adapter.inbound.web;

import com.example.demo.adapter.inbound.web.dto.ProducerDTO;
import com.example.demo.application.cqrs.command.CreateProducerCommand;
import com.example.demo.application.cqrs.query.GetProducerQuery;
import com.example.demo.application.cqrs.query.ListProducerQuery;
import com.example.demo.application.sample.service.ProducerAppService;
import com.hibuka.soda.foundation.error.BaseException;
import com.hibuka.soda.foundation.io.PageResult;
import com.hibuka.soda.foundation.io.PageVO;
import com.hibuka.soda.foundation.io.Result;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.concurrent.CompletableFuture;

@RestController
@RequestMapping("api")
@RequiredArgsConstructor
public class ProducerController {
    private final ProducerAppService producerAppService;

    @Operation(summary = "Create aggregate root", description = "creatProducer create endpoint description")
    @ApiResponse(responseCode = "200", description = "response body",
            content = @Content(mediaType = "application/json", schema = @Schema(allOf = Result.class)))
    @PostMapping("creatProducer")
    public Result<ProducerDTO> creatProducer(@RequestBody CreateProducerCommand command) throws BaseException {
        ProducerDTO dto = producerAppService.creatProducer(command);
        return Result.success(dto);
    }

    @Operation(summary = "Create aggregate root asynchronously")
    @PostMapping("creatProducerSync")
    public CompletableFuture<Result<ProducerDTO>> creatProducerSync(@RequestBody CreateProducerCommand command) throws BaseException {
        return producerAppService.creatProducerSyn(command).thenApply(Result::success);
    }

    @Operation(summary = "Get aggregate root")
    @PostMapping("getProducer")
    public Result<ProducerDTO> getProducer(@RequestBody GetProducerQuery query) throws BaseException {
        ProducerDTO dto = producerAppService.getProducer(query);
        return Result.success(dto);
    }

    @Operation(summary = "List aggregate roots")
    @PostMapping("listProducers")
    public PageResult<List<ProducerDTO>> listProducer(@RequestBody ListProducerQuery query) throws BaseException {
        PageVO<ProducerDTO> dto = producerAppService.listProducer(query);
        return PageResult.success(dto.getList(), dto.getTotal(),
                query.getPageNumberWithDefault(), query.getPageSizeWithDefault());
    }
}

Two points worth noting:

  • Command / Query bind directly as @RequestBody — they extend BaseCommand / BaseQuery, so Jackson deserializes them fine.
  • Write throws BaseException. BaseException is a RuntimeException subclass, so it compiles either way; writing it expresses more clearly that "this endpoint may return a business failure".

ControllerAroundHandler: moving request headers into the Command

This is the heart of the Web layer wiring. One @Aspect aspect does four things:

  1. Reads requestId and userName from request headers
  2. Writes them into the Command / Query object
  3. Writes them into MDC so logs carry trace information
  4. Logs latency uniformly and converts exceptions to Result

Full code:

java
package com.example.demo.adapter.inbound.web.advice;

import com.alibaba.fastjson.JSON;
import com.hibuka.soda.foundation.constant.BaseConstant;
import com.hibuka.soda.foundation.error.BaseErrorCode;
import com.hibuka.soda.foundation.error.BaseException;
import com.hibuka.soda.foundation.io.BaseRequest;
import com.hibuka.soda.foundation.io.PageResult;
import com.hibuka.soda.foundation.io.Result;
import com.hibuka.soda.cqrs.command.BaseCommand;
import com.hibuka.soda.cqrs.query.BaseQuery;
import jakarta.servlet.http.HttpServletRequest;
import org.apache.commons.lang3.ArrayUtils;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import java.util.UUID;
import java.util.concurrent.CompletableFuture;

@Aspect
@Component
public class ControllerAroundHandler {
    private static final Logger logger = LoggerFactory.getLogger(ControllerAroundHandler.class);
    private static final String LOG_PATTERN = "requestId:{},method:{}-{}, {}:{}, result:{}, duration:{},username:{}";

    @Around(value = "execution(* com.example.demo.adapter.inbound.web..*.*(..))")
    public Object handleController(ProceedingJoinPoint point) throws Throwable {
        if (!shouldProcess(point)) {
            return point.proceed();
        }

        ServletRequestAttributes requestAttributes =
                (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        if (requestAttributes == null) {
            return point.proceed();
        }

        return processRequest(point, point.getArgs()[0], requestAttributes);
    }
    ...
}
Warning

The package name in the pointcut execution(* com.example.demo.adapter.inbound.web..*.*(..)) must match your actual package. If it's wrong the aspect simply never applies — no error, just requestId always being a random UUID and MDC staying empty. After renaming packages, remember to come back and fix this pointcut.

Filtering which methods to process

Not every Controller method should be handled by this aspect. Three filters:

java
private boolean shouldProcess(ProceedingJoinPoint point) {
    // 1. validate return type
    MethodSignature signature = (MethodSignature) point.getSignature();
    if (!isValidReturnType(signature.getReturnType())) {
        return false;
    }

    // 2. validate arguments
    Object[] args = point.getArgs();
    if (ArrayUtils.isEmpty(args)) {
        return false;
    }

    // 3. validate the first argument's type
    return isValidArg(args[0]);
}

private boolean isValidReturnType(Class<?> returnType) {
    if (Result.class.isAssignableFrom(returnType) || PageResult.class.isAssignableFrom(returnType)) {
        return true;
    }
    // handle generic wrapper types, e.g. CompletableFuture<Result<T>>
    if (CompletableFuture.class.isAssignableFrom(returnType)) {
        return true;
    }
    return false;
}

private boolean isValidArg(Object arg) {
    return arg instanceof BaseCommand
            || arg instanceof BaseQuery
            || arg instanceof BaseRequest;
}

BaseRequest is an ordinary request base class soda provides (five fields: requestId, jti, authorities, userName, callerUid), for plain input parameters that are neither Command nor Query.

Processing the request

java
private Object processRequest(ProceedingJoinPoint point, Object firstArg,
                              ServletRequestAttributes requestAttributes) throws Throwable {
    MethodSignature signature = (MethodSignature) point.getSignature();
    Class<?> returnType = signature.getReturnType();
    HttpServletRequest request = requestAttributes.getRequest();

    final String requestId;
    String tempRequestId = request.getHeader(BaseConstant.SODA_REQUEST_ID);
    if (tempRequestId == null || tempRequestId.isEmpty()) {
        requestId = UUID.randomUUID().toString();
    } else {
        requestId = tempRequestId;
    }
    String username = request.getHeader(BaseConstant.SODA_USER_NAME);

    // set request info
    setRequestInfo(firstArg, requestId, username);

    // process the request and log
    long startNanos = System.nanoTime();
    Object result = null;

    MDC.put("requestId", requestId);
    MDC.put("username", username);
    try {
        result = point.proceed();
        long durationMs = (System.nanoTime() - startNanos) / 1_000_000L;
        logSuccess(requestId, signature, firstArg, result, durationMs, username);

        // handle CompletableFuture return types
        if (result instanceof CompletableFuture) {
            return ((CompletableFuture<?>) result).thenApply(res -> {
                setRequestId(res, requestId);
                return res;
            });
        }
    } catch (Exception e) {
        result = handleException(e, returnType);
        long durationMs = (System.nanoTime() - startNanos) / 1_000_000L;
        logError(requestId, signature, firstArg, result, durationMs, username, e);
    } finally {
        MDC.remove("requestId");
        MDC.remove("username");
    }

    return setRequestId(result, requestId);
}

The key points:

  • requestId comes from the request header first. If there is no Soda-Request-Id, a random UUID is generated. So on cross-service calls, the ID injected by the gateway travels all the way through.
  • MDC is cleaned up in finally. Without that, when a thread pool reuses threads, the previous request's requestId leaks into the next request's logs.
  • Async returns are handled separately. A CompletableFuture's result is produced on another thread, so the requestId is patched in inside thenApply.

Writing into the request object

java
private void setRequestInfo(Object firstArg, String requestId, String username) {
    if (firstArg instanceof BaseCommand) {
        BaseCommand cmd = (BaseCommand) firstArg;
        cmd.setRequestId(requestId);
        cmd.setUserName(username);
    } else if (firstArg instanceof BaseQuery) {
        BaseQuery query = (BaseQuery) firstArg;
        query.setRequestId(requestId);
        query.setUserName(username);
    } else if (firstArg instanceof BaseRequest) {
        BaseRequest request = (BaseRequest) firstArg;
        request.setRequestId(requestId);
        request.setUserName(username);
    }
}

Once written, soda's CqrsAroundHandler moves these values into DomainEventContext, and RepositoryEventAspect injects them into domain events. The chain is connected.

Tip

When you need to pass more identity information (authorities, jti, callerUid, tenantId), just read and write them here too — BaseCommand and BaseQuery both carry those fields.

Exception mapping

java
private Object handleException(Exception e, Class<?> returnType) {
    String errorCode;
    String message;
    boolean showDetail = true;

    if (e instanceof BaseException) {
        // business exception: return as-is
        BaseException baseException = (BaseException) e;
        errorCode = String.valueOf(baseException.getCode());
        message = baseException.getMessage();
    } else {
        // system exception: hide internal details
        errorCode = String.valueOf(BaseErrorCode.SYSTEM_ERROR.getCode());
        message = "Internal server error, please try again later";
        showDetail = false;
    }

    if (Result.class.isAssignableFrom(returnType)) {
        return Result.fail(Integer.parseInt(errorCode), message);
    } else if (PageResult.class.isAssignableFrom(returnType)) {
        return PageResult.fail(Integer.parseInt(errorCode), message);
    } else {
        return Result.fail(Integer.parseInt(errorCode), message);
    }
}

The rules are simple:

Exception typecodemessage
BaseExceptionthe exception's own codethe exception's own message
anything else10000 (SYSTEM_ERROR)"Internal server error, please try again later"
Important

System exceptions never expose the original message to the client. This is a hard requirement — original messages often contain SQL statements, file paths, class names. Details go into server-side logs only.

Logging

java
private void logSuccess(String requestId, MethodSignature signature, Object firstArg,
                        Object result, long durationMs, String username) {
    String argJson = safeJson(firstArg, true);
    String resultJson = safeJson(result, true);
    logger.info(LOG_PATTERN, requestId, signature.getDeclaringTypeName(), signature.getName(),
            firstArg.getClass().getSimpleName(), argJson, resultJson, durationMs, username);
}

private void logError(String requestId, MethodSignature signature, Object firstArg,
                      Object result, long durationMs, String username, Exception e) {
    String argJson = safeJson(firstArg, false);
    String resultJson = safeJson(result, false);
    logger.error(LOG_PATTERN, requestId, signature.getDeclaringTypeName(), signature.getName(),
            firstArg.getClass().getSimpleName(), argJson, resultJson, durationMs, username, e);
}

Log format: requestId:{},method:{}-{}, {}:{}, result:{}, duration:{},username:{}.

safeJson does two things: short-circuits by log level (if the level isn't enabled it doesn't serialize), and truncates oversized content:

java
private String safeJson(Object obj, boolean forInfo) {
    if (obj == null) {
        return "null";
    }
    boolean enabled = forInfo ? logger.isInfoEnabled() : logger.isErrorEnabled();
    if (!enabled) {
        return "";
    }
    String json = JSON.toJSONString(obj);
    int maxLen = 8192;
    if (json.length() <= maxLen) {
        return json;
    }
    int head = 4096;
    int tail = 256;
    if (json.length() <= head + tail) {
        return json.substring(0, Math.min(json.length(), head));
    }
    return json.substring(0, head) + "...<truncated>..." + json.substring(json.length() - tail);
}

Beyond 8192 characters it keeps only the first 4096 and last 256, marking the middle <truncated>. This stops one huge response body from flooding the logs.

Warning

safeJson uses com.alibaba.fastjson.JSON. fastjson 1.x has known historical CVEs; if your security scan flags it, swap in Jackson's ObjectMapper. soda-core itself depends on fastjson 1.2.83 (the fastjson-version property), but in the Web layer you're free to replace it.

Patching in requestId

java
private Object setRequestId(Object result, String requestId) {
    if (result instanceof Result) {
        ((Result) result).setRequestId(requestId);
    } else if (result instanceof PageResult) {
        ((PageResult) result).setRequestId(requestId);
    }
    return result;
}

Carrying requestId in the response body means a client reporting a problem can hand you that value directly.

MDC log format

The aspect puts requestId and username into MDC. You can reference them in your logback config:

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

%X{requestId} is the value from MDC. soda's CQRS aspect logs too, in the format requestId:{},method:{}-{}, ..., also carrying requestId.

GlobalExceptionHandler: the fallback outside the aspect

ControllerAroundHandler already handles exceptions inside the aspect, but some exceptions occur outside it (parameter binding failures, for example). @RestControllerAdvice is the fallback:

java
package com.example.demo.adapter.inbound.web.advice;

import com.hibuka.soda.foundation.error.BaseErrorCode;
import com.hibuka.soda.foundation.error.BaseException;
import com.hibuka.soda.foundation.io.Result;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import org.springframework.web.HttpMediaTypeNotSupportedException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.servlet.NoHandlerFoundException;

import java.util.List;
import java.util.stream.Collectors;

@RestControllerAdvice(basePackages = "com.example.demo.adapter.inbound.web")
public class GlobalExceptionHandler {
    private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    @ExceptionHandler(BaseException.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public Result<Object> handleBaseException(BaseException e) {
        logger.error("Business Exception: {}", e.getMessage(), e);
        return Result.fail(e.getCode(), e.getMessage());
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Result<Object> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
        List<String> errors = e.getBindingResult().getFieldErrors().stream()
                .map(FieldError::getDefaultMessage).collect(Collectors.toList());
        String message = String.join(", ", errors);
        logger.warn("Method Argument Not Valid: {}", message);
        return Result.fail(HttpStatus.BAD_REQUEST.value(), message);
    }

    @ExceptionHandler(BindException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Result<Object> handleBindException(BindException e) { ... }

    @ExceptionHandler(MissingServletRequestParameterException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Result<Object> handleMissingServletRequestParameterException(MissingServletRequestParameterException e) { ... }

    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Result<Object> handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException e) { ... }

    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
    @ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED)
    public Result<Object> handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e) { ... }

    @ExceptionHandler(HttpMediaTypeNotSupportedException.class)
    @ResponseStatus(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
    public Result<Object> handleHttpMediaTypeNotSupportedException(HttpMediaTypeNotSupportedException e) { ... }

    @ExceptionHandler(NoHandlerFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public Result<Object> handleNoHandlerFoundException(NoHandlerFoundException e) { ... }

    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public Result<Object> handleException(Exception e) {
        logger.error("Unexpected Exception: {}", e.getMessage(), e);
        // system exceptions never show detailed error info to the client
        return Result.fail(BaseErrorCode.SYSTEM_ERROR.getCode(), "Internal server error, please try again later");
    }
}

Note the layering here:

ExceptionHTTP statusResponse code
BaseException500the exception's own code
MethodArgumentNotValidException / BindException400400
MissingServletRequestParameterException400400
MethodArgumentTypeMismatchException400400
HttpRequestMethodNotSupportedException405405
HttpMediaTypeNotSupportedException415415
NoHandlerFoundException404404
anything else50010000 (SYSTEM_ERROR)
Note

BaseException also returns HTTP 500. If you want business errors to return 400, change @ResponseStatus — but be aware this affects how every monitoring alert is grouped.

basePackages must be correct, or this Advice never applies at all.

Package name changes in Spring Boot 4

If your project is on Spring Boot 4.x, the starter package names changed:

Spring Boot 3Spring Boot 4
spring-boot-starter-webspring-boot-starter-webmvc
spring-boot-starter-aopspring-boot-starter-aspectj
spring-boot-starter-jsonspring-boot-starter-jackson

soda-core 17.0.1 already switched to depending on spring-boot-starter-aspectj, so if your project still uses spring-boot-starter-aop, double-check the aspect-related dependencies.

The complete request chain

text
HTTP POST /api/creatProducer
  Header: Soda-Request-Id: abc-123
          Soda-User-Name: alice
  Body:   {"name": "Production Workshop"}
  │
  ├─ ControllerAroundHandler
  │    ├─ reads Soda-Request-Id → requestId = "abc-123"
  │    ├─ reads Soda-User-Name → username = "alice"
  │    ├─ cmd.setRequestId("abc-123") / cmd.setUserName("alice")
  │    ├─ MDC.put("requestId", ...) / MDC.put("username", ...)
  │    └─ proceed()
  │
  ├─ Controller: binds CreateProducerCommand, calls the application service
  │
  ├─ AppService: busFacade.sendTransactCommand(command)
  │
  ├─ CqrsAroundHandler (intercepts CommandBus.send)
  │    ├─ moves requestId / userName / jti / authorities into DomainEventContext
  │    └─ proceed()
  │
  ├─ CommandHandler.handle(command)
  │    ├─ ParamChecker validation
  │    ├─ Producer.create(name) → addPendingEvent(ProducerEvent)  ← context auto-injected
  │    └─ writeRepository.save(domain)
  │          └─ RepositoryEventAspect
  │               ├─ executes the real insert
  │               ├─ takes the domainEvents and clears them
  │               └─ publish(event) after the transaction commits
  │
  ├─ ProducerEventHandler.handle(event)  ← event received
  │
  └─ Controller returns Result.success(dto)
       ControllerAroundHandler patches in requestId, cleans up MDC

Next steps