Errors & Error Codes
soda expresses failure one uniform way: an error code plus a (by default Chinese) message. Error codes are defined in BaseErrorCode, the exception is BaseException, validation uses ParamChecker, and responses are wrapped in Result.
BaseErrorCode
com.hibuka.soda.foundation.error.BaseErrorCode, implementing CodeEnum<Integer>. 48 codes in three ranges:
| Range | Span | Meaning |
|---|---|---|
| HTTP semantic codes | 200 ~ 503 | Aligned with HTTP status codes |
| Framework generic codes | 10000 ~ 10039 | Technical-level errors |
| Business codes | 19999 | Reserved for business customization |
HTTP semantic codes
| Enum | code | desc |
|---|---|---|
SUCCESS | 200 | 成功 |
BAD_REQUEST | 400 | 请求错误 |
UNAUTHORIZED | 401 | 未授权 |
FORBIDDEN | 403 | 禁止访问 |
NOT_FOUND | 404 | 资源不存在 |
INTERNAL_SERVER_ERROR | 500 | 服务器内部错误 |
SERVICE_UNAVAILABLE | 503 | 服务不可用 |
Framework generic codes
| Enum | code | desc |
|---|---|---|
SYSTEM_ERROR | 10000 | 系统错误 |
PARAM_ERROR | 10001 | 参数错误 |
BUSINESS_ERROR | 10002 | 业务错误 |
NETWORK_ERROR | 10003 | 网络错误 |
DATABASE_ERROR | 10004 | 数据库错误 |
VALIDATION_ERROR | 10005 | 验证错误 |
AUTHENTICATION_ERROR | 10006 | 认证错误 |
PERMISSION_ERROR | 10007 | 权限错误 |
RESOURCE_ERROR | 10008 | 资源错误 |
TIMEOUT_ERROR | 10009 | 超时错误 |
CONFLICT_ERROR | 10010 | 冲突错误 |
CONFIG_ERROR | 10011 | 配置错误 |
RPC_ERROR | 10012 | RPC调用错误 |
CACHE_ERROR | 10013 | 缓存错误 |
FILE_ERROR | 10014 | 文件错误 |
ENCRYPT_ERROR | 10015 | 加密错误 |
DECRYPT_ERROR | 10016 | 解密错误 |
SERIALIZATION_ERROR | 10017 | 序列化错误 |
DESERIALIZATION_ERROR | 10018 | 反序列化错误 |
INVALID_FORMAT | 10019 | 格式错误 |
INVALID_STATE | 10020 | 状态错误 |
UNSUPPORTED_OPERATION | 10021 | 不支持的操作 |
NULL_POINTER_ERROR | 10022 | 空指针错误 |
INDEX_OUT_OF_BOUNDS_ERROR | 10023 | 索引越界错误 |
DIVISION_BY_ZERO_ERROR | 10024 | 除零错误 |
ILLEGAL_ARGUMENT_ERROR | 10025 | 非法参数错误 |
ILLEGAL_STATE_ERROR | 10026 | 非法状态错误 |
CONCURRENT_ERROR | 10027 | 并发错误 |
DEADLOCK_ERROR | 10028 | 死锁错误 |
MEMORY_ERROR | 10029 | 内存错误 |
STACK_OVERFLOW_ERROR | 10030 | 栈溢出错误 |
IO_ERROR | 10031 | IO错误 |
INTERRUPTED_ERROR | 10032 | 中断错误 |
CLASS_NOT_FOUND_ERROR | 10033 | 类未找到错误 |
NO_SUCH_METHOD_ERROR | 10034 | 方法未找到错误 |
ILLEGAL_ACCESS_ERROR | 10035 | 非法访问错误 |
INSTANTIATION_ERROR | 10036 | 实例化错误 |
TYPE_ERROR | 10037 | 类型错误 |
CAST_ERROR | 10038 | 类型转换错误 |
VERIFICATION_ERROR | 10039 | 验证错误 |
Business codes
| Enum | code | desc |
|---|---|---|
CUSTOM_ERROR | 19999 | 自定义错误 |
Your business error codes should follow BaseErrorCode's idea in a separate enum — do not add to BaseErrorCode itself. soda's BaseErrorCode is shared across the framework; changing it collides with other projects.
Look up a message by code
public static String getMessage(Integer code) {
for (BaseErrorCode errorCode : values()) {
if (errorCode.code.equals(code)) {
return errorCode.desc;
}
}
return "未知错误";
}A code missing from the dictionary returns "未知错误".
The desc of both VALIDATION_ERROR (10005) and VERIFICATION_ERROR (10039) is 「验证错误」. The duplicate name is deliberate — the two codes have different semantics but the same message text.
BaseException
com.hibuka.soda.foundation.error.BaseException, extending RuntimeException and implementing IErrorCode.
Constructors
| Constructor | Notes |
|---|---|
BaseException(int code, String message) | Most used |
BaseException(int code, String message, Throwable cause) | With the original exception |
BaseException(int code, String message, Map<String, Object> errorData) | With extra data |
BaseException(int code, String message, Map<String, Object> errorData, Throwable cause) | All parameters |
BaseException(int code) | message taken from BaseErrorCode.getMessage(code) |
Static factories
public static BaseException of(int code, String message)
public static BaseException of(int code, String message, Throwable cause)
public static BaseException of(int code, String message, Map<String, Object> errorData)
public static BaseException of(int code, String message, Map<String, Object> errorData, Throwable cause)
public static BaseException of(int code)What it carries
Besides code and message, BaseException automatically records the throw site:
| Field | Type | Description |
|---|---|---|
code | int | Error code |
errorData | Map<String, Object> | Extra information: timestamp, className, methodName, lineNumber, plus whatever extra data you passed in |
timestamp | long | Millisecond timestamp at creation |
className | String | Class name of the throw site |
methodName | String | Method name of the throw site |
lineNumber | int | Line number of the throw site |
The throw-site logic skips BaseException itself and Thread, taking the first "business" frame in the stack:
private StackTraceElement findCaller(StackTraceElement[] stackTrace) {
for (StackTraceElement element : stackTrace) {
if (!element.getClassName().equals(BaseException.class.getName()) &&
!element.getClassName().equals("java.lang.Thread")) {
return element;
}
}
return stackTrace[0];
}errorData is wrapped in Collections.unmodifiableMap — nothing outside can change it. Extra data you pass in is merged into the same map, and your keys override the auto-recorded ones (on collision).
Usage
// Most common: wrap it in the repository
try {
return scodaProducerMapper.insert(scodaProducer) > 0;
} catch (Exception e) {
throw new BaseException(BaseErrorCode.DATABASE_ERROR.getCode(), e.getMessage());
}
// With the original exception
throw new BaseException(BaseErrorCode.NETWORK_ERROR.getCode(), "Failed to call downstream", e);
// With extra data
Map<String, Object> errorData = new HashMap<>();
errorData.put("uid", uid);
throw new BaseException(BaseErrorCode.NOT_FOUND.getCode(), "Production workshop does not exist", errorData);BaseException is a RuntimeException subclass — it propagates up without being caught. When you catch (Exception e) in a repository, remember to pass e as the cause, or the original stack trace is lost and debugging becomes painful.
IErrorCode
public interface IErrorCode {
int getCode();
default String getMessage() {
return BaseErrorCode.getMessage(this.getCode());
}
default Map<String, Object> getErrorData() {
return null;
}
}This is an extensible error-code interface. BaseErrorCode implements it; your business can implement its own:
@Getter
public enum OrderErrorCode implements IErrorCode {
ORDER_NOT_EXIST(20001, "Order does not exist"),
ORDER_ALREADY_PAID(20002, "Order already paid"),
ORDER_AMOUNT_MISMATCH(20003, "Order amount mismatch");
private final int code;
private final String message;
OrderErrorCode(int code, String message) {
this.code = code;
this.message = message;
}
@Override
public int getCode() {
return code;
}
@Override
public String getMessage() {
return message;
}
}Note that the default IErrorCode.getMessage() returns BaseErrorCode.getMessage(code) — the Chinese message from the framework dictionary — not your own message. So a custom enum must override getMessage(), otherwise the message returned by Result.fail(errorCode) will be 「未知错误」.
Also, the default getErrorData() returns null, and Result's constructor turns null into an empty Map, so no NPE.
Custom error codes can be fed straight into Result:
return Result.fail(OrderErrorCode.ORDER_NOT_EXIST);ParamChecker
com.hibuka.soda.foundation.io.ParamChecker is a set of static validation methods. Every failed check throws BaseException, with the error code fixed to BAD_REQUEST (400); the message is the hint you passed in.
Basic checks
| Method | Failure condition | ||
|---|---|---|---|
notNull(T obj, String message) | obj == null | ||
notNull(T obj, int errorCode, String message) | Same, with a custom error code | ||
notNull(T obj, Supplier<E> exceptionSupplier) | Same, throwing a custom exception | ||
notEmpty(String str, String message) | `str == null \ | \ | str.isEmpty()` |
notBlank(String str, String message) | `str == null \ | \ | str.isBlank()` |
notEmpty(T collection, String message) | Collection is null or empty | ||
notEmpty(Map<K, V> map, String message) | Map is null or empty | ||
notEmpty(T[] array, String message) | Array is null or length 0 |
notEmpty has four overloads (String / Collection / Map / array). Passing a null literal is ambiguous and won't compile — write (String) null to pin the type.
Boolean and range checks
| Method | Failure condition |
|---|---|
isTrue(boolean condition, String message) | condition == false |
isTrue(boolean condition, int errorCode, String message) | Same, with a custom error code |
isTrue(boolean condition, Supplier<E> exceptionSupplier) | Same, throwing a custom exception |
isFalse(boolean condition, String message) | condition == true |
inRange(T value, T min, T max, String message) | Value outside the closed interval [min, max] |
lengthInRange(String str, int min, int max, String message) | String length outside [min, max] |
matches(String str, String regex, String message) | String doesn't match the regex |
inRange and lengthInRange use closed intervals — both min and max themselves are valid.
inRange requires the type to implement Comparable:
public static <T extends Comparable<T>> T inRange(T value, T min, T max, String message)Collection membership checks
| Method | Failure condition |
|---|---|
inCollection(T value, Collection<T> collection, String message) | Value not in the collection |
inArray(T value, T[] array, String message) | Value not in the array |
Both methods first check that value is non-null and the container is non-empty — so an empty container reports the same message.
Default-value methods
These three throw nothing; they return the default when the value is empty:
| Method | When the default is returned | ||
|---|---|---|---|
defaultIfEmpty(String str, String defaultValue) | `str == null \ | \ | str.isEmpty()` |
defaultIfBlank(String str, String defaultValue) | `str == null \ | \ | str.isBlank()` |
defaultIfNull(T obj, T defaultValue) | obj == null |
There are also single-argument versions:
| Method | Description |
|---|---|
defaultIfNull(T collection) | Returns an empty collection when the collection is null |
defaultIfNull(Map<K, V> map) | Returns an empty Map when the Map is null |
defaultIfNull(T[] array) | Returns an empty array when the array is null |
The difference between defaultIfEmpty and defaultIfBlank: the first treats only the empty string ("") as empty; the second also treats all-whitespace strings (" "). For form fields fed by user input, defaultIfBlank is the safer choice.
Usage
private void commandValidate(CreateProducerCommand command) throws BaseException {
ParamChecker.notNull(command, "command");
ParamChecker.notBlank(command.getName(), "Name must not be blank");
ParamChecker.lengthInRange(command.getName(), 1, 64, "Name length must be between 1 and 64");
}With a custom error code:
ParamChecker.notNull(command, BaseErrorCode.PARAM_ERROR.getCode(), "command must not be blank");Throwing a custom exception:
ParamChecker.notNull(command, () -> new OrderException("Order does not exist"));
ParamChecker.isTrue(amount > 0, () -> new OrderException("Amount must be positive"));Result
Factory methods of com.hibuka.soda.foundation.io.Result<T>.
Success
| Method | code | message |
|---|---|---|
success(T data) | 200 | "" |
success(T data, String message) | 200 | Yours |
success(T data, int code, String message) | Yours | Yours |
successWithRequestId(T data, String requestId) | 200 | "" |
successWithRequestId(T data, String message, String requestId) | 200 | Yours |
successWithRequestId(T data, int code, String message, String requestId) | Yours | Yours |
Failure
| Method | code | message |
|---|---|---|
fail(int code, String message) | Yours | Yours |
fail(T data, int code, String message) | Yours | Yours |
failWithRequestId(T data, int code, String message, String requestId) | Yours | Yours |
fail(IErrorCode errorCode) | errorCode.getCode() | errorCode.getMessage() |
fail(T data, IErrorCode errorCode) | Same | Same |
failWithRequestId(T data, IErrorCode errorCode, String requestId) | Same | Same |
fail has two int code variants and one IErrorCode variant. With IErrorCode, what gets used is the interface's getMessage(), not BaseErrorCode.getDesc(). Custom enums must override getMessage().
setRequestId returns this, so you can chain it:
Result.success(dto).setRequestId(requestId);Result's fields
| Field | Type | Default |
|---|---|---|
requestId | String | null |
code | Integer | 200 |
message | String | "" |
data | T | null |
success | Boolean | true |
errorData | Map<String, Object> | Empty Map |
PageResult
PageResult<T> extends Result<T> with eight paging fields:
| Field | Type | Description |
|---|---|---|
total | Long | Total record count |
pageNo | Integer | Current page number, starting at 1 |
pageSize | Integer | Items per page |
pages | Long | Total pages |
hasNext | Boolean | Whether there is a next page |
hasPrevious | Boolean | Whether there is a previous page |
startIndex | Long | Start index of the current page |
endIndex | Long | End index of the current page |
Factory methods
| Method | Description |
|---|---|
success(T data, Long total, Integer pageNo, Integer pageSize) | Full paging |
success(T data, Long total, Integer pageNo, Integer pageSize, int code, String message) | With code and message |
success(T data, Integer pageNo, Integer pageSize) | total fixed to 0 |
success(T data) | total=0, pageNo=1, pageSize=10 |
successWithRequestId(T data, Long total, Integer pageNo, Integer pageSize, String requestId) | With requestId |
successWithRequestId(T data, Long total, Integer pageNo, Integer pageSize, int code, String message, String requestId) | All parameters |
successWithRequestId(T data, Integer pageNo, Integer pageSize, String requestId) | total=0 |
successWithRequestId(T data, String requestId) | total=0, pageNo=1, pageSize=10 |
fail(int code, String message) | Failure |
fail(T data, int code, String message) | Failure with data |
failWithRequestId(T data, int code, String message, String requestId) | Failure with requestId |
The parameter order of success(T data, Long total, Integer pageNo, Integer pageSize) is data → total → pageNo → pageSize. Before 4.0.0 it was data → pageNo → pageSize → total. Change this when upgrading from an older version. Details: Upgrade from v16.
How the paging metadata is computed:
private Long calculatePages(Long total, Integer pageSize) {
if (total == null || total <= 0) {
return 0L;
}
return total % pageSize == 0 ? total / pageSize : total / pageSize + 1;
}
private Long calculateStartIndex(Integer pageNo, Integer pageSize) {
return (long) (pageNo - 1) * pageSize + 1;
}
private Long calculateEndIndex(Long startIndex, Integer pageSize, Long total) {
Long endIndex = startIndex + pageSize - 1;
return Math.min(endIndex, total);
}startIndex starts at 1, not 0.
In calculatePages, total % pageSize does no null check on pageSize. Passing a null pageSize causes an NPE. calculateStartIndex / calculateEndIndex use pageSize directly too. When constructing PageResult, always make sure pageSize is non-null — query.getPageSizeWithDefault() does the trick.
Mapping exceptions to responses
The web layer needs one central place that translates exceptions into Result. Typical rules:
| Exception | Response code | Response message |
|---|---|---|
BaseException | The code the exception carries | The message the exception carries |
| Any other exception | SYSTEM_ERROR (10000) | "Internal server error, please try again later" |
@ExceptionHandler(Exception.class)
public Result<Object> handleException(Exception e) {
logger.error("Unexpected Exception: {}", e.getMessage(), e);
return Result.fail(BaseErrorCode.SYSTEM_ERROR.getCode(), "Internal server error, please try again later");
}Never return e.getMessage() of a system exception straight to the client — it often contains SQL statements, file paths, class names. Write the details to the server log only.
Details: Web Layer.
Next steps
- How exceptions get translated into responses in the web layer: Web Layer
- Which file holds the full error-code dictionary: Configuration Reference