Let's continue our series of short posts about code refactoring! In it, we discuss technics and tools that can help you improve your code and projects.
Today we will talk about how to set clear boundaries between modules and limit the scope of changes when refactoring code.
Ripple Effect Problem
One of the most annoying problems in refactoring is the ripple effect problem. It's a situation when changes in one module “leak” into other (sometimes far distant) parts of the code base.
When the spread of changes isn't limited, we become “afraid” of modifying the code. It feels like “everything's gonna blow up” or “we're gonna need to update a lot of code”.
Most often the ripple effect problem arises when modules know too much about each other.
High Coupling
The degree to which one module knows about the structure of other modules is called coupling.
When the coupling between modules is high, it means that they rely on the internal structure and implementation details of each other.
That is exactly what causes the ripple effect. The higher the coupling, the harder it is to make changes in isolation to a particular module.
Take a look at the example. Let's say we develop a blogging platform and there's a function that creates a new post for the current user:
import { api } from 'network'
async function createPost(content) {
// ...
const result = await api.post(
api.baseUrl + api.posts.create,
{ body: content });
// ...
}
The problem with this code lies in the network
module:
- It exposes too many of its internal details to other modules to use.
- It doesn't provide the other modules with the clear public API that would guide them in how to use the
network
module.
We can fix that if we make the boundaries between the modules clearer and narrower.
Unclear and Wide Boundaries
As we said earlier the root cause of the ripple effect is coupling. The higher the coupling, the wider the changes spread.
In the example above, we can count how tightly the createPost
function is coupled with the network
module:
import { api } from 'network' /* (1) */
async function createPost(content) {
// ...
const result = await api.post( /* (2) */
/* (3) */ api.baseUrl + api.posts.create, /* (4) */
/* (5) */ { body: content });
// ...
}
/**
* 1. The “entry point” to the `network` module.
* 2. Using the `post` method of the `api` object.
* 3. Using the `baseUrl` property...
* 4. ...And the `.posts.create` property to build a URL.
* 5. Passing the post content as the value for the `body` key.
*/
This number of points (5) is way too many. Any change in the api
object details will immediately affect the createPost
function.
If we assume that there are many other places where the api
object is used, all those modules will be affected too.
The boundary between createPost
and network
is wide and unclear. The network
module doesn't declare a clear set of functions for consumers (like createPost
) to use.
We can fix this using contracts.
API Contracts
A contract is a guarantee of one entity over others. It specifies how the modules can be used and how they can't.
Contracts allow other parts of the program to rely not on the module's implementation but only on its “promises” and to base the work on those “promises.”
In TypeScript, we can declare contracts using types and interfaces. Let's use them to set a contract for the network
module:
type ApiResponse = {
state: "OK" | "ERROR";
};
interface ApiClient {
createPost(post: Post): Promise<ApiResponse>;
}
Then, let's implement this contract inside the network
module, only exposing the public API (the contract promises) and not revealing any extra details:
const client: ApiClient = {
createPost: async (post) => {
const result = await api.post(
api.baseUrl + api.posts.create,
{ body: post })
return result
}
};
We concealed all the implementation details behind the ApiClient
interface and exposed only the methods that are really needed for the consumers.
By the way, it can remind you about the “Facade” pattern or “Anti-Corruption Layer” technic.
After this change, we'd use the network
module in the createPost
function like this:
import { client } from 'network'
async function createPost(post) {
// ...
const result = await client.createPost(post);
// ...
}
The number of coupling points decreased now to only 2:
import { client } from 'network' /* (1) */
async function createPost(post) {
// ...
const result = await client.createPost(post); /* (2) */
// ...
}
We don't rely on how the client
works under the hood, only on how it promises us to work.
It allows us to change the internal structure of the network
module how we want. Because while the contract (the ApiClient
interface) stays the same the consumers don't need to update their code.
By the way, contracts aren't necessarily a type signature or an interface. They can be sound or written agreements, DTOs, message formats, etc. The important thing is that these agreements should declare and fixate the behavior of parts of the system toward each other.
It also allows to split the code base into distinct cohesive parts that are connected with narrow and clearly specified contracts:
This, in turn, lets us limit the spread of changes when refactoring the code because they will be scoped inside the module:
More About Refactoring in My Book
In this post, we only discussed the coupling and module boundaries.
We haven't mentioned the other significant part of this topic, which is cohesion. We skipped the formal definition of a contract and haven't discussed the Separation of Concerns principle, which can help us to see the places where to draw these boundaries.
If you want to know more about these aspects and refactoring in general, I encourage you to check out my online book:
The book is free and available on GitHub. In it, I explain the topic in more detail and with more examples.
Hope you find it helpful! Enjoy the book 🙌
Top comments (0)