Split Operation

Updated:

 Note: This is a preview release and subject to change. Feedback welcome! Contact Information and Background (PDF)

also known as: Decompose API Call

Context and Motivation

An API with endpoints and operations has been defined and implemented. As new capabilities are added to the API, some operations acquire multiple responsibilities. For example, an operation might take a boolean parameter or enumeration in its request message that leads to a different execution logic in the 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 to use for client developers, and can be modified rapidly and flexibly by provider developers.

Stakeholder Concerns (including Quality Attributes and Design Forces)

#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 focussed and do not offer unwanted capabilities) and for the provider staff due to the lower complexity of the implementation.
#maintainability and #evolvability
Operations that follow the single responsibility principle are easier to maintain and evolve over time, for instance because their provider only has a single reason to change their request and response message structure and content during API evolution. Changing an operation that does many things is unnecessarily difficult if the change only affects certain aspects of the operation; unexpected side effects may occur. For example, the access control list has to include the superset of all stakeholders of the “many things”; changing it has a rather large impact. Deprecating the operation also is more complicated if an operation has multiple stakeholders. Test cases have to cover all – and all combinations of – the “many things” the operation is in charge of.

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 simple 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 or not, it is either updated or created.

This refactoring targets an endpoint implementation and its request and response messages.

Smells / Drivers

Role and/or responsibility diffusion
The operation does (too) many things at once. Clients have to understand all these things to be able to use the operation correctly. Its request message is rather deeply structured and may contain optional or generic, variable parts to be able to express a diverse set of input options. This complexity may lead to superfluous errors and a degraded developer experience. The internal coupling of the operation is low.
Combinatorial explosion of input options
Multiple boolean parameters or other flags that determine the execution path lead to a combinatorial explosion of possibilities. This is problematic both for the client that has to understand this complex option space to prepare valid requests and the provider that has to validate and process the parameter handling. API testing on client and provider side is also complicated.

Instructions (Steps)

To split an operation, the following steps apply:

  1. Copy the operation implementation, remove the branching metadata parameter (in the copy) and delete the part of the implementation that is no longer needed.
  2. Expose the new operation to clients. Depending on the underlying technology, this can be non-trivial. When using HTTP, choosing a new (previously unused) verb might be appropriate for the new operation.
  3. Also copy the tests that cover the parts of the implementation that now reside in the copy. Remove the obsolete branching metadata parameter from the tests, adjust the test data, and make sure the tests pass.
  4. Include the newly created operation in the API Description. Inform the clients that the previous behavior is now covered by a new operation.
  5. If access to the original operation was restricted to specific clients, apply the same security rules to the new operation.
  6. If necessary to avoid breaking changes, mark the parameter as deprecated or immediately remove it. Note that this decision depends on the lifecycle guarantees given to clients, as for instance documented as one of the Evolution Patterns.

Target Solution Sketch (Evolution Outline)

After the refactoring, the behavior of the original operation appears in two distinct operations:

Splitting the operation into two (or more) distinct operations makes each one of them easier to use and maintain. No provider-side dispatching or branching logic is required anymore.

Example(s)

The following example from a construction management API shows a Spring Boot implementation of an endpoint that offers an operation to modify the data of a certain building site:

    @PutMapping("/constructions/{id}")
    public ResponseEntity<Construction> updateConstruction(
        @PathVariable(value = "id") final Long id,
        @PathVariable(value = "partial-update") final Boolean partialUpdate,
        @NotNull @RequestBody Construction construction ) {

        if (!constructionRepository.existsById(id)) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Entity not found");
        }

        Construction result;
        if (partialUpdate) {
            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 are provided by the client in the request body are overwritten. If partialUpdate is set to false, the entire entity is replaced, as shown in the else block.

HTTP actually provides the PATCH verb to represent partial updates (whereas PUT methods are supposed to replace the resource in its entirety). So we can move the “patch” parts of the operation to a new one:

    @PatchMapping("/constructions/{id}")
    public ResponseEntity<Construction> updateConstructionPartially(
        @PathVariable(value = "id") final 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 of the new updateConstructionPartially operation above. The old updateConstruction operation has become much simpler now:

    @PutMapping("/constructions/{id}")
    public ResponseEntity<Construction> updateConstruction(
        @PathVariable(value = "id") final Long id,
        @NotNull @RequestBody Construction construction
    ) {

        if (!constructionRepository.existsById(id)) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Entity not found");
        }

        Construction result = constructionRepository.save(construction);

        return ResponseEntity.ok().body(result);
    }

In this example, the PatchMapping for the endpoint was not already used. If this had been the case, we would have had to introduce a new endpoint for the split off operation, or apply Move Operation to move the existing operation to the new endpoint. And even though the method name updateConstruction is 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 protocol’s conventions. For example, a PUT request is idempotent, meaning that the result of sending such a request is the same whether it has been sent exactly one or more times. This contrasts with a POST request, which is not idempotent (which might lead to inconsistent provider-side state, for instance, duplicates after multiple creates of the same data). Not following these rules confuses clients and may cause bugs.

HTTP redirections provide a technical solution for informing clients about the new operation.

Once an operation has been split into two, one of them 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 implementation things REST) are explained in the form of recipes in [Allamaraju 2010].

References

Allamaraju, Subbu. 2010. RESTful Web Services Cookbook. O’Reilly.

Fowler, Martin. 2018. Refactoring: Improving the Design of Existing Code. 2nd ed. Addison-Wesley Signature Series (Fowler). Boston, MA: Addison-Wesley.