The Art of Managing Team Exits: Lessons for Engineering Leaders

As engineering leaders, we often focus on hiring, developing, and retaining talent. However, an equally important aspect of team management is handling exits – both voluntary and involuntary. How we manage these transitions can significantly impact our team’s morale, productivity, and long-term success. Let’s explore the dos and don’ts of managing team exits.

When Things Go South: Handling Involuntary Exits

People may forget many things about their job, but the day they’re let go is etched in their memory forever. Every minute of that day counts, so it’s crucial to handle these situations with utmost care and respect.

Show Leadership and Respect

As a leader, it’s your responsibility to handle difficult conversations directly. Don’t hide behind HR or the People Team. Show up, be present, and demonstrate genuine respect for the individual, regardless of the circumstances leading to their exit.

Consider the Ripple Effect

Remember, the exiting employee likely has social connections within your team. Whatever they experience during their exit process will be shared with their colleagues. If they leave feeling disrespected or unfairly treated, it can bring down the morale of your entire team.

Make the Best of a Bad Situation

While letting someone go is never pleasant, you can strive to make the process as respectful and supportive as possible. Offer guidance on next steps, provide honest and constructive feedback, and if appropriate, offer to serve as a reference for future opportunities.

For a deeper dive into handling these challenging situations, I highly recommend Ben Horowitz’s book, “The Hard Thing About Hard Things.” It offers valuable insights on navigating the toughest aspects of leadership. It’s mandatory reading for all my managers.

The Positive Approach: Planning for Voluntary Transitions

On the flip side, it’s important to recognize that people won’t (and shouldn’t) stay on your team forever. Variety in one’s career is healthy and contributes to personal and professional growth.

I was once in a meeting with a group of managers and HR about an engineer that resigned. And the person from HR was explaining the exit interview feedback they said “He said he left because he was on teh same team for 8 years, and nothing was changing”, his manager had missed this conversation with him in 1on1s, and we lost a good engineer, after that we got regular reports from HR about team tender to make sure we were addressing it. And as a Director I make a habit of pushing my managers to have conversation about how their directs are felling on the team, if they fell like a change, especially if they’ve been on the same team for 3-4 years.

If you have an engineer leave for another company to progress their career, it will send the wrong message to the other engineers on their team.

So plan for people to transition to other teams within your organization. Help them find roles that align with their future aspirations and development goals. This approach not only supports individual growth but also retains valuable talent within the company.

And for any engineer who’s been on your team for about three years, start having conversations about their future aspirations. Are they still finding the work challenging? Would they like to try something different? The exact timing may vary based on the individual and the nature of the work, but don’t let these conversations slide. In my experience, most people who’ve been in the same role for 8+ years are likely contemplating a significant change.

It’s far better to keep a good engineer within your organization, even if they move to a different team, than to lose them to another company. An internal move is usually less disruptive than an external exit. Moreover, you want to cultivate an environment where people feel they can grow their careers within the organization, rather than feeling they need to leave to progress.

Mark the Moment

Whether someone is moving to another team or leaving the company, always mark the moment with your team. Celebrate their contributions, share memories, and wish them well in their future endeavors at a team lunch or make an occasion somehow for this. This not only honors the departing team member but also reinforces a positive team culture.

Conclusion

Managing exits, whether voluntary or involuntary, is a crucial leadership skill. By handling these situations with respect, foresight, and empathy, you can maintain a positive team culture, support individual growth, and contribute to the overall health of your organization. Remember, how people leave your team is just as important as how they join it. Make every exit a testament to your leadership and your team’s values.

Managing Dependency Conflicts in Library Development: Advanced Versioning Strategies

In the complex ecosystem of software development, managing dependencies is a challenge that often leads developers into what’s infamously known as “dependency hell.” This post explores the problems arising from traditional dependency management approaches and presents advanced versioning strategies as solutions.

The Problem: Dependency Hell

Let’s consider a common scenario that many developers face:

You’re working on an application, MyApp, which uses a logging library called PrettyLogs version 1.6.3. PrettyLogs:1.6.3 depends on Platform.Logging:2.3.2. This setup requires MyApp to use the exact version 2.3.2 of Platform.Logging.

Now, imagine you want to upgrade to a newer version of Platform.Logging. You might try to install a higher version directly in MyApp, hoping it remains backward compatible with PrettyLogs:1.6.3. However, the situation becomes more complex if MyApp also uses other libraries that depend on different versions of Platform.Logging.

This scenario often leads to version conflicts, where different parts of your application require incompatible versions of the same dependency. Attempting to resolve these conflicts can be time-consuming and frustrating, often resulting in compromises that can affect the stability or functionality of your application.

Moreover, as your application grows and incorporates more libraries, each with its own set of dependencies, the problem compounds. You might find yourself in a situation where upgrading one library necessitates upgrades or downgrades of several others, creating a domino effect of version changes throughout your project.

This is the essence of “dependency hell” – a state where managing the intricate web of library versions becomes a major obstacle to development progress and software stability.

The Solution: Advanced Versioning Strategies

To address these challenges, we need to move beyond simple Semantic Versioning (SemVer) and adopt more sophisticated strategies. Here are several approaches that can help mitigate dependency conflicts:

1. Abstraction Libraries

One effective strategy is to introduce an abstraction or interface library that remains stable over time. Let’s revisit our earlier example:

Instead of PrettyLogs:1.6.3 depending directly on Platform.Logging:2.3.2, we create Platform.Logging.Abstractions:2.0.0, which contains only the public interfaces and essential data models.

Now, the dependency structure looks like this:

  • PrettyLogs:1.6.3 depends on Platform.Logging.Abstractions:2.0.0
  • MyApp depends on Platform.Logging:2.3.2, which implements Platform.Logging.Abstractions:2.0.0

This abstraction layer provides several benefits:

  1. PrettyLogs can work with any version of Platform.Logging that implements the 2.0.0 version of the abstractions.
  2. MyApp can upgrade Platform.Logging independently, as long as it still implements the required abstraction version.
  3. Different libraries can use different versions of Platform.Logging within the same application, as long as they all implement the same abstraction version.

Here’s how the file structure might look:

Platform.Logging.Abstractions (v2.0.0)
 ├── ILogger.cs
 └── LogLevel.cs

Platform.Logging (v2.3.2)
 ├── Logger.cs (implements ILogger)
 └── (other implementation details)

2. Dependency Injection of Abstractions

Building on the abstraction library concept, we can use dependency injection to further decouple our code from specific implementations. Instead of creating concrete instances of loggers, we depend on abstractions:

public class MyService
{
    private readonly ILogger _logger;
    
    public MyService(ILogger logger)
    {
        _logger = logger;
    }

    public void DoSomething()
    {
        _logger.Log(LogLevel.Info, "Doing something");
    }
}

This approach allows the concrete logger implementation to be injected at runtime, providing flexibility and reducing direct dependencies.

3. Adapter Pattern for Library-Specific Implementations

For popular third-party libraries, we can provide adapter implementations in separate packages. This allows consumers to choose which concrete implementation to use. Our package structure might look like this:

Platform.Logging
Platform.Logging.Serilog
Platform.Logging.NLog

This structure allows users to plug in their preferred logging framework while still adhering to our abstraction.

4. Minimum Version Specification

When referencing specific libraries, specify the minimum version required rather than an exact version. This gives consumers more flexibility in resolving version conflicts. For example, in a .NET project file:

<PackageReference Include="Platform.Logging" Version="2.3.2" />

could become:

<PackageReference Include="Platform.Logging" Version="[2.0.0, )" />

Conclusion

By implementing these advanced versioning strategies, we can significantly reduce the pain of dependency management. Abstraction libraries provide a stable interface for consumers, dependency injection increases flexibility, adapters allow for easy swapping of implementations, minimum version specifications provide upgrade flexibility, and feature flags enable gradual feature adoption.

These approaches help create a more robust and maintainable ecosystem of libraries and applications. They allow for easier upgrades, reduce conflicts between dependencies, and provide a clearer path for evolving software over time. While they require more upfront design and thought, the long-term benefits in terms of maintainability and user satisfaction are substantial.

Remember, the goal is to create libraries and applications that can evolve smoothly over time, providing new features and improvements without causing undue pain for their consumers. By thinking beyond simple versioning and considering the broader ecosystem in which our software exists, we can create more resilient and adaptable systems.

Mastering Execution in Engineering Teams: From Formation to Delivery

In the fast-paced world of software development, execution is everything. It’s not just about writing code; it’s about forming effective teams, collaborating across departments, focusing on outcomes, and managing technical debt. Let’s dive into these crucial aspects of engineering execution.

The Art of Forming Teams and Structures

When it comes to team formation, the old adage rings true: “Adding manpower to a late software project makes it later,” as famously stated in Brooks’ Law. This counterintuitive principle reminds us that team dynamics are complex and that simply adding more people doesn’t necessarily speed things up.

Understanding the stages of team formation is crucial. The Forming/Storming/Norming/Performing model, developed by Bruce Tuckman, provides a useful framework. In my experience, the Forming and Storming stages usually take a minimum of 2-3 sprints. If you’re struggling with these initial stages, consider reducing your sprint cadence to give the team a short reflection period on their working process to drive process change faster.

Here are some key principles for effective team structure:

Longevity should be a priority when structuring your engineering teams. Teams should be viewed as long-term investments rather than temporary assemblies. Even when headcount calculations don’t align perfectly, resist the urge to disband established teams. The relationships, shared knowledge, and mutual understanding that develop over time are invaluable assets that can’t be easily replicated. A team that has worked together for an extended period will often outperform a newly formed team, even if the latter looks better on paper.

Independence is another crucial factor in team effectiveness. Strive to create teams that possess all the skills necessary to execute their projects without constant handoffs to other teams. This autonomy not only boosts efficiency by reducing communication overhead and wait times but also increases accountability. When a team has end-to-end ownership of a project or feature, they’re more likely to take pride in their work and ensure its success.

Lastly, system ownership plays a vital role in team engagement and performance. In my experience, teams should have clear ownership over specific systems or components within your technology stack. This ownership fosters a deep understanding of the system and a sense of responsibility for its performance and evolution. Conversely, teams without any system ownership often struggle to appreciate impact of the technical debt they introduce and may lose respect for the value of the systems they interact with. By giving teams ownership, you’re not just assigning responsibility; you’re teaching a team about how to responsibly manage technical debt, as they are ultimately going to be then one’s responsible for it in their own system.

The Diplomacy of Inter-Team Collaboration

Working with other teams is an essential skill in any large organization, and it requires a strategic approach rooted in understanding human behavior and organizational dynamics. One crucial concept to keep in mind is what I like to call “Game Theory in Action.” When seeking collaboration with other teams, always consider the question, “What’s in it for me?” from their perspective. It’s a natural human tendency for individuals and groups to act in their own interest, and engineering teams are no exception. By anticipating this mindset, you can proactively address the needs and motivations of other teams, making collaboration more likely and more fruitful. This doesn’t mean being manipulative; rather, it’s about finding genuine win-win scenarios that benefit all parties involved.

Another key aspect of successful inter-team collaboration is the cultivation of informal networks within your organization. As a leader, one of your roles is to help your team build what I call an “irregular social network” that extends beyond the formal organizational structure. Encourage your team members to connect with colleagues from other departments, attend cross-functional meetings or events, and engage in casual conversations with people outside their immediate circle. These informal connections can be invaluable for smooth collaboration and problem-solving. They create channels for quick information exchange, foster mutual understanding, and often lead to creative solutions that might not emerge through formal channels alone. By building these networks, your team will be better positioned to know more about what’s going on within the org, and share more in common solutions to problems, in small organizations this isn’t as important as in large ones.

Shifting Focus: From Output to Outcome

It’s easy to get caught up in metrics like story points, sprint completion rates, or hours logged. However, these are merely measures of output, not outcome. Your true measure of success should be the business value your team delivers.

I once had a conversation with one of my engineers about changing the way the calculate carrier over work, I told him a half done story is “not done” and should count to zero for sprint completion, ultimately making their completion rate lower and closer to actual “completion”, his response was “But my points!”, he was fixated on his story points being his sole measure of success and was ignoring the actual value the team was delivering to the business.

Keep your engineers connected to the value they’re creating. Don’t let product management focus solely on “feature” or “milestone” success without tying it to measurable business value. If you do, you risk falling into the trap of DDD (Deadline Driven Development).

Remember Dan Pink’s insights on motivation: autonomy, mastery, and purpose are key drivers. By connecting your team’s work to real business outcomes, you’re providing that crucial sense of purpose.

Dan Pink, what motivates people

The Balancing Act of Technical Debt Management

Managing technical debt is a critical part of long-term success in software engineering, and it requires a strategic approach. One principle I’ve found effective is what I call the “30% Rule.” This involves allocating about 30% of your team’s time for technical improvements. While it might seem like a significant investment, especially when faced with pressing feature demands, this dedication to ongoing improvement pays substantial dividends in the long run. It helps prevent the accumulation of technical debt that can slow down development and increase the risk of system failures.

Why 30%? I asked Yaron Zeidman this once, who taught me this, and his response was, “Joel, I’ve worked in companies where we tried 20%, and we found that we weren’t able to keep on top of debt and technical improvements we needed, and i worked in companies where we tried 40%, and we found we weren’t able to execute on product enough, so 30% seems to be the happy middle ground.”.

Time-boxing is another powerful technique for addressing technical debt. One approach I’ve seen work well is the use of “Mobathons” – intensive periods focused solely on tackling technical debt or improvements. See this post about them.

Another instance, I once worked with a team that implemented a “60% leap sprint,” where the majority of a sprint was dedicated to making significant progress on technical debt, and every other sprint was 100% product work. These focused efforts can create momentum and visible progress, boosting team morale and improving system health.

If you try to do every sprint exactly 70/30 split, it almost never works out well.

One of the most important principles in managing technical debt is to finish what you start. It’s all too easy to let the tail end of technical migrations drag on for years, but this approach can be costly. The longer legacy systems remain in place, the more their costs grow, and the more significant their impact becomes. By seeing migrations through to completion, you can fully realize the benefits of your work and avoid the compounding costs of maintaining legacy systems.

When it comes to system design and development, thinking small can yield big benefits. Building small, modular systems allows for incremental improvement and quicker realization of value, for example framework upgrades such as reactjs or other frameworks, need to be done at system level, for a single large system it becomes an all in effort, if you have 10 smaller systems you can do one and measure the value in an increment, validate assumptions, to help you re-prioritize before continuing. This approach not only makes it easier to manage and update your systems but also allows for more frequent deliveries of value to your users and engineers.

While technical debt may seem like a purely engineering concern, it’s crucial to include product management in these discussions. Getting buy-in from product managers on your technical work can be tremendously beneficial. Not only can they help you ask the right questions about the business impact of technical decisions, but they can also become powerful allies in advocating for necessary technical work.

Finally, don’t hesitate to escalate when necessary. If technical debt is severely impacting your team’s ability to deliver, it’s time to have a serious conversation with product management and leadership. Work together to pitch for more headcount or resources. Remember, addressing technical debt isn’t just about engineering preferences – it’s about maintaining the health and efficiency of the systems that drive your business.

Conclusion

Effective execution in engineering teams is a multifaceted challenge. It requires thoughtful team formation, skilled inter-team collaboration, a focus on meaningful outcomes, and diligent technical debt management. By mastering these areas, you can create a high-performing engineering organization that consistently delivers value.

Remember, the goal isn’t just to write code or complete sprints. It’s to create systems and products that drive real business value. Keep this north star in mind, and you’ll be well on your way to engineering excellence.

Mastering Performance Management in Engineering Teams

As engineering leaders, one of our most critical responsibilities is effectively managing and developing our team’s performance. This goes beyond simply tracking metrics or conducting annual reviews. It’s about creating a culture of continuous improvement, open communication, and clear expectations. Let’s dive into some key aspects of performance management that can help you elevate your team’s effectiveness and job satisfaction.

The Art of Feedback

Feedback is the lifeblood of performance management. It should flow freely within your team, not just from manager to engineer. Many organizations offer training to help team members give and receive feedback effectively. As a manager, aim to provide feedback to your engineers at least biweekly. While technical feedback is important, don’t get too caught up in the technical details. Focus on broader aspects of performance and development.

A word of caution: be wary of feedback that’s overly positive or non-actionable. While positivity is great, feedback should always include areas for improvement or specific actions to maintain high performance. Remember, the goal is growth, not just praise.

Setting Behavior Expectations

When it comes to performance management, we often fall into the trap of creating “to-do” lists for promotion. However, what we’re really after is a change in mindset. We want our team members to be self-motivated, incorporating best practices into their daily work not because they’re chasing a promotion, but because it’s become part of their professional identity.

But how do we measure or change someone’s mindset? The truth is, we can’t directly measure it. However, the behaviors people exhibit serve as an excellent proxy. By setting expectations around day-to-day behaviors, especially in engineering-specific scenarios, we can create goals that foster the mindset we’re after.

This approach is inspired by Ben Horowitz’s famous “Good PM, Bad PM” blog post, which applied similar principles to product managers in the 90s and 00s. By focusing on behaviors rather than just outcomes, we create a culture of continuous improvement that becomes ingrained in daily routines.

The Power of Coaching

Effective coaching is a cornerstone of performance management and a critical skill for any engineering leader. It’s not just about solving problems for your team members; it’s about empowering them to solve problems themselves and grow in the process.

The Socratic Method: Questions as a Tool for Growth

One powerful approach to coaching is the Socratic method. Named after the classical Greek philosopher Socrates, this method involves asking probing questions to stimulate critical thinking and illuminate ideas. Instead of simply telling your team members what to do, ask questions that guide them to their own conclusions.

For example, if an engineer is struggling with a complex bug, instead of immediately offering a solution, you might ask:

  • “What have you tried so far?”
  • “Where do you think the problem might be originating?”
  • “What would be the impact if we approached it this way?”

This approach not only helps team members develop problem-solving skills but also increases their confidence and buy-in for the solutions they come up with. It transforms the coaching process from a one-way directive into a collaborative exploration.

The Importance of Explicit Language

Explicit Language isn’t about swearing, but on rare occasions that helps, but that’s a topic for another post.

When coaching, the clarity of your communication is paramount. Use explicit language to ensure your message is understood clearly. Be specific about what you’re observing, what needs to change, and what success looks like. Vague feedback or instructions can lead to confusion and frustration.

For instance, instead of saying “Your code needs improvement,” you might say “I noticed that the function on line 57 is handling multiple responsibilities. Let’s discuss how we can refactor this to improve its single responsibility and readability.”

Coaching for Technical and Soft Skills

While technical skills are crucial in engineering, don’t neglect coaching on soft skills. Leadership, communication, and collaboration are equally important for career growth. Help your team members identify areas for improvement in both technical and soft skills, and provide targeted coaching or resources for each.

The Continuous Nature of Coaching

Remember that coaching is not a one-time event, but a continuous process. Make it a regular part of your interactions with your team. This could be through scheduled one-on-one sessions, impromptu conversations, or even in the context of code reviews or project discussions.

By embracing the power of coaching, you’re not just solving immediate problems; you’re building a team of self-sufficient, confident engineers who are equipped to handle future challenges. This approach to leadership can dramatically improve team performance, job satisfaction, and overall success in your engineering organization.

Career Development: A Collaborative Effort

Career development should be a collaborative process between you and your team members. Start by creating individual development plans, or getting them to create them scales more and will have more meaning for them. These should be breathing documents that outline goals, areas for improvement, and action steps.

As a manager, it’s your job to provide opportunities for training and upskilling. Remember, you won’t always be the one who can directly train your team members. Most of the time, your role will be to identify and facilitate learning opportunities, whether that’s through courses, conferences, or mentorship programs.

Both you and your team members should have a clear understanding of what’s needed to reach the next level. An exceptional manager has a good sense of when all of their direct reports will be ready for their next promotion. This foresight allows you to provide targeted development opportunities and set realistic expectations.

Conclusion

Effective performance management is about more than just evaluating work. It’s about creating an environment where feedback flows freely, expectations are clear, and everyone is committed to continuous improvement. By focusing on behaviors, providing regular feedback, coaching effectively, and collaboratively planning career development, you can create a high-performing team that’s not just productive, but also engaged and satisfied in their work.

Remember, the goal of performance management isn’t just to improve output—it’s to help each team member grow, both professionally and personally. When done right, it’s a powerful tool for building an engineering team.

Growing Your Engineering Team: Leadership, Empathy, and Growth

Building a strong engineering team doesn’t stop at hiring. As leaders, we must continually nurture our teams to ensure they grow, feel connected, and perform at their best. This post explores key strategies for fostering a thriving team environment.

The Power of Empathy and Connection

One of the most underrated skills in technical leadership is the ability to connect with your team members on a personal level. This goes beyond discussing code or project deadlines. To build stronger connections, use your one-on-one meetings wisely. If you find yourself with spare time during these sessions, take the opportunity to ask about your team members’ weekends or personal interests. Having genuine curiosity about their lives outside of work can foster a deeper, more meaningful relationship.

It’s crucial to understand the whole person behind the engineer. Take an interest in who your team members are beyond their technical skills. Explore what motivates them and what aspirations they hold for their future. This holistic approach to understanding your team can provide valuable insights into how to best support and motivate each individual.

Remember that each team member is unique, with their own personality traits, communication preferences, and working styles. As a leader, it’s your responsibility to recognize and appreciate these differences. Tailor your communication and management style to suit different personality types within your team. This flexibility in your approach can lead to more effective leadership and a more harmonious team dynamic. This is especially important in multi-cultural environments.

By fostering these connections and showing genuine interest in your team members as individuals, you create a more engaged, loyal, and motivated team. This personal touch in your leadership style can make a significant difference in team morale and overall performance.

Leadership That Inspires

True leadership is about making your team feel part of something bigger than themselves. As a leader, one of your primary responsibilities is to regularly acknowledge how the team’s work contributes to broader company goals. By consistently highlighting the impact of their efforts, you help your engineers understand that they’re not just writing code or solving technical problems – they’re part of a meaningful mission that extends beyond their immediate tasks.

Team events and gatherings play a crucial role in fostering this sense of purpose and belonging. However, it’s important to approach these events strategically. Don’t organize team outings simply because you have a budget to use. Instead, use these gatherings as opportunities to mark important moments in your team’s journey. Celebrate significant milestones, warmly welcome new team members, or bid a heartfelt farewell to departing colleagues. These rituals do more than just break up the workweek – they create a tangible sense of team identity and reinforce the idea that each member is part of something special.

When you do host these events, make sure to take an active role. As a leader, your presence and words carry significant weight. Take the time to stand up and say something meaningful. This doesn’t have to be a long, formal speech – even a brief, heartfelt message can significantly boost team morale and cohesion. Your words can reinforce the team’s achievements, emphasize shared goals, or simply express appreciation for everyone’s hard work.

Remember, at their core, people want to belong to something greater than themselves. As a leader, it’s your responsibility – and privilege – to create and nurture that sense of belonging. By celebrating team impact, marking important moments, and being present and vocal at team events, you create an environment where your engineers feel valued, connected, and inspired to do their best work.

The Cornerstone of Respect

Respect is the foundation of any healthy team dynamic, and as a leader, it’s crucial that you exemplify and foster respect in every interaction. One simple yet powerful way to demonstrate respect is through consistent eye contact. When engaging with your team members, whether in one-on-one conversations or group meetings, make a conscious effort to maintain eye contact. This seemingly small gesture speaks volumes – it shows that you’re fully present, engaged, and value what the other person is saying. It’s a non-verbal cue that can significantly enhance the quality of your interactions and build stronger, more respectful relationships within your team. Be careful not to over do it though as you may intimidate people.

As a leader, you’ll inevitably face difficult conversations about topics like compensation, performance, or organizational changes. In these moments, it’s tempting to delegate these discussions to HR or hide behind company policies. However, true respect means having the courage to handle these hard conversations personally. By taking ownership of these discussions, you show your team members that you value them enough to engage directly, even when the topic is challenging. This approach builds trust and demonstrates that you take your leadership role seriously, setting a tone of openness and honesty within your team. It’s the hard part of the job, but it’s still part of the job, is how I look at it.

Another critical aspect of respect in leadership is following through on your commitments. When you make promises contingent on certain expectations being met, it’s vital that you deliver on those promises when your team meets those expectations. This means being explicit about your expectations from the start and then honoring your word when those conditions are fulfilled. For example, if you promise someone a salary bump if they meet a target, be clear about what month and have a dollar amount communicated explicitly, you maybe have certain months of the year that salary can be adjusted, your engineer probably doesn’t know that and be expecting a bump the month he delivers.

Consistency between your words and actions is key to building and maintaining respect. It shows your team that you’re reliable and that their efforts are truly valued and rewarded.

By incorporating these practices you create a culture of respect within your team. This respect forms the bedrock of a high-functioning, motivated, and loyal engineering team. Remember, respect isn’t just about being polite; it’s about consistently demonstrating through your actions that you value each team member’s contributions, thoughts, and feelings.

Navigating Conflict Constructively

Conflict, when managed effectively, can be a powerful catalyst for growth and innovation within your team. As a leader, your approach to conflict can set the tone for how your entire team handles disagreements and challenges. One crucial aspect of managing conflict is the timing and delivery of feedback. There’s a delicate balance to strike between addressing issues promptly and choosing the right moment for a conversation. Sometimes, an on-the-spot callout is necessary to immediately correct a behavior or decision. Other times, it’s more appropriate to take someone aside for a private conversation, allowing for a more in-depth and nuanced discussion. Your judgment in choosing between these approaches can significantly impact how your feedback is received and acted upon.

A valuable tool in navigating team conflicts is the “Disagree and Commit” method. This approach encourages team members to voice their disagreements openly and honestly during the decision-making process. However, once a decision is made, everyone commits to supporting it fully, regardless of their initial stance. By teaching and implementing this method, you create an environment where diverse opinions are valued, but the team remains unified in its execution. A healthy view i head on this once, is one team member who was the odd one out told the rest of teh team “I’ll bet you a coffee it doesn’t work”, this light hearted approach to disagree and commit put a simle on everyone’s face when he said it, he got the coffee in the end on that occasion though, which also showed the humility in the rest of the team of admitting their failure.

Sometimes, despite best efforts, your team might reach an impasse on how to implement a solution. In such cases, consider embracing failure as a learning opportunity. If the team can’t agree on a single implementation, you might choose to build two different versions. While one approach might ultimately fail, the process of building and comparing both solutions can provide invaluable lessons for the entire team. This approach turns potential conflict into a collaborative learning experience, fostering a culture where experimentation and calculated risk-taking are encouraged.

By viewing conflict as an opportunity for growth rather than a problem to be avoided, you can build a more resilient and innovative team. This constructive approach to conflict encourages open communication, promotes learning from failures, and ultimately leads to better solutions. Remember, your role as a leader is not to eliminate all conflict, but to channel it productively towards team growth and improved outcomes.

Conclusion

Growing a strong engineering team is an ongoing process that requires empathy, strong leadership, respect, and the ability to navigate conflicts constructively. By focusing on these areas, you’ll create a team that’s not just technically proficient, but also engaged, loyal, and primed for long-term success.

Remember, your role as a leader is not just about managing tasks and deadlines. It’s about creating an environment where each team member can thrive, feel valued, and see their work as part of a larger, meaningful whole.

The Art of Hiring in Building Awesome Engineering Teams

As engineering managers, one of our most crucial responsibilities is building and maintaining high-performing teams. In this post, we’ll explore some key insights and strategies for effective hiring as a part of team building in the tech industry.

The Myth of the All-Star Team

Many of us dream of assembling a team full of tech leads, but the reality is that such a strategy is rarely feasible or even desirable, except perhaps in very small startups. The market simply doesn’t have enough of these high-level professionals to go around, and even if it did, there are compelling reasons to build a more diverse team.

Embracing Diversity in Experience and Skills

A truly effective team balances youth and experience, varied skill sets, and diverse backgrounds. While it’s tempting to hire only seasoned professionals, too much experience in one team can lead to conflicts and stagnation. On the flip hand, experienced team members often find fulfillment in mentoring younger colleagues.

It’s also crucial to avoid the pitfall of building a homogeneous team in terms of technical skills. A team of “all Java engineers,” for instance, might excel in their specific domain but struggle when faced with challenges that require a broader skill set, this makes them less able to respond to change, less Agile. Instead, aim for a mix of professionals who can work across different stacks, encouraging cross-pollination of ideas and skills within the team.

The T-Shaped Professional

“Overspecialization breeds weakness.” – Major Motoko Kusanagi

This philosophy applies perfectly to building tech teams. We should aim to cultivate and hire T-shaped professionals – individuals with deep expertise in one area (the vertical bar of the T) and a broad understanding of other related fields (the horizontal bar).

This approach not only creates a more adaptable team but also fosters an environment of continuous learning.

The biggest jump in my engineering knowledge was when I had to work with engineers that came from a different language background. It forced me to learn abstract language concepts to communicate effectively, which is a major step to becoming a polyglot.

Growing Your Own Talent

Sometimes, the best hire is the one you don’t make. As Ben Horowitz famously said, “When you can’t hire them, you need to grow them.” If you’re struggling to find the right external candidates, consider investing in developing your existing team members. Ask yourself: What would it take to turn your IC3 engineers into IC5s? This approach not only builds loyalty but can also be more cost-effective in the long run as hiring takes time and resources.

The Art of Headhunting

Never underestimate the power of your personal and professional networks when it comes to hiring. If you don’t have an extensive network, make one. Engage more actively on professional platforms like LinkedIn, get out into the community to conferences and meet people, leverage social media for hires.

Personal connections can significantly improve your hiring success rate. For instance, I maintain an almost 100% offer acceptance rate because I know most of my candidates through either my or my directs’ social networks. This allows for open conversations about expectations well before we reach the offer stage.

Common Hiring Pitfalls to Avoid

  1. Overemphasis on Technical Skills: While technical prowess is important, it shouldn’t be the sole criterion. Remember, it’s often easier to teach technical skills than to change someone’s personality or cultural fit. We hare engineering manager’s not psychologists.
  2. Rigid Skill Checklists: Instead of ticking boxes on a predefined list of skills, focus on understanding how a candidate can add unique value to your team. Some of my best hires have come from unexpected backgrounds, like Linux kernel experts or embedded systems specialists, simply because they were excellent engineers with the ability to adapt and learn. Embedded systems was an interesting one, you need to build in a lot of redundancy for things in remote location to have backups in the event of failure, its not unlike the web scale production systems we deal with.

The Crucial Probation Period

The probation period is your opportunity to evaluate a new hire in action. Use this time wisely:

  • Set clear expectations from the start
  • Create a structured onboarding plan with built-in evaluation points
  • Don’t hesitate to make tough decisions if necessary

Remember, passing someone you’re unsure about can have long-lasting consequences.

What if you pass someone you aren’t sure on? What are the consequences?

If you have to let them go later its impactful, not only on them but the team. They start to form social bonds to members of the team, letting someone go is never a good experience, and what ever experience they have they’ll be sharing with all their friends on the team after they leave, letting someone go they don’t just disappear. It’s much easier on the staff and the team if this happens earlier.

Conclusion

Hire is a core part of building an effective engineering team, and is both an art and a science. By focusing on diversity, adaptability, and cultural fit, while avoiding common pitfalls, you can create a team that’s not just technically proficient, but also innovative, collaborative, and poised for long-term success.

Building Awesome Teams: An Engineering Manager’s Guide

Welcome to our comprehensive series on the art and science of building exceptional engineering teams. As we embark on this journey together, let’s start with a fundamental question that lies at the heart of engineering leadership:

“What is the job of an Engineering Manager?”

If you were to ask me this question, my answer would be simple yet profound: “Building Awesome Teams.”

This straightforward statement encapsulates the essence of engineering management, a role that extends beyond technical oversight or project management. Building awesome teams is a continuous process that begins the moment you start hiring and continues through every stage of an engineer’s journey with your team, right up to and including the point when they move on to new challenges.

Tech: A People Problem in Disguise

One of the most crucial insights that any engineering leader can gain is this: Tech is, first and foremost, a people problem. The sooner you realize this truth, the sooner you’ll start winning at tech.

Yes, we work with complex systems, intricate code, and cutting-edge technologies. But at the end of the day, it’s people who write the code, design the systems, and push the boundaries of what’s possible. It’s people who collaborate, innovate, and turn ideas into reality. And it’s people who can make or break a project, a product, or even an entire company.

Exploring the Art of Team Building

In this series, we’ll dive deep into all aspects of building awesome teams. We’ll cover topics such as:

  1. Hiring: How to attract, identify, and onboard the right talent for your team.
  2. Performance Management: Strategies for nurturing growth, providing feedback, and helping your team members excel.
  3. Execution: Techniques for forming effective teams, collaborating across departments, and delivering results.
  4. Managing Exits: How to handle both voluntary and involuntary departures in a way that respects individuals and maintains team morale.

Each of these topics is crucial in its own right, but they also interlink and influence each other. By mastering these areas, you’ll be well on your way to building and maintaining truly awesome teams.

Why This Matters

In the fast-paced world of technology, having a high-performing team isn’t just a nice-to-have—it’s a necessity. They’re better equipped to solve complex problems, adapt to changing circumstances, and deliver value to your organization and its customers.

Moreover, awesome teams create a positive feedback loop. They attract more great talent, inspire each other to greater heights, and create an environment where everyone can do their best work. As an engineering manager, there’s no greater satisfaction than seeing your team thrive and achieve things they never thought possible.

Join Us on This Journey

Whether you’re a seasoned engineering leader or just starting your management journey, this series has something for you. We’ll blend theoretical insights with practical advice, drawing on real-world experiences and best practices from the field.

So, are you ready to dive in and start building awesome teams? Let’s begin this exciting journey together!

Stay tuned for our first installment in the series

7 Golden Rules for Library Development: Ensuring Stability and Reliability

As software engineers, we often rely on libraries to streamline our development process and enhance our applications. However, creating and maintaining a library comes with great responsibility. In this post, we’ll explore five essential practices that every library developer should follow to ensure their code remains stable, reliable, and user-friendly.

Before we dive in, let’s consider this famous quote from Linus Torvalds, the creator of Linux:

“WE DO NOT BREAK USERSPACE!”

This statement encapsulates a core principle of software development, especially relevant to library creators. It underscores the importance of maintaining compatibility and stability for the end-users of our code.

1. Preserve Contract Integrity: No Breaking Changes

The cardinal rule of library development is to never introduce breaking changes to your public contracts. This means:

  • Use method overloads instead of modifying existing signatures
  • Add new properties rather than altering existing ones
  • Think critically about your public interfaces before implementation

Remember, the urge to “make the code cleaner” is rarely a sufficient reason to break existing contracts. Put more effort into designing robust public interfaces from the start.

Code Examples

Let’s look at some examples in Kotlin to illustrate how to preserve contract integrity:

C# Examples

using System;

// Original version
public class UserService
{
    public void CreateUser(string name, string email)
    {
        // Implementation
    }
}

// Good: Adding an overload instead of modifying the existing method
public class UserService
{
    public void CreateUser(string name, string email)
    {
        // Original implementation
    }
    
    public void CreateUser(string name, string email, int age)
    {
        // New implementation that includes age
    }
}

// Bad: Changing the signature of an existing method
public class UserService
{
    // This would break existing code
    public void CreateUser(string name, string email, int age)
    {
        // Modified implementation
    }
}

// Good: Adding a new property instead of modifying an existing one
public class User
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string Email { get; set; }
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow; // New property with a default value
}

// Better: Avoid using primitives in parameters
public class UserService
{
    public void CreateUser(User user)
    {
        // Modified implementation
    }
}

2. Maintain Functional Consistency

Contract changes are the basic one that people are usually aware of, functional changes are changes that change what you expect from a library under a given condition, this is a little harder, but again its a simple practice to follow to achieve it.

Functional consistency is crucial for maintaining trust with your users. To achieve this:

  • Have good test coverage
  • Only add new tests; never modify existing tests

This approach ensures that you don’t inadvertently introduce functional changes that could disrupt your users’ applications.

3. Embrace the “Bug” as a Feature

Counter-intuitive as it may seem, fixing certain bugs can sometimes do more harm than good. Here’s why:

  • Users often build their code around existing behavior, including bugs
  • Changing this behavior, even if it’s “incorrect,” can break dependent systems, and cause more problems than you fix

Unless you can fix a bug without modifying existing tests, it’s often safer to leave it be and document the behavior thoroughly.

4. Default to Non-Public Access: Minimize Your Public API Surface

When developing libraries, it’s crucial to be intentional about what you expose to users. A good rule of thumb is to default to non-public access for all elements of your library. This approach offers several significant benefits for both library maintainers and users.

Firstly, minimizing your public API surface provides you with greater flexibility for future changes. The less you expose publicly, the more room you have to make internal modifications without breaking compatibility for your users. This flexibility is invaluable as your library evolves over time.

Secondly, a smaller public API reduces your long-term maintenance burden. Every public API element represents a commitment to long-term support. By keeping this surface area minimal, you effectively decrease your future workload and the potential for introducing breaking changes.

Lastly, a more focused public API often results in a clearer, more understandable interface for your users. When users aren’t overwhelmed with unnecessary public methods or properties, they can more easily grasp the core functionality of your library and use it effectively.

To implement this principle effectively, consider separating your public interfaces and contracts into a distinct area of your codebase, or even into a separate project or library. This separation makes it easier to manage and maintain your public API over time.

Once an element becomes part of your public API, treat it as a long-term commitment. Any changes to public elements should be thoroughly considered and, ideally, avoided if they would break existing user code. This careful approach helps maintain trust with your users and ensures the stability of projects that depend on your library.

In languages that support various access modifiers, use them judiciously. Employ ‘internal’, ‘protected’, or ‘private’ modifiers liberally, reserving ‘public’ only for those elements that are explicitly part of your library’s interface. This practice helps enforce the principle of information hiding and gives you more control over your library’s evolution.

For the elements you do make public, provide comprehensive documentation. Thorough documentation helps users understand the intended use of your API and can prevent misuse that might lead to dependency on unintended behavior.

Consider the following C# example:

// Public API - in a separate file or project
public interface IUserService
{
    User CreateUser(string name, string email);
    User GetUser(int id);
}

// Implementation - in the main library project
internal class UserService : IUserService
{
    public User CreateUser(string name, string email)
    {
        // Implementation
    }

    public User GetUser(int id)
    {
        // Implementation
    }

    // Internal helper method - can be changed without affecting public API
    internal void ValidateUserData(string name, string email)
    {
        // Implementation
    }
}

In this example, only the IUserService interface is public. The actual implementation (UserService) and its helper methods are internal, providing you with the freedom to modify them as needed without breaking user code.

Remember, anything you make public becomes part of your contract with users. By keeping your public API surface as small as possible, you maintain the maximum flexibility to evolve your library over time while ensuring stability for your users. This approach embodies the spirit of Linus Torvalds’ mandate: “WE DO NOT BREAK USERSPACE!” It allows you to respect your users’ time and effort by providing a stable, reliable foundation for their projects.

6. Avoid Transient Dependencies: Empower Users with Flexibility

An often overlooked aspect of library design is the management of dependencies. While it’s tempting to include powerful third-party libraries to enhance your functionality, doing so can lead to unforeseen complications for your users. Instead, strive to minimize transient dependencies and provide mechanisms for users to wire in their own implementations. This approach not only reduces potential conflicts but also increases the flexibility and longevity of your library.

Consider a scenario where your library includes functions for pretty-printing output. Rather than hardcoding a dependency on a specific logging or formatting library, design your interface to accept generic logging or formatting functions. This allows users to integrate your library seamlessly with their existing tools and preferences.

Here’s an example of how you might implement this in C#:

// Instead of this:
public class PrettyPrinter
{
    private readonly ILogger _logger;

    public PrettyPrinter()
    {
        _logger = new SpecificLogger(); // Forcing a specific implementation
    }

    public void Print(string message)
    {
        var formattedMessage = FormatMessage(message);
        _logger.Log(formattedMessage);
    }
}

// Do this:
public class PrettyPrinter
{
    private readonly Action<string> _logAction;

    public PrettyPrinter(Action<string> logAction)
    {
        _logAction = logAction ?? throw new ArgumentNullException(nameof(logAction));
    }

    public void Print(string message)
    {
        var formattedMessage = FormatMessage(message);
        _logAction(formattedMessage);
    }
}
// Or this: (When the framework has support for generic implementations like logging and DI)
public class PrettyPrinter
{
    private readonly ILogger _logger;

    public PrettyPrinter(ILogger<PrettyPrinter> logger)
    {
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
    }

    public void Print(string message)
    {
        var formattedMessage = FormatMessage(message);
        _logger.LogInformation(formattedMessage);
    }
}

In the improved version, users can provide their own logging function, which could be from any logging framework they prefer or even a custom implementation. This approach offers several benefits:

  1. Flexibility: Users aren’t forced to adopt a logging framework they may not want or need.
  2. Reduced Conflicts: By not including a specific logging library, you avoid potential version conflicts with other libraries or the user’s own code.
  3. Testability: It becomes easier to unit test your library without needing to mock specific third-party dependencies.
  4. Future-proofing: Your library remains compatible even if the user decides to change their logging implementation in the future.

This principle extends beyond just logging. Apply it to any functionality where users might reasonably want to use their own implementations. Database access, HTTP clients, serialization libraries – all of these are candidates for this pattern.

By allowing users to wire in their own dependencies, you’re not just creating a library; you’re providing a flexible tool that can adapt to a wide variety of use cases and environments. This approach aligns perfectly with our overall goal of creating stable, user-friendly libraries that stand the test of time.

You can also consider writing extension libraries that add default implementations.

For example, your base Library MyLibrary doesn’t include a serializer, just the interface, and you create MyLibrary.Newtonsoft that contains the Newtonsoft Json serializer implementation for the interface in your library and wires it up for the user. This gives teh consumer the convenance of an optional default, but flexibility the change.

7. Target Minimal Required Versions: Maximize Compatibility

When developing a library, it’s tempting to use the latest features of a programming language or framework. However, this approach can significantly limit your library’s usability. A crucial principle in library development is to target the minimal required version of your language or framework that supports the features you need.

By targeting older, stable versions, you ensure that your library can be used by a wider range of projects. Many development teams, especially in enterprise environments, cannot always upgrade to the latest versions due to various constraints. By supporting older versions, you make your library accessible to these teams as well.

Here are some key considerations:

  1. Assess Your Requirements: Carefully evaluate which language or framework features are truly necessary for your library. Often, you can achieve your goals without the newest features.
  2. Research Adoption Rates: Look into the adoption rates of different versions of your target language or framework. This can help you make an informed decision about which version to target.
  3. Use Conditional Compilation: If you do need to use newer features, consider using conditional compilation to provide alternative implementations for older versions.
  4. Document Minimum Requirements: Clearly state the minimum required versions in your documentation. This helps users quickly determine if your library is compatible with their project.
  5. Consider Long-Term Support (LTS) Versions: If applicable, consider targeting LTS versions of frameworks, as these are often used in enterprise environments for extended periods.

Here’s an example in C# demonstrating how you might use conditional compilation to support multiple framework versions:

public class MyLibraryClass
{
    public string ProcessData(string input)
    {
#if NETSTANDARD2_0
        // Implementation for .NET Standard 2.0
        return input.Trim().ToUpper();
#elif NETSTANDARD2_1
        // Implementation using a feature available in .NET Standard 2.1
        return input.Trim().ToUpperInvariant();
#else
        // Implementation for newer versions
        return input.Trim().ToUpperInvariant();
#endif
    }
}

In this example, we provide different implementations based on the target framework version. This allows the library to work with older versions while still taking advantage of newer features when available.

Remember, the goal is to make your library as widely usable as possible. By targeting minimal required versions, you’re ensuring that your library can be integrated into a diverse range of projects, increasing its potential user base and overall value to the developer community.

8. Internal Libraries: Freedom with Responsibility

Yes there’s a 8th, but it only applies to internal.

While internal libraries offer more flexibility, it’s crucial not to abuse this freedom:

  • Use tools like SourceGraph to track usage of internal methods
  • Don’t let this capability become an excuse to ignore best practices
  • Strive to maintain the same level of stability as you would for public libraries

Remember, avoiding breaking changes altogether eliminates the need for extensive usage tracking, saving you time and effort in the long run.

Tips

  1. Set GenerateDocumentationFile to true in your csproj files, it will enable a static code analysis rule that errors if you don’t have xml comments for documentation of public methods. It will make you write documentation for all public methods that will help you think about “should this be public” and if the answer is yes think about the contract.
  2. Use Analyzers: Implement custom Roslyn analyzers, eslint, etc. to enforce your library’s usage patterns and catch potential misuse at compile-time. (Example)
  3. Performance Matters: Include benchmarks in your test suite to catch performance regressions early. Document performance characteristics of key operations.
  4. Version Thoughtfully: Use semantic versioning (SemVer) to communicate the nature of changes in your library. Major version changes should be avoided and reserved for breaking changes, minor versions for new features, and patches for bug fixes.

Conclusion

Developing a library is more than just writing code; it’s about creating a tool that empowers other developers and stands the test of time. By adhering to the golden rules we’ve discussed – from preserving contract integrity to targeting minimal required versions – you’re not just building a library, you’re crafting a reliable foundation for countless projects.

Remember, every public API you expose is a promise to your users. By defaulting to non-public access, avoiding transient dependencies, and embracing stability even in the face of “bugs,” you’re honoring that promise. You’re telling your users, “You can build on this with confidence.”

The words of Linus Torvalds, “WE DO NOT BREAK USERSPACE!”, serve as a powerful reminder of our responsibility as library developers. We’re not just writing code for ourselves; we’re creating ecosystems that others will inhabit and build upon.

As you develop your libraries, keep these principles in mind. Strive for clarity in your public interfaces, be thoughtful about dependencies, and always consider the long-term implications of your design decisions. By doing so, you’ll create libraries that are not just useful, but respected and relied upon.

In the end, the mark of a truly great library isn’t just in its functionality, but in its reliability, its adaptability, and the trust it builds with its users. By following these best practices, you’re well on your way to creating such a library. Happy coding, and may your libraries stand the test of time!

The F5 Experience (Local Setup)

In our journey to achieve the perfect F5 Experience, one of the most critical aspects is local setup. The ability to clone a repository and immediately start working, without any additional configuration, is the cornerstone of a smooth development process. In this post, we’ll explore various techniques and best practices that contribute to a zero-setup local environment.

1. .gitattributes: Ensuring Cross-Platform Compatibility

One often overlooked but crucial file is .gitattributes. This file can prevent frustrating issues when working across different operating systems, particularly between Unix-based systems and Windows.

# Set default behavior to automatically normalize line endings
* text=auto

# Explicitly declare text files you want to always be normalized and converted
# to native line endings on checkout
*.sh text eol=lf

By specifying eol=lf for shell scripts, we ensure that they maintain Unix-style line endings even when cloned on Windows. This prevents the dreaded “bad interpreter” errors that can occur when Windows-style CRLF line endings sneak into shell scripts.

2. .editorconfig: Consistent Coding Styles Across IDEs

An .editorconfig file helps maintain consistent coding styles across different editors and IDEs. This is particularly useful in teams where developers have personal preferences for their development environment.

# Top-most EditorConfig file
root = true

# Unix-style newlines with a newline ending every file
[*]
end_of_line = lf
insert_final_newline = true
charset = utf-8
indent_style = space
indent_size = 2

# 4 space indentation for Python files
[*.py]
indent_size = 4

3. IDE Run Configurations: Streamlining Scala and Kotlin Setups

For Scala and Kotlin applications, storing IntelliJ IDEA run configurations in XML format and pushing them to the repository can save significant setup time. Create a .idea/runConfigurations directory and add XML files for each run configuration:

<component name="ProjectRunConfigurationManager">
  <configuration default="false" name="MyApp" type="Application" factoryName="Application">
    <option name="MAIN_CLASS_NAME" value="com.example.MyApp" />
    <module name="myapp" />
    <method v="2">
      <option name="Make" enabled="true" />
    </method>
  </configuration>
</component>

This avoids manual configuration after cloning the repo.

4. Package Manager Configurations: Handling Private Repositories

For .NET projects, a nuget.config file can specify private NuGet servers:

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <packageSources>
    <add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
    <add key="MyPrivateRepo" value="https://nuget.example.com/v3/index.json" />
  </packageSources>
</configuration>

nuget will search all parent folders looking for a nuget config, so put it high up once.

Similarly, for Node.js projects, you can use .npmrc or .yarnrc files to configure private npm registries:

registry=https://registry.npmjs.org/
@myorg:registry=https://npm.example.com/

5. launchSettings.json: Configuring .NET Core Apps

While launchSettings.json is often in .gitignore, including it can provide a consistent run configuration for .NET Core applications:

{
  "profiles": {
    "MyApp": {
      "commandName": "Project",
      "launchBrowser": true,
      "applicationUrl": "https://localhost:5001;http://localhost:5000",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    }
  }
}

6. Stick to Native Commands

While it’s tempting to create custom scripts to automate setup, this can lead to a proliferation of project-specific commands that new team members must learn, also it can lead to dependencies on not only things being installed but platform specific scripts (bash on windows vs linux vs mac, or version of python, etc). Instead, stick to well-known, native commands when possible:

  • For .NET: dotnet builddotnet rundotnet test
  • For Node.js: npm run devnpm test
  • For Scala: sbt runsbt test

This approach reduces the learning curve for new contributors and maintains consistency across projects.

7. Gitpod for Casual Contributors

While experienced developers often prefer local setups, Gitpod can be an excellent option for casual contributors. It provides a cloud-based development environment that can be spun up with a single click. Consider adding a Gitpod configuration to your repository for quick contributions and code reviews.

8. README.md: The Canary in the Coal Mine

Your README.md should be concise and focused on getting started. If your README contains more than a few simple steps to get the project running, it’s a sign that your setup process needs optimization.

A ideal README might look like this:

# MyAwesomeProject

## Getting Started

1. Clone the repository
2. Open in your preferred IDE

That's it! If you need to do anything more than this, please open an issue so we can improve our setup process.

Conclusion

Achieving a seamless F5 Experience for local setup is an ongoing process. By implementing these practices, you can significantly reduce the friction for new contributors and ensure a consistent development experience across your team.

Remember, the goal is to make the setup process as close to zero as possible. Every step you can eliminate or automate is a win for developer productivity and satisfaction.

In our next post, we’ll dive into optimizing the build and compile process to further enhance the F5 Experience. Stay tuned!

The F5 Experience (Testing)

Part of the F5 Experience is also running tests. Can I open my IDE and “just run the tests” after cloning a project?

Unit tests generally yes, but pretty much every other kind of test that engineers create these days (UI, Integration, end-to-end, etc.) needs complex environments managed either manually, or spawned in Kubernetes or a Docker Compose that brings your laptop to a crawl when running.

End-to-end tests I’ll leave for another day, and focus mainly on the ones with fewer hops such as UI and integration tests.

So what’s the problem here? The problem is if tests are hard to run, people won’t run them locally. They’ll wait for CI, and CI then becomes a part of the inner loop of development. You rely on it for dev feedback for code changes locally.

Even the best CI pipelines are looking at at least 5-10 minutes, the average ones even longer. So if you have to wait 10-15 minutes to validate your code changes are OK, then it’s going to make you less effective. You want the ability to run the test locally to get feedback in seconds.

Let’s first measure the problem. Below are open-source repos for Jest, Vitest, NUnit, and xUnit collectors:

These allow us to fire the data at an ingestion endpoint to get it to our Hadoop. They can be reused as well by anyone that sets up an endpoint and ingests the data.

They will also send from CI, using the username of the person that triggered the build when running in CI and the logged-in user from the engineer’s local. This allows us to compare who is triggering builds that run tests vs. if they are running on their locals.

Looking into this data on one of our larger repos, we found that there was a very low number of users running the integration tests locally, so it was a good candidate for experimentation.

When looking at the local experience, we found a readme with several command-line steps that needed to be run in order to spin up a Docker environment that worked. Also, the steps for local and CI were different, which was concerning, as this means that you may end up with tests that fail on CI but you can’t replicate locally.

Looking at this with one of my engineers, he suggested we try Testcontainers to solve the problem.

So we set up the project with Testcontainers to replace the Docker Compose.

The integration tests would now appear and be runnable in the IDE, the same as the unit tests. So we come back to our zero setup goal of the F5 Experience, and we are winning.

Also, instead of multiple command lines, you can now run dotnet test and everything is orchestrated for you (which is what the IDE does internally). Some unreliable “waits” in the Docker Compose were able to be removed because the Testcontainers orchestration takes care of this, knowing when containers are ready and able to be used (such as databases, etc.).

It did take a bit of time for our engineers to get used to it, but we can see over time the percentage of engineers running CI vs. Local is increasing, meaning our inner loop is getting faster.

Conclusion

The F5 Experience in testing is crucial for maintaining a fast and efficient development cycle. By focusing on making tests easy to run locally, we’ve seen significant improvements in our team’s productivity and the quality of our code.

Key takeaways from our experience include:

  1. Measure First: By collecting data on test runs, both in CI and locally, we were able to identify areas for improvement and track our progress over time.
  2. Simplify the Setup: Using tools like Testcontainers allowed us to streamline the process of running integration tests, making it as simple as running unit tests.
  3. Consistency is Key: Ensuring that the local and CI environments are as similar as possible helps prevent discrepancies and increases confidence in local test results.
  4. Automation Matters: Removing manual steps and unreliable waits not only saves time but also reduces frustration and potential for errors.

The journey to improve the F5 Experience in testing is ongoing. As we continue to refine our processes and tools, we should keep in mind that the ultimate goal is to empower our engineers to work more efficiently and confidently. This means constantly evaluating our testing practices and being open to new technologies and methodologies that can further streamline our workflow.

Remember, the ability to quickly and reliably run tests locally is not just about speed—it’s about maintaining the flow of development, catching issues early, and fostering a culture of quality. As we’ve seen, investments in this area can lead to tangible improvements in how our team works and the software we produce.

Let’s continue to prioritize the F5 Experience in our development practices, always striving to make it easier and faster for our engineers to write, test, and deploy high-quality code.