Scaling Dependency Injection: How Agoda Solved DI Challenges with Agoda.IoC

Introduction

In the world of modern software development, Dependency Injection (DI) has become an essential technique for building maintainable, testable, and scalable applications. By allowing us to decouple our code and manage object lifecycles effectively, DI has revolutionized how we structure our applications.

However, as projects grow in size and complexity, even the most beneficial practices can become challenging to manage. This is especially true for large-scale applications with hundreds of developers and thousands of components. At Agoda, we faced this exact challenge with our dependency injection setup, and we’d like to share how we overcame it.

In this post, we’ll explore the problems we encountered with traditional DI approaches at scale, and introduce Agoda.IoC, our open-source solution that has transformed how we handle dependency injection across our codebase.

The Problem: DI at Scale

To understand the magnitude of the challenge we faced, let’s first consider the scale at which Agoda operates its customer facing website:

  • In an average month, we merge over 260 pull requests
  • We add more than 38,000 lines of code
  • We have around 100 active engineers contributing to our codebase

Stats from 2021

With such a large and active development environment, our traditional approach to dependency injection began to show its limitations. Like many .NET projects, we were using the built-in DI container, registering our services in the Startup.cs file or through extension methods. It looked something like this:

public void ConfigureServices(IServiceCollection services)
{
    services.AddSingleton<IService, Service>();
    services.AddTransient<IRepository, Repository>();
    // ... hundreds more registrations
}

While this approach works well for smaller projects, we encountered several significant issues as our codebase grew:

  1. Merge Conflicts: With numerous developers working on different features, all needing to register new services, our Startup.cs file became a constant source of merge conflicts. This slowed down our development process and created unnecessary friction.
  2. Lack of Visibility into Object Lifecycles: As our registration code grew and was split into multiple methods and even separate files, it became increasingly difficult for developers to understand the lifecycle of a particular service without digging through configuration code. This lack of visibility could lead to subtle bugs, especially when dealing with scoped or singleton services that might inadvertently capture user-specific data.
  3. Maintenance Nightmare: Our main configuration class ballooned to nearly 4,000 lines of code at its peak. This made it incredibly difficult to maintain, understand, and modify our DI setup.

These issues were not just minor inconveniences. They were actively hindering our ability to develop and release products quickly and reliably. We needed a solution that would allow us to scale our dependency injection along with our codebase and team size.

The “Just Break It Up” Approach

When faced with a massive configuration file, the knee-jerk reaction is often to break it up into smaller pieces. This might look something like this:

public void ConfigureServices(IServiceCollection services)
{
    services.AddDataServices()
            .AddBusinessServices()
            .AddInfrastructureServices();
    // ... more method calls
}

public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddDataServices(this IServiceCollection services)
    {
        services.AddSingleton<IDatabase, Database>();
        services.AddTransient<IUserRepository, UserRepository>();
        // ... more registrations
        return services;
    }

    // ... more extension methods
}

This pattern can make your Startup.cs look cleaner, but it’s really just hiding the complexity rather than addressing it. The registration logic is still centralized, just in different files. This can actually make it harder to find where a particular service is registered, exacerbating our visibility problem.

Introducing Agoda.IoC

To address the challenges we faced with dependency injection at scale, we developed Agoda.IoC, an open-source C# IoC extension library. Agoda.IoC takes a different approach to service registration, moving away from centralized configuration and towards a more distributed, attribute-based model. In building it we also pulled out a bunch of complex but handy registration patterns we found in use.

Agoda.IoC uses C# attributes to define how services should be registered with the dependency injection container. This approach brings several benefits:

  1. Decentralized Configuration: Each service is responsible for its own registration, reducing merge conflicts and improving code organization.
  2. Clear Visibility of Lifecycles: The lifetime of a service is immediately apparent when viewing its code.
  3. Simplified Registration Process: No need to manually add services to a configuration file; the library handles this automatically.

Let’s look at some examples of how Agoda.IoC works in practice:

Basic Registration

Consider a logging service that you want to use throughout your application:

public interface ILogger {}

[RegisterSingleton]
public class Logger : ILogger {}

This replaces the traditional services.AddSingleton<ILogger, Logger>(); in your startup code. By using the [RegisterSingleton] attribute, you ensure that only one instance of Logger is created and used throughout the application’s lifetime. This is ideal for stateless services like loggers, configuration managers, or caching services.

The interface is used to register the class by default.

Factory Registration

Factory registration is useful for services that require complex initialization or depend on runtime parameters. For example, let’s consider a database connection service:

[RegisterSingleton(Factory = typeof(DatabaseConnectionFactory))]
public class DatabaseConnection : IDatabaseConnection
{
    private readonly string _connectionString;
    
    public DatabaseConnection(string connectionString)
    {
        _connectionString = connectionString;
    }
    
    // implementation here
}

public class DatabaseConnectionFactory : IComponentFactory<IDatabaseConnection>
{
    public IDatabaseConnection Build(IComponentResolver resolver)
    {
        var config = resolver.Resolve<IConfiguration>();
        string connectionString = config.GetConnectionString("DefaultConnection");
        return new DatabaseConnection(connectionString);
    }
}

This approach allows you to create a DatabaseConnection with a connection string that’s only known at runtime. The factory can use the IComponentResolver to access other registered services (like IConfiguration) to build the connection.

Explicit Interface Registration

When a class implements multiple interfaces but should only be registered for one, explicit interface registration comes in handy. This is particularly useful in scenarios where you’re adapting third-party libraries or creating adapters:

[RegisterTransient(For = typeof(IExternalServiceAdapter))]
public class ExternalServiceAdapter : IExternalServiceAdapter, IDisposable
{
    private readonly ExternalService _externalService;
    
    public ExternalServiceAdapter(ExternalService externalService)
    {
        _externalService = externalService;
    }
    
    // IExternalServiceAdapter implementation
    
    public void Dispose()
    {
        _externalService.Dispose();
    }
}

In this case, we only want to register ExternalServiceAdapter as IExternalServiceAdapter, not as IDisposable. This prevents other parts of the application from accidentally resolving this class when they ask for an IDisposable.

Collection Registration

Collection registration is powerful when you have multiple implementations of an interface that you want to use together, such as in a pipeline pattern or for plugin-like architectures. Here’s an example with a simplified order processing pipeline:

public interface IOrderProcessor
{
    void Process(Order order);
}

[RegisterSingleton(For = typeof(IOrderProcessor), OfCollection = true, Order = 1)]
public class ValidateOrderProcessor : IOrderProcessor
{
    public void Process(Order order) 
    {
        // Validate the order
    }
}

[RegisterSingleton(For = typeof(IOrderProcessor), OfCollection = true, Order = 2)]
public class InventoryCheckProcessor : IOrderProcessor
{
    public void Process(Order order) 
    {
        // Check inventory
    }
}

[RegisterSingleton(For = typeof(IOrderProcessor), OfCollection = true, Order = 3)]
public class PaymentProcessor : IOrderProcessor
{
    public void Process(Order order) 
    {
        // Process payment
    }
}

With this setup, you can inject IEnumerable<IOrderProcessor> into a service that needs to run all processors in order:

public class OrderService
{
    private readonly IEnumerable<IOrderProcessor> _processors;
    
    public OrderService(IEnumerable<IOrderProcessor> processors)
    {
        _processors = processors;
    }
    
    public void ProcessOrder(Order order)
    {
        foreach (var processor in _processors)
        {
            processor.Process(order);
        }
    }
}

This approach allows you to easily add or remove processing steps without changing the OrderService class.

Keyed Registration

Keyed registration allows you to register multiple implementations of the same interface and retrieve them by a specific key. This is particularly useful when you need different implementations based on runtime conditions.

Example scenario: Multiple payment gateways in an e-commerce application.

public interface IPaymentGateway
{
    bool ProcessPayment(decimal amount);
}

[RegisterSingleton(Key = "Stripe")]
public class StripePaymentGateway : IPaymentGateway
{
    public bool ProcessPayment(decimal amount)
    {
        // Stripe-specific payment processing logic
        return true;
    }
}

[RegisterSingleton(Key = "PayPal")]
public class PayPalPaymentGateway : IPaymentGateway
{
    public bool ProcessPayment(decimal amount)
    {
        // PayPal-specific payment processing logic
        return true;
    }
}

public class PaymentService
{
    private readonly IKeyedComponentFactory<IPaymentGateway> _gatewayFactory;

    public PaymentService(IKeyedComponentFactory<IPaymentGateway> gatewayFactory)
    {
        _gatewayFactory = gatewayFactory;
    }

    public bool ProcessPayment(string gatewayName, decimal amount)
    {
        var gateway = _gatewayFactory.GetByKey(gatewayName);
        return gateway.ProcessPayment(amount);
    }
}

In this example, you can switch between payment gateways at runtime based on user preference or other factors.

Mocked Mode for Testing

Agoda.IoC provides a mocked mode that allows you to easily swap out real implementations with mocks for testing purposes. This is particularly useful for isolating components during unit testing.

Example scenario: Testing a user service that depends on a database repository.

public interface IUserRepository
{
    User GetUserById(int id);
}

[RegisterSingleton(Mock = typeof(MockUserRepository))]
public class UserRepository : IUserRepository
{
    public User GetUserById(int id)
    {
        // Actual database call
        return new User { Id = id, Name = "Real User" };
    }
}

public class MockUserRepository : IUserRepository
{
    public User GetUserById(int id)
    {
        // Return a predefined user for testing
        return new User { Id = id, Name = "Mock User" };
    }
}

public class UserService
{
    private readonly IUserRepository _userRepository;

    public UserService(IUserRepository userRepository)
    {
        _userRepository = userRepository;
    }

    public string GetUserName(int id)
    {
        var user = _userRepository.GetUserById(id);
        return user.Name;
    }
}

When running in normal mode, the real UserRepository will be used. In mocked mode (typically during testing), the MockUserRepository will be injected instead, allowing for predictable test behavior without actual database calls.

Open Generic Service Registration

Agoda.IoC supports registration of open generic services, which is particularly useful when you have a generic interface with multiple implementations.

Example scenario: A generic repository pattern in a data access layer.

public interface IRepository<T> where T : class
{
    T GetById(int id);
    void Save(T entity);
}

[RegisterTransient(For = typeof(IRepository<>))]
public class GenericRepository<T> : IRepository<T> where T : class
{
    public T GetById(int id)
    {
        // Generic implementation
    }

    public void Save(T entity)
    {
        // Generic implementation
    }
}

// Usage
public class UserService
{
    private readonly IRepository<User> _userRepository;

    public UserService(IRepository<User> userRepository)
    {
        _userRepository = userRepository;
    }

    // Service implementation
}

With this setup, Agoda.IoC will automatically create and inject the appropriate GenericRepository<T> when an IRepository<T> is requested for any type T.

These advanced features of Agoda.IoC provide powerful tools for handling complex dependency injection scenarios, from runtime-determined implementations to easier testing and support for generic patterns. By leveraging these features, you can create more flexible and maintainable application architectures.

Implementing Agoda.IoC in Your Project

Now that we’ve explored the features and benefits of Agoda.IoC, let’s walk through the process of implementing it in your .NET project. This guide will cover installation, basic setup, and the migration process from traditional DI registration.

First, you’ll need to install the Agoda.IoC package. You can do this via the NuGet package manager in Visual Studio or by running the following command in your project directory:

dotnet add package Agoda.IoC.NetCore

Basic Setup

Once you’ve installed the package, you need to set up Agoda.IoC in your application’s startup code. The exact location depends on your project structure, but it’s typically in the Startup.cs file for traditional ASP.NET Core projects or in Program.cs for minimal API projects.

For a minimal API project (Program.cs):

using Agoda.IoC.NetCore;

var builder = WebApplication.CreateBuilder(args);

// Your existing service configurations...

// Add this line to set up Agoda.IoC
builder.Services.AutoWireAssembly(new[] { typeof(Program).Assembly }, isMockMode: false);

var app = builder.Build();
// ... rest of your program

The AutoWireAssembly method takes two parameters:

  1. An array of assemblies to scan for registrations. Typically, you’ll want to include your main application assembly.
  2. A boolean indicating whether to run in mock mode (useful for testing, as we saw in the advanced features section).

Before:

// In Startup.cs
services.AddSingleton<IEmailService, EmailService>();
services.AddTransient<IUserRepository, UserRepository>();
services.AddScoped<IOrderProcessor, OrderProcessor>();

// In your classes
public class EmailService : IEmailService { /* ... */ }
public class UserRepository : IUserRepository { /* ... */ }
public class OrderProcessor : IOrderProcessor { /* ... */ }

After:

// In Startup.cs
services.AutoWireAssembly(new[] { typeof(Startup).Assembly }, isMockMode: false);

// In your classes
[RegisterSingleton]
public class EmailService : IEmailService { /* ... */ }

[RegisterTransient]
public class UserRepository : IUserRepository { /* ... */ }

[RegisterPerRequest] // This is equivalent to AddScoped
public class OrderProcessor : IOrderProcessor { /* ... */ }

The Reflection Concern

When introducing a new library into a project, especially one that uses reflection, it’s natural to have concerns about performance.

The key point to understand is that Agoda.IoC primarily uses reflection during application startup, not during runtime execution. Here’s how it breaks down:

  1. Startup Time: Agoda.IoC scans the specified assemblies for classes with registration attributes. This process happens once during application startup.
  2. Runtime: Once services are registered, resolving dependencies uses the same mechanisms as the built-in .NET Core DI container. There’s no additional reflection overhead during normal application execution.

Agoda.IoC.Generator: Enhancing Performance with Source Generators

While the reflection-based approach of Agoda.IoC is performant for most scenarios, we understand that some projects, especially those targeting AOT (Ahead-of-Time) compilation, may require alternatives. This is where Agoda.IoC.Generator comes into play.

To use Agoda.IoC.Generator, you simply need to add it to your project alongside Agoda.IoC. The source generator will automatically detect the Agoda.IoC attributes and generate the appropriate registration code.

By offering both reflection-based and source generator-based solutions, we ensure that Agoda.IoC can meet the needs of a wide range of projects, from traditional JIT-compiled applications to those requiring AOT compilation.

Conclusion: Embracing Agoda.IoC for Scalable Dependency Injection

As we’ve explored throughout this blog post, dependency injection is a crucial technique for building maintainable and scalable applications. However, as projects grow in size and complexity, traditional DI approaches can become unwieldy. This is where Agoda.IoC steps in.

Let’s recap the key benefits of Agoda.IoC:

  1. Decentralized Configuration: By moving service registration to attributes on the classes themselves, Agoda.IoC eliminates the need for a centralized configuration file. This reduces merge conflicts and makes it easier to understand the lifecycle of each service.
  2. Improved Code Organization: With Agoda.IoC, the registration details are right where they belong – with the service implementations. This improves code readability and maintainability.
  3. Flexibility: From basic registrations to more complex scenarios like keyed services and open generics, Agoda.IoC provides the flexibility to handle a wide range of dependency injection needs.
  4. Testing Support: The mocked mode feature makes it easier to write and run unit tests, allowing you to easily swap out real implementations for mocks.
  5. Performance: Despite using reflection, Agoda.IoC is designed to be performant, with minimal impact on startup time and runtime performance. And for scenarios requiring AOT compilation, Agoda.IoC.Generator provides a source generator-based alternative.
  6. Scalability: As your project grows from a small application to a large, complex system, Agoda.IoC scales with you, maintaining clean and manageable dependency registration.

At Agoda, we’ve successfully used this library to manage dependency injection in our large-scale applications, handling thousands of services across a team of hundreds of developers. It has significantly reduced the friction in our development process and helped us maintain a clean, understandable codebase even as our systems have grown.

Of course, like any tool, Agoda.IoC isn’t a silver bullet. It’s important to understand your project’s specific needs and constraints. For some smaller projects, the built-in DI container in .NET might be sufficient. For others, especially larger, more complex applications, Agoda.IoC can provide substantial benefits.

We encourage you to give Agoda.IoC a try in your projects. Start with the basic features, and as you become more comfortable, explore the advanced capabilities like keyed registration and collection registration. We believe you’ll find, as we have, that it makes managing dependencies in large projects significantly easier and more maintainable.

In the end, the goal of any development tool or practice is to make our lives as developers easier and our code better. We believe Agoda.IoC does just that for dependency injection in .NET applications. We hope you’ll find it as useful in your projects as we have in ours.

The Product Engineering Mindset: Bridging Technology and Business

In our previous posts, we explored the evolution of software development and the core principles of product engineering. Today, we’re diving into the product engineering mindset – the set of attitudes and approaches that define successful product engineers. This mindset is what truly sets product engineering apart from traditional software development roles.

The T-Shaped Professional

At the heart of the mindset is the concept of the T-shaped professional. This term, popularized by IDEO CEO Tim Brown, describes individuals who have deep expertise in one area (the vertical bar of the T) coupled with a broad understanding of other related fields (the horizontal bar of the T).

For engineers, the vertical bar typically represents their technical skills – be it front-end development, back-end systems, data engineering, or any other specific domain. The horizontal bar, however, is what truly defines this mindset. It includes:

  1. Understanding of user experience and design principles
  2. Knowledge of business models and metrics
  3. Familiarity with product management concepts
  4. Basic understanding of data analysis and interpretation
  5. Awareness of market trends and competitive landscape

This T-shaped skillset allows these engineers to collaborate effectively across disciplines, make informed decisions, and understand the broader impact of their work.

Customer-Centric Thinking

At the heart of product engineering lies a fundamental principle: an unwavering focus on the customer. Product engineers don’t just build features; they solve real problems for real people. This customer-centric approach permeates every aspect of their work, from initial concept to final implementation and beyond.

Central to this mindset is empathy – the ability to understand and share the feelings of another. This means going beyond surface-level user requirements to truly comprehend the user’s context, needs, and pain points. It’s about putting yourself in the user’s shoes, understanding their frustrations, their goals, and the environment in which they use your product.

Curiosity is another crucial component of customer-centric thinking. Engineers are not content with surface-level understanding; they constantly ask “why?” to get to the root of problems. This curiosity drives them to dig deeper, to question assumptions, and to seek out the underlying causes of user behavior and preferences.

For example, if users aren’t engaging with a particular feature, a curious engineer won’t simply accept this at face value. They’ll ask: Why aren’t users engaging? Is the feature difficult to find? Is it not solving the problem it was intended to solve? Is there a more fundamental issue that we haven’t addressed? This relentless curiosity leads to deeper insights and more effective solutions.

Observation is the third pillar of customer-centric thinking. Engineers pay close attention to how users actually interact with their products, not just how they’re expected to. This often involves going beyond analytics and user feedback to engage in direct observation and user testing.

Consider an engineer working on an e-commerce platform. They might set up user testing sessions where they observe customers navigating the site, making purchases, and encountering obstacles. They might analyze heatmaps and user flows to understand where customers are dropping off or getting confused. They might even use techniques like contextual inquiry, observing users in their natural environments to understand how the product fits into their daily lives.

Amazon’s “working backwards” process exemplifies this customer-centric mindset in action. Before writing a single line of code, product teams at Amazon start by writing a press release from the customer’s perspective. This press release describes the finished product, its features, and most importantly, the value it provides to the customer.

This approach forces teams to think deeply about the customer’s needs and desires from the very beginning of the product development process. It ensures that every feature is grounded in real customer value, not just technical possibilities or internal priorities.

In the end, customer-centric thinking is what transforms a good product engineer into a great one. It’s the difference between building features and creating solutions, between meeting specifications and delighting users.

Balancing Technical Skills with Business Acumen

While deep technical skills form the foundation of a product engineer’s expertise, the modern tech landscape demands a broader perspective. Today’s engineers need to bridge the gap between technology and business, understanding not just how to build products, but why they’re building them and how they fit into the larger business strategy.

This balance begins with a solid understanding of the business model. Engineers need to grasp how their company generates revenue and manages costs. This isn’t about becoming financial experts, but rather about understanding the basic mechanics of the business. For instance, an engineer at a SaaS company should understand the concepts of customer acquisition costs, lifetime value, and churn rate. They should know whether the company operates on a freemium model, enterprise sales, or something in between. This understanding helps engineers make informed decisions about where to invest their time and effort, aligning their technical work with the company’s financial goals.

Equally important is a grasp of key performance indicators (KPIs) and how engineering decisions impact these metrics. Different businesses will have different KPIs, but common examples include user acquisition, retention rates, conversion rates, and average revenue per user. engineers need to understand which metrics matter most to their business and how their work can move the needle on these KPIs.

At Airbnb, for example, engineers don’t just focus on building a fast and reliable booking system. They understand how factors like booking conversion rate, host retention, and customer lifetime value impact the company’s success. This knowledge informs their technical decisions, ensuring that their work aligns with and supports the company’s broader goals.

Awareness of market dynamics is another crucial aspect of business acumen for engineers. This involves understanding who the competitors are, what they’re doing, and how the market is evolving. Engineers should have a sense of where their product fits in the competitive landscape and what sets it apart.

This market awareness also extends to understanding broader industry trends that might impact the product. For instance, an engineer working on a mobile app needs to be aware of trends in mobile technology, changes in app store policies, and shifts in user behavior. This knowledge helps them anticipate challenges and opportunities, informing both short-term decisions and long-term strategy.

Consider an engineer at a streaming service like Netflix. They need to be aware of not just direct competitors in the streaming space, but also broader trends in entertainment consumption. Understanding the rise of short-form video content on platforms like TikTok, for example, might inform decisions about feature and infrastructure development or content recommendation algorithms.

Balancing technical skills with business acumen doesn’t mean that engineers need to become business experts. Rather, it’s about developing enough understanding to make informed decisions and communicate effectively with business stakeholders.

Developing this business acumen is an ongoing process. It involves curiosity about the broader context of one’s work, a willingness to engage with non-technical stakeholders, and a commitment to understanding the “why” behind product decisions.

Embracing Uncertainty and Learning

The product engineering mindset is characterized by a unique comfort with uncertainty and an unwavering commitment to continuous learning. In the fast-paced world of technology, where change is the only constant, this mindset is not just beneficial—it’s essential for success.

At the heart of this mindset is a willingness to experiment. Engineers understand that innovation often comes from trying new approaches, even when the outcome is uncertain. They view each project not just as a task to be completed, but as an opportunity to explore and learn. This experimental approach extends beyond just trying new technologies; it encompasses new methodologies, team structures, and problem-solving techniques.

Crucially, these engineers see both successes and failures as valuable learning experiences. When an experiment succeeds, they analyze what went right and how to replicate that success. When it fails, they don’t see it as a setback, but as a rich source of information. They ask: What didn’t work? Why? What can we learn from this? This resilience in the face of failure, coupled with a curiosity to understand and learn from it, is a hallmark of the product engineering mindset.

Data-driven decision making is another key aspect of this mindset. Product engineers don’t rely on hunches or assumptions; they seek out data to inform their choices. This might involve A/B testing different features, analyzing user behavior metrics, or conducting performance benchmarks. They’re comfortable with analytics tools and basic statistical concepts, using these to derive insights that guide their work.

However they also understand the limitations of data. They know that not everything can be quantified and that sometimes, especially when innovating, there may not be historical data to rely on. In these cases, they balance data with intuition and experience. They’re not paralyzed by a lack of complete information but are willing to make informed judgments when necessary.

Spotify’s “fail fast” culture exemplifies this mindset in action. Engineers are encouraged to experiment with new ideas, measure the results, and quickly iterate or pivot based on what they learn. This approach not only leads to innovative solutions but also creates an environment where learning is valued and uncertainty is seen as an opportunity rather than a threat.

Collaborative Problem-Solving

Product engineers don’t work in silos. The complexity of modern software products demands a collaborative approach, where diverse perspectives and skill sets come together to create solutions. Product engineers collaborate closely with designers, product managers, data scientists, and other stakeholders, each bringing their unique expertise to the table.

Teamwork is another crucial aspect of collaborative problem-solving. Engineers must be willing to share their ideas openly, knowing that exposure to different viewpoints can refine and improve their initial concepts. They need to be open to feedback, seeing it not as criticism but as an opportunity for growth and improvement. At the same time, they should be ready to offer constructive feedback to others, always keeping the common goal in mind. This give-and-take of ideas, when done in a spirit of mutual respect and shared purpose, can lead to breakthroughs that no single individual could have achieved alone.

Often, these engineers find themselves in the role of facilitator, especially when it comes to technical decisions that impact the broader product strategy. They may need to guide discussions, helping the team navigate complex technical tradeoffs while considering business and user experience implications. This requires not just technical knowledge, but also the ability to listen actively, synthesize different viewpoints, and guide the team towards consensus. It’s about finding the delicate balance between driving decisions and ensuring all voices are heard.

At Google, this collaborative mindset is embodied in their design sprint process. In these intensive, time-boxed sessions, cross-functional teams come together to tackle complex problems. Engineers work side-by-side with designers, product managers, and other stakeholders, rapidly prototyping and testing ideas. This process not only leads to innovative solutions but also builds stronger, more cohesive teams.

Conclusion

The product engineering mindset is about much more than coding skills. It’s about understanding the bigger picture, taking ownership of outcomes, focusing relentlessly on user needs, and working collaboratively to solve complex problems.

Developing this mindset is a journey. It requires curiosity, empathy, and a willingness to step outside the comfort zone of pure technical work. But for those who embrace it, this mindset opens up new opportunities to create meaningful impact and drive innovation.

In our next post, we’ll dive into the specific skills that product engineers need to cultivate to be successful in their roles. We’ll explore both technical and non-technical skills that are crucial in the world of product engineering.

What aspects of the product engineering mindset resonate with you? How have you seen this mindset impact product development in your organization? Share your thoughts and experiences in the comments below!

Understanding Product Engineering: A New Paradigm in Software Development

In our previous post, we explored how the software development landscape is rapidly changing and why traditional methods are becoming less effective. Today, we’re diving deep into the concept of product engineering – a paradigm shift that’s reshaping how we approach software development.

What is Product Engineering?

At its core, product engineering is a holistic approach to software development that combines technical expertise with a deep understanding of user needs and business goals. It’s not just about writing code or delivering features; it’s about creating products that solve real problems and provide tangible value to users.

Product engineering teams are cross-functional, typically including software engineers, designers, product managers, and sometimes data scientists or other specialists. These teams work collaboratively, with each member bringing their unique perspective to the table.

The Purpose of Product Engineering

1. Innovating on Behalf of the Customer

The primary purpose of product engineering is to innovate on behalf of the customer. This means going beyond simply fulfilling feature requests or specifications. Instead, product engineers strive to deeply understand the problems customers face and develop innovative solutions – sometimes before customers even realize they need them.

For example, when Amazon introduced 1-Click ordering in 1999, they weren’t responding to a specific customer request. Instead, they identified a pain point in the online shopping experience (the tedious checkout process) and innovated a solution that dramatically improved user experience.

2. Building Uncompromisingly High-Quality Products

Teams are committed to building high-quality products that customers love to use. This goes beyond just ensuring that the code works correctly. It encompasses:

  • Performance: Ensuring the product is fast and responsive
  • Reliability: Building systems that are stable and dependable
  • User Experience: Creating intuitive, enjoyable interfaces
  • Scalability: Designing systems that can grow with user demand

Take Spotify as an example. Their product engineering teams don’t just focus on adding new features. They continually work on improving streaming quality, reducing latency, and enhancing the user interface – all elements that contribute to a high-quality product that keeps users coming back.

3. Driving the Business

While product engineering is customer-centric, it also plays a crucial role in driving business success. Engineers need to understand the business model and how their work contributes to key performance indicators (KPIs).

For instance, at Agoda, a travel booking platform, teams might focus on metrics like “Incremental Bookings per Day” in the booking funnel or “Activations” in the Accommodation Supply side. These metrics directly tie to business success while also reflecting improvements in the customer experience.

Key Principles of Product Engineering

1. Problem-Solving Over Feature Building

Teams focus on solving problems rather than just building features. Instead of working from a list of specifications, they start with a problem statement. For example, rather than “Build feature X to specification Y,” a product engineering team might tackle “We don’t have a good enough conversion rate on our booking funnel.”

This approach allows for more creative solutions and ensures that the team’s efforts are always aligned with real user needs and business goals.

2. Cross-Functional Collaboration

Teams are enabled with all the expertise needed to solve the problem at hand. This might include UX designers, security experts, or even legacy system specialists, depending on the project’s needs.

This cross-functional collaboration ensures that all aspects of the product – from its technical architecture to its user interface – are considered from the start, leading to more cohesive and effective solutions.

3. Ownership of Results

Teams take ownership of the results, not just the delivery of features. If a change doesn’t increase conversion rates or solve the intended problem, it’s up to the team to iterate and improve until they achieve the desired results.

This shift from being judged on feature delivery to business results can be challenging for engineers used to traditional methods. As one engineer put it, “It was easier before when I just had to deliver 22 story points. Now you expect me to deliver business results?” However, this ownership leads to more impactful work and a deeper sense of satisfaction when real improvements are achieved.

The Shift from Feature Factories to Problem-Solving Teams

Traditional software development often operates like a “feature factory.” Requirements come in, code goes out, and success is measured by how many features are delivered to specification. This approach can lead to bloated software with features that aren’t used or don’t provide real value, remember our 37% unused software? that’s how companies get to this number.

Product engineering turns this model on its head. Teams are given problems to solve rather than features to build. They have the autonomy to explore different solutions, run experiments, and iterate based on real-world feedback. Success is measured not by features delivered, but by problems solved and value created for users and the business.

Conclusion

Product engineering represents a fundamental shift in how we approach software development. By focusing on customer needs, maintaining a commitment to quality, and aligning closely with business goals, teams are able to create software that truly makes a difference.

In our next post, we’ll explore the mindset required for successful product engineering. We’ll discuss the concept of T-shaped professionals and the balance of technical skills with business acumen that characterizes great product engineers.

What’s your experience with product engineering? Have you seen this approach in action in your organization? Share your thoughts and experiences in the comments below!

The Evolution of Product Engineering: Adapting to a Rapidly Changing World

In today’s fast-paced digital landscape, the way we approach software development is undergoing a significant transformation. As a product engineer with decades of experience in the field, I’ve witnessed firsthand the shift from traditional methodologies to a more dynamic, customer-centric approach. This blog post, the first in our series on Product Engineering, will explore this evolution and why it’s crucial for modern businesses to adapt.

The Changing Landscape of Software Development

Remember the days when software projects followed rigid, long-term plans? When we’d spend months mapping out every detail, stake holder meetings,d esign reviews for weeks architecting a massive new system, before writing a single line of code? Well it’s becoming increasingly clear that it’s no longer sufficient in our rapidly evolving digital world.

The reality is that by the time we finish implementing software based on these detailed plans, the world has often moved on. Our assumptions become outdated, and our solutions may no longer fit the problem at hand. As Mike Tyson says, “Everyone has a plan until they get punched in the mouth.” In software development, that punch often comes in the form of changing market conditions, disruptive technologies, or shifts in user behavior.

The Pitfalls of Traditional Methods

Let’s consider a real-world example. The finance industry has been turned on its head by small, agile fintech startups. Traditional banks, confident in their market position, initially dismissed these newcomers, thinking, “They aren’t stealing our core market.” But before they knew it, these startups were nibbling away at their core business. By the time the banks started planning their response, it was often too late – they were too slow to adapt.

PayPal and Square as examples revolutionized online and mobile payments. While banks were still relying on traditional credit card systems, these startups made it easy for individuals and small businesses to accept payments digitally. By the time banks caught up, PayPal had become a household name, processing over $936 billion in payments in 2020.

Robinhood as well disrupted the investment world by offering commission-free trades and fractional shares, making investing accessible to a new generation. Established brokerages were forced to eliminate trading fees to compete, significantly impacting their revenue models.

This scenario isn’t unique to finance. Across industries, we’re seeing that the old ways of developing software – with long planning cycles and rigid roadmaps – are becoming less effective. In fact, a staggering statistic reveals that 37% of software in large corporations is rarely or never used. Think about that for a moment. We constantly hear about the scarcity of engineering talent, yet more than a third of the software we produce doesn’t provide value. Clearly, something needs to change.

The Rise of Product Engineering

Enter product engineering – a approach that’s gaining traction among the most innovative companies in the world. But what sets apart companies like Spotify, Amazon, and Airbnb? Why do they consistently build software that we love to use?

The answer lies in their approach to product development. These companies understand a fundamental truth that Steve Jobs articulated so well: “A lot of times, people don’t know what they want until you show it to them.” And as far back as Henry Ford as well said, “If I had asked people what they wanted, they would have said faster horses.”

Product engineering isn’t about blindly following customer requests or building features that someone thinks people want. It’s about deeply understanding customer problems and innovating on their behalf. It’s about creating solutions that customers might not even realize they need – yet come to love.

The Need for a New Approach

In the traditional models many companies have built, engineers are often isolated from the product side of things. They’re told to focus solely on coding, “go code, do what you are good at”, protect this precious engineering resource, and don’t let them be disturbed by non-engineering things, with the assumption that someone else will worry about whether the product actually enhances the customer’s life or gets used at all.

This leads to what I call the “feature factory” – a system where engineers are fed requirements through tools like Jira, expected to churn out code, and measured solely on their ability to deliver features to specification. The dreaded term “pixel perfect” comes to mind. But this approach misses a crucial point: the true measure of our work isn’t in the features we ship, but in the value we create for our customers and our business.

Product engineering flips this model on its head. It brings engineers into the heart of the product development process, encouraging them to think deeply about the problems they’re solving and the impact of their work. It’s about creating cross-functional teams that are empowered to make decisions, experiment, and iterate quickly based on real-world feedback.

Looking Ahead

As we dive deeper into this series on Product Engineering, we’ll explore the specific skills, mindsets, and practices that define this approach. We’ll look at how to build empowered, cross-functional teams, how to make decisions in the face of uncertainty, and how to measure success in ways that truly matter.

The evolution of product engineering isn’t just a trend – it’s a necessary adaptation to the realities of modern software development. By embracing this approach, we can create better products, reduce waste, and ultimately deliver more value to our customers and our businesses.

Stay tuned for our next post, where we’ll dive deeper into what exactly makes a product engineering team tick.

What’s your experience with traditional software development versus more modern, product-focused approaches? Share your thoughts in the comments below!

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.