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:
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:
| Constant | Header | Meaning |
|---|---|---|
SODA_REQUEST_ID | Soda-Request-Id | soda's own trace ID, injected by the caller or gateway |
SODA_USER_NAME | Soda-User-Name | soda's own user name header |
X_REQUEST_ID | X-Request-Id | generic trace ID |
X_UID | X-UID | user UID |
X_USER_NAME | X-User-Name | user name |
X_AUTHORITIES | X-Authorities | authorities string |
X_JTI | X-Jti | JWT token id |
X_CALLER_UID | X-Caller-Uid | caller UID |
X_TENANT_ID | X-Tenant-Id | tenant ID |
SCADA | scada | a constant value, not a header |
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:
| Field | Type | Default | Description |
|---|---|---|---|
requestId | String | null | trace ID |
code | Integer | 200 | response code |
message | String | "" | response message; empty string means success |
data | T | null | business data |
success | Boolean | true | success flag |
errorData | Map<String, Object> | empty Map | error details, present only on failure |
Result factory methods:
| Method | Description |
|---|---|
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):
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.
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 extendBaseCommand/BaseQuery, so Jackson deserializes them fine. - Write
throws BaseException.BaseExceptionis aRuntimeExceptionsubclass, 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:
- Reads
requestIdanduserNamefrom request headers - Writes them into the Command / Query object
- Writes them into MDC so logs carry trace information
- Logs latency uniformly and converts exceptions to
Result
Full code:
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);
}
...
}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:
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
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 insidethenApply.
Writing into the request object
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.
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
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 type | code | message |
|---|---|---|
BaseException | the exception's own code | the exception's own message |
| anything else | 10000 (SYSTEM_ERROR) | "Internal server error, please try again later" |
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
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:
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.
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
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:
<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:
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:
| Exception | HTTP status | Response code |
|---|---|---|
BaseException | 500 | the exception's own code |
MethodArgumentNotValidException / BindException | 400 | 400 |
MissingServletRequestParameterException | 400 | 400 |
MethodArgumentTypeMismatchException | 400 | 400 |
HttpRequestMethodNotSupportedException | 405 | 405 |
HttpMediaTypeNotSupportedException | 415 | 415 |
NoHandlerFoundException | 404 | 404 |
| anything else | 500 | 10000 (SYSTEM_ERROR) |
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 3 | Spring Boot 4 |
|---|---|
spring-boot-starter-web | spring-boot-starter-webmvc |
spring-boot-starter-aop | spring-boot-starter-aspectj |
spring-boot-starter-json | spring-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
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 MDCNext steps
- How to use
BusFacadein an application service: Write a Command - The complete context propagation rules (async and cross-service included): Context Propagation
- The complete error code list: Errors & Error Codes