Provide the best possible Developer Experience through the use of this pattern
Whether you’re building an API that gives developers access to your data, or you’re releasing a new version of your React component, or heck, maybe you’re working on a new MongoDB connector for Node.js, there is one constant problem you need to solve: what happens when a new version comes out with breaking changes?
In other words, how are you planning to release breaking changes without affecting your users? Interacting with an API that changes contracts every few months, or a component that has a new API every release will cause constant re-work on your user’s side, thus providing a terrible user experience.
That has always been a problem but has become even more paramount as independent components are trending as the new way to build web projects. Your components or modules are no longer shared as part of a larger library but as individual modules. A single project may depend on a large number of independent components, making it harder to keep track of breaking changes.
And while the easy solution to this problem is “versioning”, there are many ways of implementing it. Do you use Semver and hope everyone’s aware of it and what it means? Do you use your own versioning scheme and document it online? Perhaps you’re satisfied with listing all breaking changes on a release log on Github?
Let’s take a look at the Expand & Contract pattern, created specifically to handle this scenario and to provide the best possible Developer Experience.
A practical example: updating my component
Let’s take a look at it from a practical PoV: I’m going to update a composite component I created recently using Bit, for a different article talking about independent components and the composite pattern.
My composite component
The current interface for my component right now is this one:
interface ICompositeComponent<T>{
children: ICompositeComponent<T>[];
name: string;
props: T;
addChild(...c: ICompositeComponent<T>[]): boolean;
traverse(fn: (ICompositeComponent)=>void)
}
But the problem is that I’m now realizing that the traverse method makes no sense to have it go through the entire thing. I want it to apply the function fn to a particular node, using the name as ID.
The problem? This is definitely a breaking change. If I were to update that code and release the new version, every developer out there thinking they should be using the latest version “because it’s probably better” would have their code stop working.
Even worse, I would’ve stopped supporting their use case out of the sudden without prior notice. This is terrible DX!
Instead, I can Expand & Contract my code. Let me explain what that means:
There are 3 stages to this pattern: Expand, Adapt and Contract.
The Expand phase
During the first one, the “Expand” phase, I’ll have to add the code I want as my final version but I want it to coexist with the current implementation. Essentially I want to grow (thus the “expand” name) my code base into supporting both versions (the new and the old).
This is not a particularly easy feat to accomplish, depending on the type of project and the technology you’re working with, this can be as simple as providing an overloaded version of a method or as complex as having a single endpoint of an API do two different things.
The point of this phase is that it is NOT a permanent fix. The expanded state is not going to last, but it’ll give your users enough time to go into the Adapt phase without breaking their entire code base without a single warning.
In my case, my component’s traverse method will now change its signature to look like this:
interface ICompositeComponent<T>{
children: ICompositeComponent<T>[];
name: string;
props: T;
addChild(...c: ICompositeComponent<T>[]): boolean;
traverse(fn: (ICompositeComponent)=>void, name?: string)
}
Notice how I added a second, optional, attribute. This attribute is, for the time being, only to be used if present, thus my code would have to adapt into something like this:
Now I’m checking if the name parameter is present and if it is, I’m verifying the name matches, otherwise if it’s not present I’m also applying the function (like I did before).
traverse(fn: (ICompositeComponent)=>void, name?: string)
{
if(this.children.length>0){
this.children.forEach(c=>{
c.traverse(fn,name)
})
}
if((name&&this.name==name)||!name){
//Now we need to check the name
return fn(this)
}
}
This implementation is still valid for the old test cases I had, I can check that by running bit test on my terminal. These tests, as you can see here were only interested in the old behavior (no name is used).
Now I should also add a new test to ensure the new logic also works, so I can add something like this:
it("should iterate over the entire composite and apply the function only to the matching name node",()=>
{
let myHouse:CompositeComponent<HouseComponent>=new CompositeComponent("My House")
let myBedroom: CompositeComponent<HouseComponent>=new CompositeComponent('My bedroom')
let myLivingroom: CompositeComponent<HouseComponent>=new CompositeComponent('My livingroom')
myBedroom.addChild(
new CompositeComponent<HouseComponent>("My bed",{color: "white"}),
new CompositeComponent<HouseComponent>("Chair",{color: "white"})
)
myLivingroom.addChild(
new CompositeComponent<HouseComponent>("My sofa",{color: "red"}),
new CompositeComponent<HouseComponent>("Chair",{color: "red"}),
new CompositeComponent<HouseComponent>("Chair",{color: "yellow"})
)
myHouse.addChild(
myBedroom,
myLivingroom
)
let colors:string[]=[];
myHouse.traverse((current: CompositeComponent<HouseComponent>)=>{
colors.push(current.props?.color)
},"Chair")
expect(colors).toStrictEqual(["white","red","yellow"])
}
)
I am now using my traverse method to capture the color of my chairs. And Bit is telling me that it works:
$ bit test
- loading bit...
- loading aspects...
- running command test [pattern]...
testing total of 1 components in workspace 'my-workspace-name'
testing 1 components with environment teambit.harmony/node
PASS components/composite/composite.spec.ts
Composite component
√ should correctly add a component to the children list (2 ms)
√ should iterate over the full composite children-first (1 ms)
√ should iterate over the entire composite and apply the function only to the matching name node
Test Suites: 1 passed, 1 total
Tests: 3 passed, 3 total
Snapshots: 0 total
Time: 2.033 s
Ran all test suites.
tested 1 components in 3.655 seconds.
Test results for my independent component
I’m ready to release this version now. However, this is not my final version, as I started this journey trying to update the method into not supporting a full traversal anymore.
I can release a new version using Bit by typing:
$ bit tag --all 0.0.3
$ bit export
This will effectively release version 0.0.3 which is still backward compatible. After this release, I’ll allow for the Adoption phase to start.
The Adoption
This is when my users will adapt their code into supporting the new version while still having their current version work. Does that make any sense?
Essentially through the adoption phase, I’m allowing them to “future proof” their code. I can do this by showing them exactly what the new behavior will look like, without removing the old one.
During this phase I will also have to make sure I correctly communicate the future deprecation of the old behavior. I can do that through updates on the documentation, code comments or any other means of notification you can think of. However you choose to do it, remember that this is the time. You’re not just giving your users time to adapt, you’re also buying some time to properly notify them of the future breaking change.
Look at the new version of my docs for this release:
For version 0.0.3 I’ve added a deprecation warning to make sure it’s clear for all users that my traverse method is going to change.
Once you’ve given them enough information and time to adjust, the “Contract” part begins.
The Contract phase
As you can probably imagine, this is where the code changes one more time, and now is when the actual breaking change is published. My example was very basic, but consider having a bigger set of parallel features to be maintained over time.
You can’t live in the Adoption phase for too long, you have to finish what you started, so in the end, you need to remove backward compatibility and clean up your code, API or whatever it is you’re publishing. During the contract phase you’ll strip away all the extra code you added to support both versions. In my case, that looks like this:
traverse(fn: (ICompositeComponent)=>void, name: string){
if(this.children.length>0){
this.children.forEach(c=>{
c.traverse(fn,name)
})
}
//the "name" attribute is now mandatory if(this.name==name){
return fn(this)
}
}
The change is minimal in this example, but you can see how the signature of the method changed now to have the name attribute be mandatory (it didn’t change in the sense of adding extra attributes or anything, that would not be allowed at this point). And the code changed as well, the IF statement is now simpler, since the name attribute is now going to be present all the time.
In fact, with this new change, one of the tests will fail because we’re clearly no longer supporting the old version (calling traverse with only one parameter):
That code is no longer valid, so we need to either update it accordingly or remove the test altogether. Since I’m already testing this method on another test, I’ll remove this one.
And I’m now ready to publish the newest version. However, if you’re using a versioning scheme such as Semver remember to properly update the major version, since we’re definitely pushing a breaking change now.
In my case, since I’m using Bit, I can do that easily by running:
$ bit tag 1.0.0
$ bit export
What does this pattern mean for component development?
This pattern is also known as “ParallelChange”, and I think you can now appreciate why that is. While for big systems, providing a backward and future compatible version can mean a lot of extra work, if you’re dealing with a big user base and your focus is on their experience (as it should be, especially if you’re not the only available provider), then this approach is the best path of action. It provides some interesting benefits:
Better uptime: Either for your service or for the stability of the code of your consumers. Either case, you’re essentially removing the downtime required to adapt to your new version by giving them enough time to have both in parallel.
Better testing of the new feature. During the adoption phase, your users will be able to test how the new logic works without having to sacrifice functionality on their side (the old version is still available). This even gives them the ability to roll back to the old behavior during problems with the new feature.
Better time-to-market. By implementing this pattern you can gradually provide extra functionality to your users, thus giving them the chance to build their product on top of your evolving code base without ever release a breaking change they couldn’t adapt to.
Consumers of your services can also adopt this pattern by implementing a feature flag (or feature toggle) during the adoption phase. That way they can decide which version to use during that phase without worrying about the release date of your “contract” phase. This essentially decouples their final implementation from yours and as long as you keep to the contract you’ve promised during the “Expand” phase, your users will be ready.
Source: Medium, Bits and pieces
The Tech Platform
Commentaires