Split Operation
also known as: Decompose API Call
Context and Motivation
An API with endpoints and operations has been defined and implemented. Some operations acquire multiple responsibilities because new capabilities are added to the API. For example, an operation might accept several types of request messages that lead to different parts of execution logic in the API implementation.
As an API provider, I want to offer endpoints that expose operations with distinct responsibilities so that the API is easy to understand and use for client developers and can be modified rapidly and flexibly by provider developers.
Stakeholder Concerns
- #single-responsibility-principle and #understandability
- An API that offers endpoints with operations that follow the single responsibility principle is easier to understand for clients because operations are focused and do not offer extra capabilities that not all clients use. The API provider team also benefits from a lower complexity of the implementation.
- #maintainability, #evolvability
- Operations that follow the single responsibility principle are easier to maintain and evolve, for instance, because their API provider only has a single reason to change their request and response message structure and content when evolving the API. Changing an operation that does many things is unnecessarily complex if the change only affects certain aspects of the operation; unexpected side effects may occur. For example, deprecating the operation is more complicated if it has multiple stakeholders.
- #testability
- Test cases have to cover all, and all combinations of, the many things the operation is in charge of. Testing such an operation is more complex than testing a single-responsibility operation.
- #security
- Access restrictions of an API can be implemented on different levels: whole API, per endpoint, individual operations, or even depending on the executed control flow paths in the implementation of operations or the data accessed. Securing an operation that does many things is complex. The access control list must include the superset of all involved participants; changing it has a significant impact.
Initial Position Sketch
The implementation logic chosen by the operation depends on data sent by the client (represented by the Metadata Element). This branching parameter could take the form of a boolean parameter, an enumeration, or any other value obtained from the request message or the resource state. For example, the request body could contain the data of a resource representation, and depending on whether that resource is already known to the API, it is either updated or created. Figure 1 visualizes this initial position.
Figure 1: Split Operation: Initial Position Sketch. A client sends a request (1) that includes metadata, such as an enumeration value or boolean flag. The provider uses this information to steer the control flow, choosing between operations (Op1, Op2) to execute and compose a response message (2). This metadata might be optional, as shown in the second exchange (Request 3, Response 4). The provider might choose a default operation or even return an error message in that case.
This refactoring targets an endpoint implementation and its request and response messages.
Design Smells
- Role and/or responsibility diffusion and low cohesion
- An operation does (too) many things. Clients have to understand all these things to use the operation correctly. Its request message is rather deeply structured and may contain optional, generic, or variable parts to express diverse input options. This complexity may lead to errors and a degraded developer experience. The internal cohesion of the operation is low.
- Combinatorial explosion of input options
- Boolean parameters or other flags that determine the execution path lead to a combinatorial explosion of possibilities. Explaining these options bloats the API Description and is problematic for the client, who has to understand this complex option space to prepare valid requests, and the provider, who has to validate and process the parameter handling. API testing on the client and provider side is also complicated.
- Change log jitter or commit chaos
- The operation has been, and continues to be, modified frequently, according to the commit logs kept by the version control system. Frequent changes may indicate that the operation has too many responsibilities and is not focused enough.
Instructions
To split an operation, the following steps apply:
- Copy the operation implementation, remove the branching metadata parameter (in the copy), and delete the part of the implementation that is no longer needed for the new operation.
- (Optional) If there is common code in the two operations, extract it into a new method and call it from both operations. This step is optional because the shared code might be small enough to be inlined in both operations (consider the Rule of Three of refactoring1 by Fowler [2018]).
- Expose the new operation to clients. Depending on the underlying technology, this can be non-trivial. When using HTTP, choosing a different (previously unused) verb might be appropriate for the new operation. See Singjai et al. [2021] for a collection of patterns on designing API operations.
- Copy the tests covering the implementation parts that now reside in the copy. Remove the obsolete branching metadata parameter from the tests, adjust the test data and assertions, and ensure the tests pass.
- Include the newly created operation in the API Description. Inform clients that a new operation now covers the previous behavior. The decision logic previously encoded on the provider side must now be implemented on the client side instead of just passing a branching parameter.
- If access to the original operation was restricted to specific clients, apply the same security rules to the new operation.
- If necessary, to avoid breaking changes, mark the branching metadata parameter as deprecated or immediately remove it, along with any unused code in the original operation implementation. Note that this decision depends on the lifecycle guarantees given to clients, as documented as one of the Evolution Patterns.
For backward compatibility, a Content-Based Router [Hohpe and Woolf 2003] can forward requests to the correct operation.
Target Solution Sketch (Evolution Outline)
After the refactoring, the behavior of the original operation is cut into two parts, which are implemented by two distinct operations (“Op1” and “Op2”).
Figure 2: Split Operation: Target Solution Sketch. Instead of using metadata parameters to steer the provider behavior, two distinct operations (Op1, Op2) are offered. Clients can invoke one (Request 1, Response 2) or the other (Request 3, Response 4).
Splitting the operation into two (or more) distinct operations makes each one easier to use and maintain. No provider-side dispatching or branching logic is required anymore. As a downside, the client implementation might become more complex because it has to decide which operation to call. The amount of request-response message exchanges usually stays the same, though.
Example(s)
The following example from a construction management API shows a Spring Boot implementation of an endpoint that offers an updateConstruction
operation to modify the data of a particular building site, specified by the id
parameter:
@PutMapping("/constructions/{id}")
public ResponseEntity<Construction> updateConstruction(
@PathVariable(value = "id") Long id,
@PathVariable(value = "partial-update") Boolean partial,
@NotNull @RequestBody Construction construction) {
if (!constructionRepository.existsById(id)) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Entity not found");
}
Construction result;
if (partial) {
result = constructionRepository
.findById(construction.getId())
.map(existingConstruction -> {
if (construction.getName() != null) {
existingConstruction.setName(
construction.getName());
}
// repeat this for all attributes
return existingConstruction;
})
.map(constructionRepository::save).get();
} else {
result = constructionRepository.save(construction);
}
return ResponseEntity.ok().body(result);
}
The operation takes a boolean partialUpdate
parameter. If it is set to true
, the attributes that the client provides in the request body are overwritten. If partialUpdate
is false
, the entire entity is replaced, as shown in the else
block.
HTTP provides the PATCH
verb to represent partial updates (whereas PUT
methods are supposed to replace the entire resource). So we can move the “patch” parts of the operation to a new operation:
@PatchMapping("/constructions/{id}")
public ResponseEntity<Construction> updatePartially(
@PathVariable(value = "id") Long id,
@NotNull @RequestBody Construction construction) {
if (!constructionRepository.existsById(id)) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Entity not found");
}
Construction result = constructionRepository
.findById(construction.getId())
.map(existingConstruction -> {
if (construction.getName() != null) {
existingConstruction.setName(
construction.getName());
}
// repeat this for all attributes
return existingConstruction;
})
.map(constructionRepository::save).get();
return ResponseEntity.ok().body(result);
}
Note the PatchMapping
annotation on the updatePartially
operation that was added. The old updateConstruction
operation has become much simpler now (the +
and -
stand for added and removed lines, respectively):
@PutMapping("/constructions/{id}")
public ResponseEntity<Construction> updateConstruction(
@PathVariable(value = "id") Long id,
- @PathVariable(value = "partial-update") Boolean partial,
@NotNull @RequestBody Construction construction) {
if (!constructionRepository.existsById(id)) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Entity not found");
}
+ Construction result =
+ constructionRepository.save(construction);
- if (partial) {
- result = constructionRepository
- .findById(construction.getId())
- .map(existingConstruction -> {
- if (construction.getName() != null) {
- existingConstruction.setName(
- construction.getName());
- }
- // repeat this for all attributes
- return existingConstruction;
- })
- .map(constructionRepository::save).get();
- } else {
- result = constructionRepository.save(construction);
- }
return ResponseEntity.ok().body(result);
}
In this example, no operation in the endpoint had a PatchMapping
so far. If this had been the case, we would have had to introduce a new endpoint for the split-off operation and apply Move Operation to move either operation to the new endpoint.
Even though the method name updateConstruction
is an implementation detail and not exposed to API clients, it could also be renamed. For example, replaceConstruction
would fit better with the new responsibility of the method.
Hints and Pitfalls to Avoid
When using HTTP, follow the conventions of the protocol. For example, a PUT request should be idempotent, meaning that the result of sending such a request is the same whether it has been sent exactly once or multiple times. In contrast, a POST request is not necessarily idempotent. Sending it multiple times might lead to incorrect provider-side state, such as duplicated data. Not following such conventions confuses clients and may cause API usage bugs.
HTTP redirections provide a technical solution for informing clients about the new operation [Fielding and Reschke 2014]. This approach only works if the URI changes. Using redirects to tell clients to use another HTTP verb is not possible.
With respect to security concerns, the split-off operation should probably be accessible to the same clients as the original operation if authentication and authorization are required.
Possible impacts on Rate Limits, monitoring, caching, and other aspects of the API should be considered as well.
Related Content
Merge Operations is the inverse refactoring. Add Wish List can also combine two Retrieval Operations that return related data.
Once an operation has been split into two, one can also be moved to a different endpoint with the Move Operation refactoring.
When refactoring the API implementation, the Extract Method [Fowler 2018] refactoring is eligible.
The correct use of HTTP verbs and many other REST implementation hints are explained in the RESTful Web Services Cookbook [Allamaraju 2010].
References
Allamaraju, Subbu. 2010. RESTful Web Services Cookbook. O’Reilly.
Fielding, Roy T., and Julian Reschke. 2014. “Hypertext Transfer Protocol (HTTP/1.1): Semantics and Content.” Request for Comments. RFC 7231; RFC Editor. https://doi.org/10.17487/RFC7231.
Fowler, Martin. 2018. Refactoring: Improving the Design of Existing Code. 2nd ed. Addison-Wesley Signature Series (Fowler). Boston, MA: Addison-Wesley.
Hohpe, Gregor, and Bobby Woolf. 2003. Enterprise Integration Patterns: Designing, Building, and Deploying Messaging Solutions. Addison-Wesley.
Singjai, Apitchaka, Uwe Zdun, Olaf Zimmermann, Mirko Stocker, and Cesare Pautasso. 2021. “Patterns on Designing API Endpoint Operations.” Virtual: ACM; ACM.
-
Not to be confused with the Rule of Three of the patterns community: Only call it a pattern if there are at least three known uses (https://wiki.c2.com/?RuleOfThree). ↩