State

The state pattern is a clear example of reification in software design, making the concept of our domain problem an explicit object rather than just a side value.

In Chapter 8Unit Testing and Refactoring, we had an object that represented a merge request, and it had a state associated with it (open, closed, and so on). We used an enum to represent those states because, at that point, they were just data holding a value the string representation of that particular state. If they had to have some behavior, or the entire merge request had to perform some actions depending on its state and transitions, this would not have been enough.

The fact that we are adding behavior, a runtime structure, to a part of the code has to make us think in terms of objects, because that's what objects are supposed to do, after all. And here comes the reification—now the state cannot just simply be an enumeration with a string; it needs to be an object.

Imagine that we have to add some rules to the merge request say, that when it moves from open to closed, all approvals are removed (they will have to review the code again)—and that when a merge request is just opened, the number of approvals is set to zero (regardless of whether it's a reopened or a brand new merge request). Another rule could be that when a merge request is merged, we want to delete the source branch, and of course, we want to forbid users from performing invalid transitions (for example, a closed merge request cannot be merged, and so on).

If we were to put all that logic into a single place, namely in the MergeRequest class, we will end up with a class that has lots of responsibilities (a poor design), probably many methods, and a very large number of if statements. It would be hard to follow the code and to understand which part is supposed to represent which business rule.

It's better to distribute this into smaller objects, each one with fewer responsibilities, and the state objects are a good place for this. We create an object for each kind of state we want to represent, and, in their methods, we place the logic for the transitions with the aforementioned rules. The MergeRequest object will then have a state collaborator, and this, in turn, will also know about MergeRequest (the double-dispatching mechanism is needed to run the appropriate actions on MergeRequest and handle the transitions).

We define a base abstract class with the set of methods to be implemented, and then a subclass for each particular state we want to represent. Then the MergeRequest object delegates all the actions to state, as shown in the following code:

class InvalidTransitionError(Exception):
"""Raised when trying to move to a target state from an unreachable
source
state.
"""


class MergeRequestState(abc.ABC):
def __init__(self, merge_request):
self._merge_request = merge_request

@abc.abstractmethod
def open(self):
...

@abc.abstractmethod
def close(self):
...

@abc.abstractmethod
def merge(self):
...

def __str__(self):
return self.__class__.__name__


class Open(MergeRequestState):
def open(self):
self._merge_request.approvals = 0

def close(self):
self._merge_request.approvals = 0
self._merge_request.state = Closed

def merge(self):
logger.info("merging %s", self._merge_request)
logger.info("deleting branch %s",
self._merge_request.source_branch)
self._merge_request.state = Merged


class Closed(MergeRequestState):
def open(self):
logger.info("reopening closed merge request %s",
self._merge_request)
self._merge_request.state = Open

def close(self):
pass

def merge(self):
raise InvalidTransitionError("can't merge a closed request")


class Merged(MergeRequestState):
def open(self):
raise InvalidTransitionError("already merged request")

def close(self):
raise InvalidTransitionError("already merged request")

def merge(self):
pass


class MergeRequest:
def __init__(self, source_branch: str, target_branch: str) -> None:
self.source_branch = source_branch
self.target_branch = target_branch
self._state = None
self.approvals = 0
self.state = Open

@property
def state(self):
return self._state

@state.setter
def state(self, new_state_cls):
self._state = new_state_cls(self)

def open(self):
return self.state.open()

def close(self):
return self.state.close()

def merge(self):
return self.state.merge()

def __str__(self):
return f"{self.target_branch}:{self.source_branch}"

The following list outlines some clarifications about implementation details and the design decisions that should be made:

  • The state is a property, so not only is it public, but there is a single place with the definitions of how states are created for a merge request, passing self as a parameter.
  • The abstract base class is not strictly needed, but there are benefits to having it. First, it makes the kind of object we are dealing with more explicit. Second, it forces every substate to implement all the methods of the interface. There are two alternatives to this:
  • We could have not put the methods, and let AttributeError raise when trying to perform an invalid action, but this is not correct, and it doesn't express what happened.

  • Related to this point is the fact that we could have just used a simple base class and left those methods empty, but then the default behavior of not doing anything doesn't make it any clearer what should happen. If one of the methods in the subclass should do nothing (as in the case of merge), then it's better to let the empty method just sit there and make it explicit that for that particular case, nothing should be done, as opposed to force that logic to all objects.
  • MergeRequest and MergeRequestState have links to each other. The moment a transition is made, the former object will not have extra references and should be garbage-collected, so this relationship should be always 1:1. With some small and more detailed considerations, a weak reference might be used.

The following code shows some examples of how the object is used:

>>> mr = MergeRequest("develop", "master") 
>>> mr.open()
>>> mr.approvals
0
>>> mr.approvals = 3
>>> mr.close()
>>> mr.approvals
0
>>> mr.open()
INFO:log:reopening closed merge request master:develop
>>> mr.merge()
INFO:log:merging master:develop
INFO:log:deleting branch develop
>>> mr.close()
Traceback (most recent call last):
...
InvalidTransitionError: already merged request

The actions for transitioning states are delegated to the state object, which MergeRequest holds at all times (this can be any of the subclasses of ABC). They all know how to respond to the same messages (in different ways), so these objects will take the appropriate actions corresponding to each transition (deleting branches, raising exceptions, and so on), and will then move MergeRequest to the next state.

Since MergeRequest delegates all actions to its state object, we will find that this typically happens every time the actions that it needs to do are in the form self.state.open(), and so on. Can we remove some of that boilerplate?

We could, by means of __getattr__(), as it is portrayed in the following code:

class MergeRequest:
def __init__(self, source_branch: str, target_branch: str) -> None:
self.source_branch = source_branch
self.target_branch = target_branch
self._state: MergeRequestState
self.approvals = 0
self.state = Open

@property
def state(self):
return self._state

@state.setter
def state(self, new_state_cls):
self._state = new_state_cls(self)

@property
def status(self):
return str(self.state)

def __getattr__(self, method):
return getattr(self.state, method)

def __str__(self):
return f"{self.target_branch}:{self.source_branch}"

On the one hand, it is good that we reuse some code and remove repetitive lines. This gives the abstract base class even more sense. Somewhere, we want to have all possible actions documented, listed in a single place. That place used to be the MergeRequest class, but now those methods are gone, so the only remaining source of that truth is in MergeRequestState. Luckily, the type annotation on the state attribute is really helpful for users to know where to look for the interface definition.

A user can simply take a look and see that everything that MergeRequest doesn't have will be asked of its state attribute. From the init definition, the annotation will tell us that this is an object of the MergeRequestState type, and by looking at this interface, we will see that we can safely ask for the open(), close(), and merge() methods on it.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.118.226.216