Collaborative Development: Bridging the Gap Between Design and Code
As a Software Engineer, have you ever found yourself stuck with questions in the middle of development? Like, you've got the design, started coding, had a good focus for about 30 minutes, and then it hits you: the design overlooks some critical edge cases. Or, maybe you thought you'd considered all possibilities, but once your changes were released, there are edge cases you hadn't found before. Does this sound all too familiar? Well, I can certainly say it's happened to me quite often. 😄
In this blog post, I'd like to share the lessons I've learned over the years on how to avoid these situations and build robust software products. Additionally, I'll illustrate how to ask questions and implement changes using a recent project as an example.
Be a Collaborator
To start, we need to understand this: you can't expect the initial design to have every edge case nailed down before you begin developing a feature. Initial design should primarily focus on usability and viability. This mindset is particularly valuable when concentrating on the happy paths. Once those paths are well-defined, we can expand the flow to accommodate more cases. In my experience, collaborative work between designers and engineers at this stage is far more productive than expecting designers to figure out everything on their own.
Engineers are well-suited to asking "what if" questions, as we're intimately familiar with the codebase and know which cases need specific attention. We deal with conditions like if
and else
all day long. These are the types of questions engineers can ask when reviewing a design:
- "What if the app has no network connection?"
- "What if the user is logged out?"
- "What if ... (insert some potential case combinations)?"
Here are the steps we can take to develop the feature collaboratively with designers between the initial design and development:
- Understand the Design
- Identify Missing Parts
- Improve Iteratively
Case Study: Milestones for Volunteers
Let's delve into a recent project example: "Milestones for Volunteers." This feature celebrates our volunteers' journey with badges and confetti. When you volunteer for the first time or the first five times, we want to recognize this significant milestones and celebrate your contributions.
1. Understand the Design
First, take a look at the initial design:
The initial step is to thoroughly grasp the design. Carefully inspect all screens, design notes, and flowcharts to understand the entire scope. From this, we can say there are four distinct states we'd like to handle. Also, based on the design note, we learn that the confetti remains visible for seven days.
At this point, I could have jumped straight into coding. My code might have looked something like this:
val currentServingBadge: Badge? {
val serve1Badge = badges.firstOrNull { it.guid == SERVE_1_GUID } // First time serving
val serve5Badge = badges.firstOrNull { it.guid == SERVE_5_GUID } // 5 times serving
val daysToShowAchievedState = 7
val today = Clock.System.now().toLocalDateTime(...).date
if (serve1Badge != null) {
if (!serve1Badge.isAchieved) {
return serve1Badge
} else if (serve1Badge.achievedDate.daysUntil(today) <= daysToShowAchievedState) {
return serve1Badge
} else {
if (serve5Badge != null) {
if (!serve5Badge.isAchieved) {
return serve5Badge
} else if (serve5Badge.achievedDate.daysUntil(today) <= daysToShowAchievedState) {
return serve5Badge
}
}
}
}
return ...
}
Wait. Something doesn't feel right. Yes, the nested conditions look gross. Also, I have a nagging feeling that they don't cover all possible cases, since they return early once the initial conditions are met. To account for more cases, I'd need to add even more conditions, making the code messier.
But the more significant issue is that we've implemented the behavior that wasn't addressed in the design explicitly. What if a user achieves both first-time serving and five-time serving on the same day, and seven days haven't passed yet? Based on our code, the first-time serving badge would be visible. This may or may not align with the intended behavior.
So, let's see what we can do to clarify those things. This is a good time to meet with the designer and discuss assumptions while making decisions collaboratively.
2. Identify Missing Parts
One helpful tool we can use is an example timeline.
We've set up a timeline spanning nine weeks. In this example, the user checks in on the 2nd, 4th, 5th, 6th, 7th, and 9th weeks. Obviously, this doesn't cover all potential combinations, but it's a good starting point for understanding different states.
Next, we specify the state of the Serve 1 badge and Serve 5 badge for each date.
After mapping this out, we now have concern of showing confetti for seven days. Since most check-ins occur during the weekend, we want to ensure consistent confetti display into the next week. Depending on when you check-in, you might miss finding it in the app. So, we decided to adjust the duration from seven to ten days.
3. Improve Iteratively
Now that we have a clear definition of behaviors according to the timeline, we can take it a step further by identifying potential combinations our code needs to handle.
For example, following this timeline, you'll never encounter a situation where you achieve both badges on the same day. However, if a person serves multiple times in a day, it's possible to achieve two badges within ten days. This is where a truth table comes in handy for specifying behavior across all possible scenarios.
We can break down these 12 cases based on the progress of the Serve 1 badge and Serve 5 badge.
When no badges are achieved, it's as simple as displaying the Serve 1 badge with no progress.
When Serve 1 badge is achieved, we display the completed Serve 1 badge with confetti.
After ten days, we no longer show the Serve 1 badge. We will show the Serve 5 badge based on its progress.
Once both Serve 1 and Serve 5 badges are achieved, and ten days pass, no badges are displayed.
We mark the cases we don't really think it's possible. This reflects how we manage badges in our CMS. When you check in, both Serve 1 and Serve 5 badges update their progress.
Next, we come to this edge case, where both Serve 1 and Serve 5 badges are achieved, but neither is older than ten days. After having some convo, we decided to show Serve 1 badge. This is not different outcome compared to the initial code we had, but as a team, we gained a deeper understanding of this case and made an intentional choice.
Now, the fun part begins. We can group them by badge type, resulting in this comprehensive overview:
With this in mind, we can write code like this:
val currentServingBadge: Badge? {
val serve1Badge = badges.firstOrNull { it.guid == SERVE_1_GUID } // First time serving
val serve5Badge = badges.firstOrNull { it.guid == SERVE_5_GUID } // 5 times serving
val daysToShowAchievedState = 10
val today = Clock.System.now().toLocalDateTime(...).date
return if (!serve1Badge.isAchieved || serve1Badge.achievedDate.daysUntil(today) <= daysToShowAchievedState) {
serve1Badge
} else if (!serve5Badge.isAchieved || serve5Badge.achievedDate.daysUntil(today) <= daysToShowAchievedState) {
serve5Badge
} else {
null
}
}
We can refactor a bit more to reduce the duplicate code and make it clearer.
val currentServingBadge: Badge? {
val daysToShowAchievedState = 10
val today = Clock.System.now().toLocalDateTime(...).date
return achievements
.filter {
it.guid == SERVE_1_GUID || it.guid == SERVE_5_GUID
}
.firstNotNullOfOrNull {
val achievedOnDate = it.achievedOnDate
if (achievedOnDate == null) {
it // Not achieved
} else if (achievedOnDate.daysUntil(today) <= daysToShowAchievedState) {
it // Achieved within 10 days
} else {
null
}
}
}
And with this truth table, we can write unit tests like the one below:
@Test
fun testCurrentServingBadge() {
// Serve 1 (0%), Serve 5 (0%) => Serve 1
BadgeState(...).let {
assertEquals(serve1, it.currentServingBadge!!)
}
// Serve 1 (100%), Serve 5 (10%) => Serve 1
BadgeState(...).let {
assertEquals(serve1, it.currentServingBadge!!)
}
// Serve 1 (100%) + 10 days, Serve 5 (10%) => Serve 5
BadgeState(...).let {
assertEquals(serve5, it.currentServingBadge!!)
}
...
}
Conclusion
By considering these factors and working collaboratively with the designer, we've managed to create more resilient code with comprehensive test coverage. Additionally, we were able to fine-tune some behaviors along the way.