22 Interface Design the Language of System Interaction Must Be Unified

22 Interface Design The Language of System Interaction Must Be Unified #

Today, I would like to share with you the importance of ensuring consistency in the language used for inter-system communication during interface design.

As we know, designing an interface is the first step in developing a service. Interface design involves considering many factors, such as interface naming, parameter lists, wrapper structures, interface granularity, versioning strategies, idempotence implementation, and synchronous/asynchronous processing methods.

Among these factors, there are three key points that are particularly relevant to interface design: wrapper structures, versioning strategies, and synchronous/asynchronous processing methods. Today, I will share with you some practical examples I have encountered, highlighting the problems that arise from a mismatch in understanding between the interface design approach and the calling party, as well as some related best practices.

Interface response should clearly indicate the processing result of the interface #

I once encountered a payment collection project where the response body of the order placement interface contained attributes such as success, code, info, message, etc., as well as a nested object structure called data. When we were refactoring the project, we found it difficult to get started because the interface lacked documentation, and errors would occur whenever the code was modified.

Sometimes, the response result of the order placement operation is like this: success is true, message is OK, which seems to indicate that the order placement is successful; however, the info indicates that there is a risk with the order, code is an error code of 5001, and in data, the order status is Cancelled and the order ID is -1, which seems to indicate that the order was not placed successfully.

{
  "success": true,
  "code": 5001,
  "info": "Risk order detected",
  "message": "OK",
  "data": {
    "orderStatus": "Cancelled",
    "orderId": -1
  }
}

Sometimes, the order placement interface returns a result like this: success is false, and the message indicates an illegal user ID, which seems to indicate that the order placement fails; however, in data, orderStatus is Created, info is empty, and code is 0. So, is this order placement successful or not?

{
  "success": false,
  "code": 0,
  "info": "",
  "message": "Illegal userId",
  "data": {
    "orderStatus": "Created",
    "orderId": 0
  }
}

This kind of result confused us a lot:

What is the relationship between the code in the structure and the HTTP response status code?

Does success represent a successful or failed order placement?

What is the difference between info and message?

Does data always contain data? When should we query data?

The reason for this confusion is that the payment collection service itself does not actually handle the order placement operation; it only performs some pre-validation and pre-processing. The actual order placement operation needs to call another order service inside the payment collection service. After the order service completes the processing, it will return the order status and ID.

Under normal circumstances, after placing an order, the order status should be Created and the order ID should be a positive number. The message and success in the structure actually represent the exception information and the result of the payment collection service. code and info are the results of calling the order service.

For the first call, the payment collection service itself has no problem, success is true, and message is OK, but the order service call is rejected due to an order risk issue. Therefore, code is 5001, info is Risk order detected, and the information in data is returned by the order service. Therefore, the final order status is Cancelled.

For the second call, because the user ID is illegal, the payment collection service directly returns success as false after validating the parameters, and message as Illegal userId. Since the request did not reach the order service, info, code, and data are all default values. The default value for the order status is Created. Therefore, the second order placement definitely failed, but the order status is already Created.

As you can see, such a confusing interface definition and implementation approach make it impossible for callers to understand how to handle it. In order to design the interface more reasonably, we need to consider the following two principles:

Hide internal implementation from external. Although the payment collection service calls the order service for the actual order placement operation, the direct interface is actually provided by the payment collection service. It should not directly expose the status code and error description of the order service behind it.

When designing the interface structure, clarify the meaning of each field and the processing method for the client.

Based on these two principles, we adjust the return structure and remove the info field from the outer layer, no longer informing the client of the order service call result:

@Data
public class APIResponse<T> {
    private boolean success;
    private T data;
    private int code;
    private String message;
}

And clarify the design logic of the interface:

If a non-200 HTTP response status code occurs, it means that the request did not reach the payment collection service, and it may be due to network problems, network timeouts, or network configuration issues. At this time, the server response body cannot be obtained, and the client can provide a friendly prompt, such as asking the user to retry, without the need to continue parsing the response structure.

If the HTTP response code is 200, parse the response body and check success. If it is false, it means that the order placement request processing has failed, which may be due to parameter validation errors in the payment collection service or order placement operation failures in the order service. At this time, different processing should be done based on the error code defined in the payment collection service and code. For example, provide a friendly prompt, or ask the user to fill in relevant information again. The text of the friendly prompt can be obtained from message.

Only when success is true should the data structure in the response body be further parsed. The data structure represents the business data, and there are usually the following two cases.

In the normal case, when success is true, the order status is Created, and the orderId property can be obtained.

In special cases, such as improper processing in the payment collection service or additional statuses appearing in the order service, although success is true, the actual order status is not Created. At this time, a friendly error message can be provided. Once we have clarified the design logic of the interface, we can implement the server and client for the acquiring service to simulate these scenarios.

First, let’s implement the logic for the server:

@GetMapping("server")
public APIResponse<OrderInfo> server(@RequestParam("userId") Long userId) {
    APIResponse<OrderInfo> response = new APIResponse<>();
    
    if (userId == null) {
        // For the case where userId is null, the acquiring service will handle the failure directly and provide the corresponding error code and message
        response.setSuccess(false);
        response.setCode(3001);
        response.setMessage("Illegal userId");
    } else if (userId == 1) {
        // For the case where userId is 1, simulate the scenario where the order service detects a risk user
        response.setSuccess(false);
        
        // Convert the error code returned by the order service into an acquiring service error code
        response.setCode(3002);
        response.setMessage("Internal Error, order is cancelled");
        
        // Log the internal error
        log.warn("Order service failed for user {}, reason: Risk order detected", userId);
    } else {
        // For other users, the order is successfully placed
        response.setSuccess(true);
        response.setCode(2000);
        response.setMessage("OK");
        response.setData(new OrderInfo("Created", 2L));
    }
    
    return response;
}

For the client code, we can implement it according to the logic on the flowchart, simulating three error cases and the normal order placement case:

The test case for error == 1 simulates a non-existing URL, where the request cannot reach the acquiring service and will receive a 404 HTTP status code. In this case, we can display a friendly prompt. This is the first level of handling.

img

The test case for error == 2 simulates the situation where the userId parameter is null, and the acquiring service will prompt that it is an illegal user due to the missing userId parameter. In this case, we can display the message from the response body to the user. This is the second level of handling.

img

The test case for error == 3 simulates the situation where userId is 1, and the order service call fails due to the user being at risk. The handling method is the same as before, as the acquiring service shields the internal errors of the order service.

img

However, on the server side, the following error message can be seen:

[14:13:13.951] [http-nio-45678-exec-8] [WARN ] [.c.a.d.APIThreeLevelStatusController:36  ] - Order service failed for user 1, reason: Risk order detected

The test case for error == 0 simulates a normal user and a successful order placement. In this case, we can parse the data object to extract the business result. As a fallback, we need to check the order status. If it is not “Created”, a friendly prompt should be given. Otherwise, we can query the orderId to obtain the order number of the placed order. This is the third level of handling.

img

The implementation code for the client is as follows:

@GetMapping("client")
public String client(@RequestParam(value = "error", defaultValue = "0") int error) {
    String url = Arrays.asList("http://localhost:45678/apiresposne/server?userId=2",
            "http://localhost:45678/apiresposne/server2",
            "http://localhost:45678/apiresposne/server?userId=",
            "http://localhost:45678/apiresposne/server?userId=1").get(error);
    
    // First level: Check the status code. If it is not 200, do not process the response body
    String response = "";
    
    try {
        response = Request.Get(url).execute().returnContent().asString();
    } catch (HttpResponseException e) {
        log.warn("Non-200 response from the server", e);
        return "Server is busy, please try again later!";
    }
    
    // Handle the response body based on the error code or data structure
    // ...
}
} catch (IOException e) {
    e.printStackTrace();
}

// Processing response body when the status code is 200

if (!response.equals("")) {
    try {
        APIResponse<OrderInfo> apiResponse = objectMapper.readValue(response, new TypeReference<APIResponse<OrderInfo>>() {});

        // Second level, prompt the user directly if success is false
        if (!apiResponse.isSuccess()) {
            return String.format("Failed to create order, please try again later. Error code: %s, Error message: %s", apiResponse.getCode(), apiResponse.getMessage());
        } else {
            // Third level, parsing OrderInfo
            OrderInfo orderInfo = apiResponse.getData();
            if ("Created".equals(orderInfo.getStatus()))
                return String.format("Order created successfully, order ID: %s, status: %s", orderInfo.getOrderId(), orderInfo.getStatus());
            else
                return "Failed to create order, please contact customer service.";
        }
    } catch (JsonProcessingException e) {
        e.printStackTrace();
    }
}

return "";
}

Compared to the original interface definition and processing logic, the refactored code clearly defines the meaning of each field in the interface and the output of the server and the processing steps of the client for various situations, aligning the processing logic of the client and the server. So now, can you answer the four puzzling questions from earlier?

Finally, we’ll share a little tip. In order to simplify the server-side code, we can let the framework automatically handle the wrapping of the API response body, so that we can directly return the DTO OrderInfo. For business logic errors, we can throw a custom exception:

@GetMapping(“server”) public OrderInfo server(@RequestParam(“userId”) Long userId) { if (userId == null) { throw new APIException(3001, “Illegal userId”); }

if (userId == 1) {
    ...
    // Throwing an exception directly
    throw new APIException(3002, "Internal Error, order is cancelled");
}

// Returning the DTO directly
return new OrderInfo("Created", 2L);

}

The APIException contains the error code and error message:

public class APIException extends RuntimeException { @Getter private int errorCode; @Getter private String errorMessage;

public APIException(int errorCode, String errorMessage) {
    super(errorMessage);
    this.errorCode = errorCode;
    this.errorMessage = errorMessage;
}

public APIException(Throwable cause, int errorCode, String errorMessage) {
    super(errorMessage, cause);
    this.errorCode = errorCode;
    this.errorMessage = errorMessage;
}

}

Then, define a @RestControllerAdvice to handle the automatic wrapping of the response body:

By implementing the beforeBodyWrite method of the ResponseBodyAdvice interface, we can handle the conversion of successful request response bodies.

Implement @ExceptionHandler to handle the conversion from APIException to APIResponse when handling business exceptions.

// This code is just a demo. For production-level applications, many details need to be extended @RestControllerAdvice @Slf4j public class APIResponseAdvice implements ResponseBodyAdvice { // Automatically handle APIException and wrap it as APIResponse @ExceptionHandler(APIException.class) public APIResponse handleApiException(HttpServletRequest request, APIException ex) { log.error(“process url {} failed”, request.getRequestURL().toString(), ex); APIResponse apiResponse = new APIResponse(); apiResponse.setSuccess(false); apiResponse.setCode(ex.getErrorCode()); apiResponse.setMessage(ex.getErrorMessage()); return apiResponse; }

// Only wrap automatically if the method or class is not marked with @NoAPIResponse
@Override
public boolean supports(MethodParameter returnType, Class converterType) {
    return returnType.getParameterType() != APIResponse.class
            && AnnotationUtils.findAnnotation(returnType.getMethod(), NoAPIResponse.class) == null
            && AnnotationUtils.findAnnotation(returnType.getDeclaringClass(), NoAPIResponse.class) == null;
}

// Automatically wrap outer APIResponse response
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
    APIResponse apiResponse = new APIResponse();
    apiResponse.setSuccess(true);
    apiResponse.setMessage("OK");
    apiResponse.setCode(2000);
    apiResponse.setData(body);
    return apiResponse;
}

}

Here, we have implemented a custom @NoAPIResponse annotation. If some @RestController interfaces do not want to implement automatic wrapping, they can be marked with this annotation:

@Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface NoAPIResponse { }

In the support method of ResponseBodyAdvice, we exclude the automatic response body wrapping of methods or classes marked with this annotation. For example, for the test client method we just implemented, we don’t need to wrap it in APIResponse, so we can mark it with this annotation:

@GetMapping(“client”) @NoAPIResponse public String client(@RequestParam(value = “error”, defaultValue = “0”) int error)

This way, we don’t need to consider the wrapping of the response body in our business logic, and the code will be more concise.

Considerations for Version Control Strategy for Interface Evolution #

Interfaces are not set in stone and need to constantly evolve to meet business requirements. When making significant changes or refactoring the functionality, including changes in parameter definitions or deprecating parameters, it may result in the interface being incompatible with previous versions. In such cases, it is necessary to introduce the concept of interface versions. When designing the interface version control strategy, the following points should be taken into consideration, preferably from the beginning, and the overall version control strategy should be considered throughout the service.

  1. It is best to consider the version control strategy from the beginning.

Since interfaces are always subject to changes, it is best to determine the version control strategy from the beginning. For example, determine whether it will be implemented through the URL path, query string, or HTTP header. The code for these three implementation methods is as follows:

//Version control implemented through URL path

@GetMapping("/v1/api/user")

public int right1(){

    return 1;

}

//Version control implemented through 'version' parameter in query string

@GetMapping(value = "/api/user", params = "version=2")

public int right2(@RequestParam("version") int version) {

    return 2;

}

//Version control implemented through 'X-API-VERSION' parameter in request header

@GetMapping(value = "/api/user", headers = "X-API-VERSION=3")

public int right3(@RequestHeader("X-API-VERSION") int version) {

    return 3;

}

With this implementation, clients can handle the relevant version control parameters in their configurations, making it possible to dynamically switch versions.

Among these three methods, the URL path method is the most straightforward and least prone to errors. The query string method is not easy to carry and is not recommended as a version control strategy for public APIs. The HTTP header method is less intrusive and can be considered if only certain interfaces require version control.

  1. The version implementation method should be consistent.

Previously, I encountered an O2O project that needed to implement REST interfaces for goods, shops, and users. Although we agreed to implement API version control through the URL path method, the implementation methods were not consistent. Some used /api/item/v1, some used /api/v1/shop, and others used /v1/api/merchant:

@GetMapping("/api/item/v1")

public void wrong1(){

}


@GetMapping("/api/v1/shop")

public void wrong2(){

}



@GetMapping("/v1/api/merchant")

public void wrong3(){

}

Obviously, the developers for goods, shops, and merchants’ interfaces did not implement interface version control in a consistent URL format. Even worse, we may develop two similar interfaces, such as /api/v1/user and /api/user/v1. Are these one interface or two interfaces?

Instead of setting the version number in the URL path for each interface, it is more ideal to implement it uniformly at the framework level. If you are using the Spring framework, you can customize the RequestMappingHandlerMapping as shown below:

First, create an annotation to define the version of the interface. The @APIVersion custom annotation can be applied to methods or controllers:

@Target({ElementType.METHOD, ElementType.TYPE})

@Retention(RetentionPolicy.RUNTIME)

public @interface APIVersion {

    String[] value();

}

Then, create a class APIVersionHandlerMapping that extends RequestMappingHandlerMapping.

The purpose of RequestMappingHandlerMapping is to generate RequestMappingInfo instances based on the @RequestMapping on classes or methods. Override the implementation of the registerHandlerMethod method to read the version information from the @APIVersion custom annotation and append it to the original URL pattern without the version number, forming a new RequestMappingInfo. This allows us to add version numbers to interfaces in an annotation-based manner based on the URL:

public class APIVersionHandlerMapping extends RequestMappingHandlerMapping {

    @Override

    protected boolean isHandler(Class<?> beanType) {

        return AnnotatedElementUtils.hasAnnotation(beanType, Controller.class);

    }



    @Override

    protected void registerHandlerMethod(Object handler, Method method, RequestMappingInfo mapping) {

        Class<?> controllerClass = method.getDeclaringClass();



        // Check for APIVersion annotation on the class

        APIVersion apiVersion = AnnotationUtils.findAnnotation(controllerClass, APIVersion.class);



        // Check for APIVersion annotation on the method

        APIVersion methodAnnotation = AnnotationUtils.findAnnotation(method, APIVersion.class);



        // Use method annotation if it exists

        if (methodAnnotation != null) {

            apiVersion = methodAnnotation;

        }



        String[] urlPatterns = apiVersion == null ? new String[0] : apiVersion.value();



        PatternsRequestCondition apiPattern = new PatternsRequestCondition(urlPatterns);



        PatternsRequestCondition oldPattern = mapping.getPatternsCondition();



        PatternsRequestCondition updatedFinalPattern = apiPattern.combine(oldPattern);



        // Rebuild RequestMappingInfo

        mapping = new RequestMappingInfo(mapping.getName(), updatedFinalPattern, mapping.getMethodsCondition(),

                mapping.getParamsCondition(), mapping.getHeadersCondition(), mapping.getConsumesCondition(),

                mapping.getProducesCondition(), mapping.getCustomCondition());



        super.registerHandlerMethod(handler, method, mapping);

    }

}

Finally, and easily overlooked, implement the WebMvcRegistrations interface to make the custom APIVersionHandlerMapping take effect:

@SpringBootApplication

public class CommonMistakesApplication implements WebMvcRegistrations {

...

    @Override

    public RequestMappingHandlerMapping getRequestMappingHandlerMapping() {

        return new APIVersionHandlerMapping();

    }

}

With this, you have implemented version control for interfaces using annotations on controllers or interface methods, using a unified pattern:

@GetMapping(value = "/api/user")

@APIVersion("v4")

public int right4() {

    return 4;

}

After adding the annotation, visit the browser to see the effect:

img

Using the framework to explicitly specify the API version control strategy achieves standardization and enforced API version control. With slight modifications to the above code, it is possible to generate error prompts when the @APIVersion interface is not set.

Specify whether the interface processing method is synchronous or asynchronous #

When you see this title, you may find it difficult to understand. Let’s look at an actual case directly.

There is a file upload service called FileService. One of its upload file interfaces is particularly slow because this upload interface needs to perform two operations internally: uploading the original image and then uploading the compressed thumbnail image. If each step takes 5 seconds, then this interface will take at least 10 seconds to return.

Therefore, the development team changed the interface to asynchronous processing. Each operation is submitted to a thread pool with a specified timeout. In other words, the uploadFile and uploadThumbnailFile operations are submitted to the thread pool for processing, and then a certain amount of time is waited:

private ExecutorService threadPool = Executors.newFixedThreadPool(2);

// I didn't paste the implementation of the uploadFile and uploadThumbnailFile methods, which only randomly sleep and return the file name internally, which is not critical for this example

public UploadResponse upload(UploadRequest request) {

    UploadResponse response = new UploadResponse();

    // Submit the upload original file task to the thread pool for processing
    Future<String> uploadFile = threadPool.submit(() -> uploadFile(request.getFile()));

    // Submit the upload thumbnail file task to the thread pool for processing
    Future<String> uploadThumbnailFile = threadPool.submit(() -> uploadThumbnailFile(request.getFile()));

    // Wait for the upload original file task to complete, wait for a maximum of 1 second
    try {
        response.setDownloadUrl(uploadFile.get(1, TimeUnit.SECONDS));
    } catch (Exception e) {
        e.printStackTrace();
    }

    // Wait for the upload thumbnail file task to complete, wait for a maximum of 1 second
    try {
        response.setThumbnailDownloadUrl(uploadThumbnailFile.get(1, TimeUnit.SECONDS));
    } catch (Exception e) {
        e.printStackTrace();
    }

    return response;
}

The request and response of the upload interface are relatively simple, with a binary file passed in and the original file and thumbnail download URLs returned:

@Data
public class UploadRequest {
    private byte[] file;
}

@Data
public class UploadResponse {
    private String downloadUrl;
    private String thumbnailDownloadUrl;
}

By now, can you tell what the problem with this implementation is?

From the interface name, although it is called synchronous file upload, it actually performs asynchronous file upload internally using a thread pool, and because a short timeout is set, the overall response of the interface is quite fast. However, once a timeout occurs, the interface cannot return complete data. Either you cannot get the download URL of the original file or the thumbnail download URL, and the behavior of the interface becomes unpredictable:

img

Therefore, this optimization approach to improve interface response speed is not desirable. A more reasonable approach is to make the upload interface either completely synchronous or completely asynchronous:

Synchronous processing means that both the original file and the thumbnail image are uploaded synchronously in the interface. The caller can choose to set a timeout. If there is enough time, they can wait until the upload is completed. If not, they can end the wait and retry later;

Asynchronous processing means that the interface is two-stage. The upload interface itself only returns a task ID, and the actual upload is done asynchronously. The upload interface responds quickly, and the client needs to call the task query interface later to retrieve the uploaded file URLs.

The implementation code for the synchronous upload interface is as follows, leaving the choice of timeout to the client:

public SyncUploadResponse syncUpload(SyncUploadRequest request) {

    SyncUploadResponse response = new SyncUploadResponse();

    response.setDownloadUrl(uploadFile(request.getFile()));

    response.setThumbnailDownloadUrl(uploadThumbnailFile(request.getFile()));

    return response;

}

The SyncUploadRequest and SyncUploadResponse classes here are consistent with the previously defined UploadRequest and UploadResponse. For the naming of the input and output DTOs of the interface, I recommend using the interface name + Request and Response suffixes.

Next, let’s see how the asynchronous file upload interface is implemented. The asynchronous upload interface has a slight difference in the output parameter, no longer returning the file URL, but returning a task ID:

@Data
public class AsyncUploadRequest {
    private byte[] file;
}

@Data
public class AsyncUploadResponse {
    private String taskId;
}

In the interface implementation, we also submit the upload task to the thread pool for processing. However, we do not synchronously wait for the task to complete, but write the result into a HashMap after completion. The task query interface retrieves the file URLs by querying this HashMap:

// Counter, used as the upload task ID
private AtomicInteger atomicInteger = new AtomicInteger(0);

// Temporarily store the results of upload operations, production code needs to consider data persistence
private ConcurrentHashMap<String, SyncQueryUploadTaskResponse> downloadUrl = new ConcurrentHashMap<>();

// Asynchronous upload operation
public AsyncUploadResponse asyncUpload(AsyncUploadRequest request) {

    AsyncUploadResponse response = new AsyncUploadResponse();

    // Generate a unique upload task ID
    String taskId = "upload" + atomicInteger.incrementAndGet();

    // The asynchronous upload operation only returns the task ID
    response.setTaskId(taskId);

    // Submit the upload original file operation to the thread pool for asynchronous processing
    threadPool.execute(() -> {
        String url = uploadFile(request.getFile());
        // If the ConcurrentHashMap does not contain the Key, initialize a SyncQueryUploadTaskResponse and set the DownloadUrl
        downloadUrl.computeIfAbsent(taskId, id -> new SyncQueryUploadTaskResponse(id)).setDownloadUrl(url);
    });

    // Submit the upload thumbnail file operation to the thread pool for asynchronous processing
    threadPool.execute(() -> {
        String url = uploadThumbnailFile(request.getFile());
        downloadUrl.computeIfAbsent(taskId, id -> new SyncQueryUploadTaskResponse(id)).setThumbnailDownloadUrl(url);
    });

    return response;

}

The file upload query interface takes the task ID as the input parameter and returns the download URLs of both files. Because the file upload query interface is synchronous, it is directly named syncQueryUploadTask:

// Input of the syncQueryUploadTask interface
@Data
@RequiredArgsConstructor
public class SyncQueryUploadTaskRequest {
    private final String taskId; // Use the upload file task ID to query the upload result
}

// Output of the syncQueryUploadTask interface
@Data
@RequiredArgsConstructor
public class SyncQueryUploadTaskResponse {
    private final String taskId; // Task ID
    private String downloadUrl; // Original file download URL
    private String thumbnailDownloadUrl; // Thumbnail download URL
}

public SyncQueryUploadTaskResponse syncQueryUploadTask(SyncQueryUploadTaskRequest request) {

    SyncQueryUploadTaskResponse response = new SyncQueryUploadTaskResponse(request.getTaskId());

    // Query the result from the previously defined downloadUrl ConcurrentHashMap
    response.setDownloadUrl(downloadUrl.getOrDefault(request.getTaskId(), response).getDownloadUrl());
    response.setThumbnailDownloadUrl(downloadUrl.getOrDefault(request.getTaskId(), response).getThumbnailDownloadUrl());

    return response;

}

The FileService that has been modified no longer provides an upload method, which looks like a synchronous upload but actually performs asynchronous upload internally. Instead, it provides clear:

  • Synchronous upload interface syncUpload;
  • Asynchronous upload interface asyncUpload, used in conjunction with syncQueryUploadTask to query the upload result.

Users can choose an appropriate method based on the nature of the business: if it is for back-end batch processing, synchronous upload can be used, waiting a little longer is not a big problem; if it is an interface for end users, the interface response time should not be too long. In this case, the asynchronous upload interface can be called, and then the upload result can be queried through regular polling and displayed after obtaining the result.

Key Highlights #

Today, I had an in-depth discussion with you regarding three aspects of interface design.

Firstly, concerning the confusion in response body design and the ambiguity in response result, the server needs to clearly define the meaning of each field in the response body and handle it consistently, ensuring that downstream service errors are not transparently passed through.

Secondly, regarding interface version control, it is mainly about establishing version control strategies before developing interfaces and striving to use a unified version control strategy.

Thirdly, regarding the handling of interfaces, I believe it is necessary to clearly specify whether it is synchronous or asynchronous. If the API list contains both synchronous and asynchronous interfaces, it is best to clarify it directly in the interface name.

A good interface document not only needs to explain how to call the interface but also needs to supplement the best practices for using the interface and the SLA standards of the interface. I have seen that most interface documents only provide parameter definitions, but some seemingly internally related designs, such as idempotence, synchronous/asynchronous, and caching strategies, will actually affect the caller’s usage strategy for the interface, so it’s best to also reflect them in the interface document.

Lastly, I would like to mention the debate about whether to return a 200 response code when there is an error on the server side. From the perspective of RESTful design principles, we should strive to use HTTP status codes to express errors, but it is not absolute.

If we consider HTTP status codes as contractual agreements at the protocol level, then when this error no longer involves the HTTP protocol (in other words, the server has received the request and the error occurs during the server-side business processing), it may not be necessary to strictly adhere to the error codes defined by the protocol itself. However, in cases involving invalid URLs, invalid parameters, or lack of permissions to handle the request, it is still advisable to use the correct response code.

I have put the code used today on GitHub, and you can click on this link to view it.

Reflection and Discussion #

In the example from the first section, the code field in the response structure of the interface represents the error code of the execution result. For interfaces with complex business logic, there may be many error scenarios and there could be dozens or even hundreds of possible error codes. It can be very cumbersome for client developers to write if-else statements for each error scenario to handle them separately. What do you think could be done to improve this? As a server, is it necessary to inform the client of the error code of the interface execution?

In the example from the second section, we used the custom annotation @APIVersion to mark the class or method, achieving a unified definition of interface versions based on the URL. Can you achieve a unified version control system based on request headers in a similar way (i.e., by creating a custom RequestMappingHandlerMapping)?

Regarding interface design, have you encountered any other problems? I am Zhu Ye, and I welcome you to leave your thoughts in the comments section and share today’s content with your friends or colleagues for further discussions.