How to decouple any system

Terrick Mansur
6 min readJan 19, 2021

In this article, I will show you how to decouple any system from the rest of your codebase. By doing this, your "decoupled" system will be fully testable and much easier to understand.

`Photo by freestocks on Unsplash

What does it mean for a system to be decoupled, and why is this a good thing?

For a system to e decoupled means that that system does not depend on any other system for it to perform its task. If this is true, no other system can change the behavior or state of the decoupled system. This makes it very testable, predictable, and easy to understand.

Sometimes it's OK to have two systems that are closely related to be tightly coupled, but when you start getting three, fours, five, or more, systems that are tightly coupled, it becomes very difficult to remove, change or adjust that system. This becomes a problem when the business wants to make changes to one system, but in the code, we end up having to change multiple systems that are tightly coupled to it. This is risky and generally very receptible to introducing new bugs.

Example code

In this section, we will go over a simple viewModel that we will completely decouple.

Explanation:

The initializer
Reads the value stored in the UserDefaultSytem with the key name and sets value property to the retrieved value.

The updateValue function
Takes in a string, checks if the string is valid by using the StringValidator class, if the string is valid, it sets the variable canSubmit to true, else it will be set to false.

The submit function
Does a paranoid check to confirm, that the string is indeed valid. If this is true, we store the string value in our UserDefaultSytem using the key name. The function returns a boolean indicating if the value was stored in or not.

Detecting tight coupling.

There are three questions we need to ask ourselves when trying to find coupling in our system, you can also use these questions when reviewing code.

Ask yourself for each line of code;

  1. Do we own what we are writing to?
  2. Do we own what we reading from?
  3. Do we own the function we are calling?

If the answer is NO to any of these questions, for any line of code we are reviewing, that line is coupled to the system that DOES own what we are writing/reading to, or calling the function of.

Let's look at each of the lines that answers "NO" to any of our three questions.

line 8:

value = UserDefaultSytem.shared.value(forKey: “name”) as? String ?? “”

On this line, we are writing to value, this is not a problem ✅. However, we are reading from UserDefaultSystem, since we don't own UserDefaultSystem this line couples our viewModel to UserDefaultSystem. We will come back and fix this later.

line 12:

canSubmit = StringValidator.isValid(value)

Here we are writing to canSubmit , since we own it, this is not a problem. However, we are calling the StringValidator.isValid function. We do not own this function. We will also need to fix this line of code.

line 17:

guard StringValidator.isValid(value) else {

Again on this line, we are calling the StringValidator.isValid function. We do not own this function. We will need to fix this as well.

line 21:

UserDefaultSytem.shared.setValue(value, forKey: "name")

On this line of code, we are writing to UserDefaultSystem we will also need to fix this line.

Now that we have detected the four lines of code that we need to fix, let's actually fix them.

Decoupeling your code

Now comes the fun part, actually solving the problem.

Define your Environment
The first step is to define a sub-type of your viewModel called Evironment. This will represent everything that is outside of the viewModel that it interacts with.

struct Environment { ... }

Secondly, we want to pass an instance of Environment to the initializer of the viewModel and keep a copy of it.

private let environment: Environmentinit(environment: Environment) {
self.environment = environment
...
}

Fixing line 8

The issue with line 8 is that we are reading from UserDefaultSystem, meaning that this class now depends on UserDefaultSystem existing in order for it to do its work. But if we think of it, the viewModel only needs a default value for its property value where it comes from should not matter. The way we fix this is by declaring a function variable in our Environment struct that returns a string. Pay attention! We are declaring a function variable, not a function, this is an important distinguishment.

struct Environment {
let defaultName: () -> String
}

Now instead of reading the default name directly from the UserDefaultSystem, we can read it from our Environment.

...
value = environment.defaultName()
...

Fixing line 12

On line 12 we are calling a function that belongs to StringValidator. Instead of referencing StringValidator directly, we can move this function to our Environment as well.

struct Environment {
...
let isValid: (_ value: String) -> Bool
}

Fixing line 17

Since line 17 is using the same isValid function, we can re-use the Environments isValid.

guard environment.isValid(value) else {

Fixing line 21

On line 21 we are writing to the UserDefaultSystem. This can also be moved to the Environment.

struct Environment {
...
let setDefaultName: (_ value: String) -> Void
}

environment.setName(value)

And we are done!

Our viewModel now look like this:

Our Decoupled viewModel now does not depend on any other system in your code-base. You only need an instance of it’s Environment to create it and you're done!

Even though from the viewModel‘s perspective we do not care how the Environment is implemented, here is what it would look like with the changes above.

let environment = DeCoupledViewModel.Environment(
defaultName: { UserDefaultSytem.shared.value(forKey: "name") as? String ?? "" },
isValid: { StringValidator.isValid($0)},
setName: { UserDefaultSytem.shared.setValue($0, forKey: "name")}
)

Why do this?

This setup brings a lot of benefits to your code.

The first reason we already went over, the coupling of your viewModel now happens outside of the viewModel itself, meaning if something changes in any of these systems (UserDefaultSytem, StringValidator) we don’t need to change the implementation of the viewModel, only how the Environment is initialized. This drastically reduces the chance of introducing new bugs when other systems change.

The second reason is for unit testing. You hear people say “Remember clean up after every test”. Unless you're testing a caching system or something that persists in your application, you should never need to clean up anything. The reason you need to “Clean up” is because the thing you are testing is changing a state that does not belong to them, and other tests might be doing the same. This causes conflict between the tests. Here is an example unit test for “Setting the UserDefault name”.

Now your unit tests do not interact with any other system. And it should not, it's a unit test, not an integration test.

The third reason (and my favorite one), is readability. Your Evironment struct now serves as a summary of what your viewModel is doing. You don’t need to read the entire implementation to see how your viewModel is interacting with the rest of the system. The Evironment gives you an overview of that. Simply by looking at the Evironment struct, I can see that this viewModel is “reading a default name”, “check if a given string is valid”, and “setting a name”. This helps a lot to give you some idea of what to look for in the implementation when reviewing the code.

Take away

Even though our example is a simple viewModel, you can use this pattern for any system regardless of what the system is doing. If the system is interacting with another system, (all of them do) you can use the Environment pattern to de-couple the implementations from one another and clearly define where and how they interact. This usually happens in some form of “Factory function”.

Thanks for reading

If you liked this article please follow me and show your support with a clap 👏

Get in touch:

--

--