soda docs v17.0.1

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:

RangeSpanMeaning
HTTP semantic codes200 ~ 503Aligned with HTTP status codes
Framework generic codes10000 ~ 10039Technical-level errors
Business codes19999Reserved for business customization

HTTP semantic codes

Enumcodedesc
SUCCESS200成功
BAD_REQUEST400请求错误
UNAUTHORIZED401未授权
FORBIDDEN403禁止访问
NOT_FOUND404资源不存在
INTERNAL_SERVER_ERROR500服务器内部错误
SERVICE_UNAVAILABLE503服务不可用

Framework generic codes

Enumcodedesc
SYSTEM_ERROR10000系统错误
PARAM_ERROR10001参数错误
BUSINESS_ERROR10002业务错误
NETWORK_ERROR10003网络错误
DATABASE_ERROR10004数据库错误
VALIDATION_ERROR10005验证错误
AUTHENTICATION_ERROR10006认证错误
PERMISSION_ERROR10007权限错误
RESOURCE_ERROR10008资源错误
TIMEOUT_ERROR10009超时错误
CONFLICT_ERROR10010冲突错误
CONFIG_ERROR10011配置错误
RPC_ERROR10012RPC调用错误
CACHE_ERROR10013缓存错误
FILE_ERROR10014文件错误
ENCRYPT_ERROR10015加密错误
DECRYPT_ERROR10016解密错误
SERIALIZATION_ERROR10017序列化错误
DESERIALIZATION_ERROR10018反序列化错误
INVALID_FORMAT10019格式错误
INVALID_STATE10020状态错误
UNSUPPORTED_OPERATION10021不支持的操作
NULL_POINTER_ERROR10022空指针错误
INDEX_OUT_OF_BOUNDS_ERROR10023索引越界错误
DIVISION_BY_ZERO_ERROR10024除零错误
ILLEGAL_ARGUMENT_ERROR10025非法参数错误
ILLEGAL_STATE_ERROR10026非法状态错误
CONCURRENT_ERROR10027并发错误
DEADLOCK_ERROR10028死锁错误
MEMORY_ERROR10029内存错误
STACK_OVERFLOW_ERROR10030栈溢出错误
IO_ERROR10031IO错误
INTERRUPTED_ERROR10032中断错误
CLASS_NOT_FOUND_ERROR10033类未找到错误
NO_SUCH_METHOD_ERROR10034方法未找到错误
ILLEGAL_ACCESS_ERROR10035非法访问错误
INSTANTIATION_ERROR10036实例化错误
TYPE_ERROR10037类型错误
CAST_ERROR10038类型转换错误
VERIFICATION_ERROR10039验证错误

Business codes

Enumcodedesc
CUSTOM_ERROR19999自定义错误
Tip

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

java
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 "未知错误".

Note

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

ConstructorNotes
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

java
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:

FieldTypeDescription
codeintError code
errorDataMap<String, Object>Extra information: timestamp, className, methodName, lineNumber, plus whatever extra data you passed in
timestamplongMillisecond timestamp at creation
classNameStringClass name of the throw site
methodNameStringMethod name of the throw site
lineNumberintLine number of the throw site

The throw-site logic skips BaseException itself and Thread, taking the first "business" frame in the stack:

java
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];
}
Tip

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

java
// 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);
Warning

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

java
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:

java
@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;
    }
}
Important

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:

java
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

MethodFailure 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
Note

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

MethodFailure 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:

java
public static <T extends Comparable<T>> T inRange(T value, T min, T max, String message)

Collection membership checks

MethodFailure 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:

MethodWhen 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:

MethodDescription
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
Note

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

java
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:

java
ParamChecker.notNull(command, BaseErrorCode.PARAM_ERROR.getCode(), "command must not be blank");

Throwing a custom exception:

java
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

Methodcodemessage
success(T data)200""
success(T data, String message)200Yours
success(T data, int code, String message)YoursYours
successWithRequestId(T data, String requestId)200""
successWithRequestId(T data, String message, String requestId)200Yours
successWithRequestId(T data, int code, String message, String requestId)YoursYours

Failure

Methodcodemessage
fail(int code, String message)YoursYours
fail(T data, int code, String message)YoursYours
failWithRequestId(T data, int code, String message, String requestId)YoursYours
fail(IErrorCode errorCode)errorCode.getCode()errorCode.getMessage()
fail(T data, IErrorCode errorCode)SameSame
failWithRequestId(T data, IErrorCode errorCode, String requestId)SameSame
Warning

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:

java
Result.success(dto).setRequestId(requestId);

Result's fields

FieldTypeDefault
requestIdStringnull
codeInteger200
messageString""
dataTnull
successBooleantrue
errorDataMap<String, Object>Empty Map

PageResult

PageResult<T> extends Result<T> with eight paging fields:

FieldTypeDescription
totalLongTotal record count
pageNoIntegerCurrent page number, starting at 1
pageSizeIntegerItems per page
pagesLongTotal pages
hasNextBooleanWhether there is a next page
hasPreviousBooleanWhether there is a previous page
startIndexLongStart index of the current page
endIndexLongEnd index of the current page

Factory methods

MethodDescription
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
Important

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:

java
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.

Warning

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:

ExceptionResponse codeResponse message
BaseExceptionThe code the exception carriesThe message the exception carries
Any other exceptionSYSTEM_ERROR (10000)"Internal server error, please try again later"
java
@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");
}
Important

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