Building a system right is not an easy task. It is harder to keep it right as the system evolve over time.
Change is the inevitable truth in life and in system design. Over time, software behavior that was once correct, becomes prone to misbehave as the the software becomes more complex with every change request. Domain specification changes, business logic changes, product design changes, performance and scalability require changes – and with that the likelihood of inadvertently impacting a part of the system to misbehave increases as more and more people touches the ever increasing surface area of software implementation.
The objective of a higher order system is to make it harder to impact a change where system shouldn’t change behavior. And there are several tools available at the disposal of an engineer to do exactly that.
1. Using a well-defined Type system
The value of implementing a Type System is not that it makes the system better in some magical way. But it helps us as developers worry less about making mistakes and bad decisions.
A system that flags and prevents the following:
[Case #1]
PatientId patientId;
EncounterId encounterId;
patientId = encounterId;
is inherently safer and better than the system that allows:
[Case #2]
long patientId;
long encounterId;
patientId = encounterId;
Normal C# (and all other languages except maybe for Haskell) allows Case # 2, whereas a fully Typed implementation of software in any language prevents it. The obvious benefit of the typed implementation is, in the first case, a statement like
patientId = encounterId
will be a compile-time error because of the type mismatch. That’s the direct benefit. The indirect benefit is, type system makes it harder (but not impossible!) for the developer to make a mistake.
patientId = (PatientId(encounterId.Value)); (in case # 1) is significantly more effort and deliberate than doing just
patientId = encounterid (in case # 2) Now, some languages are designed to build a fully typed system seamlessly. Like F#;
type PatientId;
type EncounterId;
is all that is needed to declare two types.
In C#, it would be more verbose,
class PatientId {
public PatientId()
{}
}
class EncounterId {
public EncounterId()
{}
}
but just because the language is not designed to make a fully type-safe implementation relatively easier is no reason to make sacrifice the quality (and peace of mind in the long run) of the software.
Consider using enums or strings anywhere in the system to control the interaction and behavior of entities. It’s just an integer. Nothing is stopping anyone from passing any integer from anywhere. Whereas in a typed implementation, the developer has to be deliberate in applying any such change.
Once we start making these exceptions, the quality of the system will start to degrade and continue to degrade as there will always be more and more exceptions – and soon, Populate will no longer be a Strongly Typed system.
Here’s a second example. Say, a system allows images of different categories, profile pictures, organization logos, x-ray images. The first order natural instinct would be to define a
string imageType;
which takes on different values such as
imageType = "ProfilePicture"; or
imageType = "X-Ray";
This makes it extremely easy to define an image as one category and then mistakenly mislabel it elsewhere in the code just by saying
Image aImage;
aImage = "ProfilePicture";
somewhere else ...
aImage = "X-Ray";
In a higher order system, we want to make mistakes harder and a change to be deliberately made.
Consider an implementation that uses marker classes
to strongly type the image categories. I would define a marker class as a type definiton, an empty class with no body. Instead of a blue print of an object or a inheritence system representing a hierarchy, they are an embodiment of a type, and nothing more.
class ImageType { }
class ProfilePicture: ImageType { }
class XRay : ImageType { }
This makes it impossible to switch the image type from ProfilePicture to an XRay within explicit type casting or utility methods for type conversion. An image uploaded as ProfilePicture will always remain a ProfilePicture by design.
2. Using state machines
State machines are wonderful tool that makes change a deterministic process through explicit transitions instead of simple assignments.
Imagine the system interacts with an external system to check, fetch and process files. A first order implementation may capture this behavior through a series of
var status = external.CheckFile();
status = external.FetchFile();
status = external.ProcessFile();
where status can be an enum or string value is one of:
{'fileExist', 'fileFetched', 'fileProcessed'}
In a world where the steps are disjointed, interwint with other processes, must deal with multiple level of failures, and distributed across different flows, the status determination is prone for mismanagement. After all, all it takes is an assignment statement to assign the ‘status’ to a pre-defined string or even an invalid string! There’s nothing in the system that’s stopping this mistake other than an alert engineer.
However, if the status transitions are controlled through a state-machine where only a valid set of transitions are allowed based on current state and action, then the ‘status’ assignment becomes much more deterministic. It would be impossible for an invalid assignment unless for explicitly defining a transition function.
Here’s what such as state-machine may look like:
public abstract class ClaimReportFileAction
{
public static ClaimReportFileStatus NextState
(ClaimReportFileStatus currentState, ClaimReportFileAction actionPerformed)
=> (currentState, actionPerformed) switch
{
(ClaimReportNewFile state, FileContentDownloaded action) => new ClaimReportFileContentDownloaded(),
(ClaimReportFileContentDownloaded state, FileContentDownloaded action) => new ClaimReportFileContentDownloaded(),
(ClaimReportFileContentDownloaded state, FileContentProcessed action) => new ClaimReportFileProcessed(),
(ClaimReportFileProcessed state, FileDeletedFromRemote action) => new ClaimReportDeletedFromRemote(),
(_ , _ ) => throw new InvalidOperationException()
};
}
where the file status and file actions are themselve marker classes
to further type safe status and action declarations:
public sealed class ClaimReportNewFile : ClaimReportFileStatus { }
public sealed class ClaimReportFileContentDownloaded : ClaimReportFileStatus { }
public sealed class ClaimReportFileProcessed : ClaimReportFileStatus { }
public sealed class ClaimReportDeletedFromRemote : ClaimReportFileStatus { }
public class FileContentDownloaded : ClaimReportFileAction { }
public class FileContentProcessed : ClaimReportFileAction { }
public class FileDeletedFromRemote : ClaimReportFileAction { }
What’s the point of doing all of this?
Any time we make a decision that prevents current errors and makes it harder to introduce future errors, we get an edge.
Any time engineers spend less time in debugging and fire-fighting and spend that time building new things; we get an edge.
The point of being strict about using the type system and state machines is to minimize engineering errors by making it hard to make mistakes as much as possible.
A well-defined type system can take advantage of the power of a compiler. The goal of a type-safe system is to “catch all non-business logic related bugs in compile time instead of run time and make the compiler do the work for us.” A compiler knows a PatientId and EncounterId are not the same type and, therefore, cannot be interchanged. But it cannot help us prevent some mistaken assignments if both are of type “long.”
And eventually, this is a faster way to build things. Time saved in unnecessary commenting through self-documenting code, needless unit tests to ensure system invariants, and fixing bugs that appeared in runtime but could have been prevented in compile time, are all put towards building features for the user.
Resource
An excellent article that discusses the peril of technical debt and poor code quality
https://www.infoq.com/articles/business-impact-code-quality/