Software Principle 9: Enforce Your Boundaries
Most systems of non-trivial scale have multiple components or layers within them. Each one of these components or layers becomes its context.
Often these components communicate to external systems to integrate third-party functionality.
Each one of these components has its interface and boundaries that define it.
Enforcing these boundaries is critical for system health, maintainability, supportability and extendability. System complexity becomes exponential when edges within them start to blur. Keeping clear boundaries within systems allow each sub-system to remain as a separate unit. Without these boundaries, it’s hard to know where one system stops and another starts.
It will create bugs and code that is hard to maintain and nearly impossible to extend without breaking functionality.
There are two types of borders, internal and external boundaries. Within your component, you have control. You know how the data originated, what form and type it takes, among other things.
However, when input is provided to the component, you do not control these factors.
Any developer could have read the interface and consumed it, and now it’s at the mercy of the interpretation of the documentation provided.
You should trust nothing that did not originate within the context of your current control.
Even within your systems, you should always verify that the provided state if it a given boundary. Be mindful of these boundaries and possible misuse.
As an example of boundaries, you should consider where you are storing data. It’s a common belief that you should format data before you store it, and you should. However, you should never trust that the format from the retrieved data on disk is in the same format.
What if some other system wrote to the data store? The format would be different, so you must again sanitize the output to ensure that you’re getting what you expect in both input and output.
External boundaries are the points where your system interacts with another system. Examples are using the file system on the system host or using a third party RESTful API. While these boundaries must also be enforced with input and output validation, they often also have other failure cases.
What if the file doesn’t exist or the API is down? There are many different techniques for solving these issues: exponential backoffs for APIs, circuit breaker patterns for microservices, etc., however, if boundaries aren’t known or explicitly managed then lines between two separate systems can also blur.
Poorly defined and managed boundaries lead to massive failure points within systems, and result in crashes.
These boundaries also, without using proper techniques, are incredibly hard to test. Things, like mocking out the connection to the other system or mocking the system entirely, are used to ensure that tests are still written, but this is at most a facsimile of the real world and shouldn’t be trusted to let you know whether or not your code works in the failure cases.
It’s important to write tests that assert how your code works in these cases, and the failure doesn’t propagate throughout the system. Allowing failures to propagate is a form of blurred boundaries and will cause headaches down the road.
Thus, each component, external or internal, should handle its failure cases elegantly. Alert the interfacing components of this failure in a general way so that they can handle it down-stream the way they need to, which helps keep the boundaries clean.
Keeping Boundaries Clean
Besides ensuring that component specific errors are handled inside the component, what other techniques exist to keep boundaries clean?
Some techniques used to enforce boundaries are:
Providing a client component to the module to act as the boundary itself
Using intermediaries such as message passing or similar techniques to decouple even the communication or knowledge of other modules
Creating an event-based system that allows modules to act accordingly to actions that occur in the system
Using design patterns that will enable higher order objects or functions to control how the modules get used
Ultimately, these are all techniques to create high amounts of decoupling in the system between modules which is the point of enforcing boundaries.
Phrased another way: the more decoupling that exists in the system the less likely that boundaries are going to blur.
Enforcing boundaries within your architecture is essential and creates incredibly maintainable and extendable code.
Too many blurred boundaries within a codebase create what is commonly known as “spaghetti code” and becomes increasingly hard to untangle and fix. Additionally, tangling of modules usually gets worse over time as it becomes unclear where functionality should live, and developers place features and bug fixes in the wrong location.
It’s also crucial to note that starting with a wholly decoupled system may be more complexity than a team or developer can handle. Therefore, it’s important to create boundaries from the start, enforce them in a non-complex way and increase decoupling of the system over time.