{"@attributes":{"version":"2.0"},"channel":{"title":"Josh Hornby","link":"https:\/\/joshhornby.com","description":"Recent content on Josh Hornby's blog","language":"en-gb","lastBuildDate":"Sun, 26 Jul 2026 09:56:28 +0000","generator":"Jekyll","image":{"url":"https:\/\/joshhornby.com\/assets\/images\/headshot.jpg","title":"Josh Hornby","link":"https:\/\/joshhornby.com"},"item":[{"title":"The Tech Lead Trap","description":"<blockquote>\n  <p>This post is part of my <a href=\"\/tags\/tech-lead\">Tech Lead Series<\/a>, a collection of practical advice for engineers stepping into leadership roles.<\/p>\n<\/blockquote>\n\n<p>A former colleague spent eight years as a tech lead. Same level, different teams. He was good at the job: respected by his teams, trusted by stakeholders, reliable. But when he started looking for his next role, he found that his skills hadn\u2019t grown much in five years. He\u2019d become very good at a job that no longer challenged him.<\/p>\n\n<p>He\u2019d fallen into the tech lead trap.<\/p>\n\n<p>The trap is subtle. The role is comfortable. You\u2019re competent. People appreciate your work. There\u2019s no obvious crisis forcing change. But beneath the surface, you\u2019ve stopped growing. The job that once stretched you now fits too well.<\/p>\n\n<h2 id=\"how-the-trap-works\">How the trap works<\/h2>\n\n<p>The tech lead role has a natural ceiling. Once you\u2019ve mastered the basics, the work becomes routine. You know how to <a href=\"\/running-effective-one-to-ones\">run 1:1s<\/a>. You know how to <a href=\"\/scope-creep-and-how-to-fight-it\">push back on scope<\/a>. You know how to <a href=\"\/balancing-coding-and-leading\">balance coding and leading<\/a>. The challenges that used to require growth now just require execution.<\/p>\n\n<p>This happens for several reasons.<\/p>\n\n<p>Your influence is locally bounded. It extends to your team and maybe some adjacent ones. Problems that require broader influence don\u2019t come to you. You solve the same scale of problems year after year.<\/p>\n\n<p>A well-running team doesn\u2019t need heroics. When things are going well, there\u2019s less pressure to change anything. The reward for doing the job well is doing the same job again.<\/p>\n\n<p>And when work is comfortable, you stop seeking discomfort. But growth happens at the edge of your abilities, not in the middle.<\/p>\n\n<p>The stagnation is invisible too. Your manager sees a reliable performer. Your team sees a steady leader. From the outside, everything looks fine. Sometimes you\u2019re the last person to notice.<\/p>\n\n<h2 id=\"signs-youre-trapped\">Signs you\u2019re trapped<\/h2>\n\n<p>Some signals that suggest you\u2019ve stopped growing:<\/p>\n\n<p>When did you last struggle with something new? If every problem feels like a variation on something you\u2019ve solved before, you\u2019re not being challenged.<\/p>\n\n<p>You can do the job on autopilot. Meetings, decisions, and reviews all run on muscle memory. You\u2019re executing, not thinking.<\/p>\n\n<p>You\u2019ve stopped being curious about new technologies, about how other teams work, about problems outside your domain. Curiosity fades when everything feels familiar.<\/p>\n\n<p>Your conversations haven\u2019t changed. The same stakeholder issues, the same team dynamics, the same technical debates. Nothing new enters the picture.<\/p>\n\n<p>Ask yourself: what have I learned this year that I didn\u2019t know last year? If you can\u2019t answer, you probably haven\u2019t grown.<\/p>\n\n<p>And if you\u2019re waiting for something to change, hoping for a promotion or a new project or a different manager, that\u2019s a sign you\u2019ve given up on creating change yourself.<\/p>\n\n<h2 id=\"getting-out\">Getting out<\/h2>\n\n<p>Escaping the trap requires intentional action. Comfort is self-reinforcing, and you won\u2019t drift out of it.<\/p>\n\n<p>Volunteer for initiatives outside your usual scope. Cross-functional projects, new domains, problems nobody owns. Unfamiliar territory is where the learning happens.<\/p>\n\n<p>Start contributing beyond your team. Help other tech leads, mentor engineers in other groups, take on org-wide technical initiatives. The skills you\u2019ve built have broader applications than you think.<\/p>\n\n<p>Write about what you\u2019re learning. Present to your team or company. Teaching forces you to deepen your understanding and exposes gaps in your knowledge.<\/p>\n\n<p>Figure out what you\u2019re avoiding because it\u2019s uncomfortable. Public speaking? System design? Strategic thinking? Go toward it.<\/p>\n\n<p>Ask people you trust where your blind spots are. What should you be better at? What\u2019s holding you back from the next level? Listen to the answers.<\/p>\n\n<p>And sometimes the trap is the role itself. If you\u2019ve genuinely maxed out growth opportunities where you are, moving to a new team, new company, or new role might be the right answer.<\/p>\n\n<h2 id=\"the-fork-in-the-road\">The fork in the road<\/h2>\n\n<p>Most tech leads eventually face a choice: engineering management or the technical track.<\/p>\n\n<p>Engineering management means moving toward people leadership. Less code, more 1:1s, more organisational thinking. You become responsible for teams, not systems. The tech lead role is partial preparation, but management requires new skills: hiring, performance management, navigating organisational politics.<\/p>\n\n<p>The technical track means moving toward <a href=\"\/doing-leveraged-work\">Staff or Principal engineer roles<\/a>. Deeper technical influence, broader architectural scope, less direct people responsibility. You\u2019re still leading, but through technical contribution rather than team management.<\/p>\n\n<p>Both are valid paths. Neither is a promotion from tech lead; they\u2019re different jobs. The trap often comes from not choosing either, staying in the middle ground where you\u2019re doing some of both but not progressing in either.<\/p>\n\n<p>Think honestly about which path fits you. Some tech leads love the people side and should go toward management. Some love the technical side and should grow into Staff roles. Some discover they want something else entirely.<\/p>\n\n<h2 id=\"staying-sharp-in-the-role\">Staying sharp in the role<\/h2>\n\n<p>Maybe you\u2019re not ready to leave. Maybe you like being a tech lead and want to stay. You can still avoid the trap.<\/p>\n\n<p>Different teams have different challenges. Rotating to a new team resets your learning curve and exposes you to new problems.<\/p>\n\n<p>Volunteer for the struggling team, the complex domain, the critical project. Comfort comes from predictability, and the hardest teams teach you the most.<\/p>\n\n<p>Identify something you want to get better at and pursue it with intent. Take courses, find mentors, put in deliberate practice.<\/p>\n\n<p>Set growth goals alongside your delivery goals. What skill will you have in a year that you don\u2019t have now? What will you understand that you don\u2019t understand today?<\/p>\n\n<p>Don\u2019t wait for performance reviews to get feedback. Ask colleagues what you could do better. Seek out honest assessments of your work.<\/p>\n\n<h2 id=\"the-honest-question\">The honest question<\/h2>\n\n<p>The question to ask yourself: am I still growing?<\/p>\n\n<p>Performance can continue long after growth stops. Comfort can coexist with stagnation. So the question isn\u2019t whether you\u2019re still good at your job. It\u2019s whether you\u2019re still getting better.<\/p>\n\n<p>Are you learning? Are you being challenged? Are you better this year than last year? Will you be better next year than this year?<\/p>\n\n<p>If the answer is no, you\u2019re in the trap. And the only way out is to acknowledge it and do something different.<\/p>\n\n<p>My colleague who spent eight years at the same level eventually moved to a Staff engineer role at a different company. The transition was hard. He\u2019d atrophied in ways he hadn\u2019t noticed. But within a year, he was learning again, challenged again, growing again.<\/p>\n\n<p>The trap isn\u2019t permanent. But escaping it requires you to notice you\u2019re in it.<\/p>\n","pubDate":"Tue, 03 Mar 2026 08:00:00 +0000","link":"https:\/\/joshhornby.com\/the-tech-lead-trap","guid":"https:\/\/joshhornby.com\/the-tech-lead-trap"},{"title":"Inheriting a Struggling Team","description":"<blockquote>\n  <p>This post is part of my <a href=\"\/tags\/tech-lead\">Tech Lead Series<\/a>, a collection of practical advice for engineers stepping into leadership roles.<\/p>\n<\/blockquote>\n\n<p>Inheriting a struggling team is one of the hardest things a tech lead can face. The problems run deep and the trust is gone. But it\u2019s also an opportunity. Teams in crisis have nowhere to go but up.<\/p>\n\n<h2 id=\"understanding-what-youve-inherited\">Understanding what you\u2019ve inherited<\/h2>\n\n<p>Before you can fix anything, you need to understand what\u2019s actually broken. The symptoms are visible; the causes are usually hidden.<\/p>\n\n<p>Talk to everyone. Schedule 1:1s with every team member in your first week. Ask open questions: What\u2019s working? What\u2019s not? What would they change? What do they wish leadership understood? Listen more than you talk. This is the same advice from <a href=\"\/your-first-90-days-as-tech-lead\">your first 90 days<\/a>, but compressed.<\/p>\n\n<p>Read the history. Old retrospectives, incident reports, architecture documents, Slack history. What patterns emerge? What keeps going wrong? What decisions led to this point?<\/p>\n\n<p>Understand the departures. If people left, find out why. Exit interviews if they exist, informal conversations if you can manage them. People leave struggling teams for reasons, and those reasons tell you what\u2019s wrong.<\/p>\n\n<p>Identify the real problems. The obvious problems are rarely the root problems. Technical debt is often a symptom of unclear priorities. Low morale is often a symptom of feeling unheard. Dig until you find causes, not just effects.<\/p>\n\n<p>Find the bright spots. Even struggling teams have things that work. People who still care, processes that function, code that\u2019s actually solid. Build on these rather than only focusing on what\u2019s broken.<\/p>\n\n<h2 id=\"quick-wins-matter\">Quick wins matter<\/h2>\n\n<p>Struggling teams have often been promised turnarounds before. They\u2019ve heard the speeches about change. They\u2019re sceptical, and they should be.<\/p>\n\n<p>Words alone won\u2019t change their minds. Only action builds credibility.<\/p>\n\n<p>Find something you can fix in the first two weeks. Something visible, something the team cares about. It doesn\u2019t have to be big. Cancel a useless meeting. Fix the flaky test that everyone hates. Push back on an unreasonable demand from stakeholders.<\/p>\n\n<p>The specific fix matters less than what it signals: things can change, and raising problems with you leads to action.<\/p>\n\n<h2 id=\"building-trust\">Building trust<\/h2>\n\n<p>Trust is usually the deepest wound. The team has learned that leadership doesn\u2019t deliver and that feedback goes nowhere.<\/p>\n\n<p>You rebuild trust through consistent, small actions over time.<\/p>\n\n<p>Do what you say. Every commitment you make matters. If you say you\u2019ll follow up on something, follow up. If you say you\u2019ll look into a problem, look into it. Dropped promises confirm that nothing has changed.<\/p>\n\n<p>Be honest about what you can\u2019t do. Don\u2019t promise changes you can\u2019t deliver. \u201cI can\u2019t fix the technical debt in a month, but I can protect time for us to address some of it\u201d is honest. \u201cI\u2019ll fix everything\u201d is a lie.<\/p>\n\n<p>Admit what you don\u2019t know. You\u2019re new. You don\u2019t understand everything. Pretending otherwise insults your team\u2019s intelligence. \u201cI\u2019m still learning how this system works\u201d builds more trust than pretending expertise.<\/p>\n\n<p>Protect them. When unreasonable demands come from outside, push back. Let the team see you doing it. Nothing builds trust faster than watching your lead fight for you.<\/p>\n\n<p>Follow through on feedback. When someone raises a concern, act on it or explain why you can\u2019t. The worst thing is asking for feedback and then ignoring it.<\/p>\n\n<h2 id=\"addressing-performance-issues\">Addressing performance issues<\/h2>\n\n<p>Struggling teams sometimes have performance issues that were never addressed. The previous lead was too busy firefighting or too conflict-averse to have hard conversations.<\/p>\n\n<p>You need to address these, but carefully.<\/p>\n\n<p>Don\u2019t rush to judgment. People underperform in struggling teams because the environment enables it. Before concluding someone is a poor performer, give them a functioning team to perform in.<\/p>\n\n<p>Separate environment from individual. Is this person struggling because they\u2019re not capable, or because the context makes success impossible? Fix the context before judging the person.<\/p>\n\n<p>But don\u2019t avoid the hard calls. Sometimes performance issues are real and persistent. When you\u2019ve given someone a fair chance and <a href=\"\/giving-hard-feedback\">clear feedback<\/a> and they still don\u2019t improve, you have to act. Keeping non-performers hurts everyone else.<\/p>\n\n<p>Be fair. The team is watching how you handle this. If you\u2019re arbitrary or cruel, trust collapses. If you\u2019re fair and clear, even difficult decisions can build credibility.<\/p>\n\n<h2 id=\"fixing-the-technical-mess\">Fixing the technical mess<\/h2>\n\n<p>Struggling teams usually have struggling codebases. Tech debt, architectural rot, unclear ownership. This can\u2019t be fixed quickly, but it can be managed.<\/p>\n\n<p>Stop the bleeding. Before improving anything, stop making it worse. Establish minimum quality standards. No more shortcuts that create debt.<\/p>\n\n<p>Make debt visible. Track it. Name it. Talk about it in planning. When the team understands the cost of debt, they\u2019re more motivated to address it. I\u2019ve written about <a href=\"\/reconsidering-tech-debt\">reconsidering how we talk about tech debt<\/a> elsewhere.<\/p>\n\n<p>Carve out time. Protect some percentage of each sprint for debt reduction. It doesn\u2019t have to be a lot. Even 10% adds up.<\/p>\n\n<p>Celebrate progress. When a gnarly piece of code gets cleaned up, acknowledge it. Progress on debt is real work, even if stakeholders don\u2019t see it.<\/p>\n\n<p>Be patient. Technical messes take time to create and time to fix. Rushing leads to new messes. Steady, sustainable progress beats heroic sprints.<\/p>\n\n<h2 id=\"communicating-with-stakeholders\">Communicating with stakeholders<\/h2>\n\n<p>Stakeholders have learned not to trust this team. They\u2019ve been burned by missed deadlines and broken features. Your job is to reset that relationship.<\/p>\n\n<p>Be honest about where things stand. Don\u2019t hide the mess. \u201cWe have significant technical debt that\u2019s slowing us down and causing quality issues\u201d is better than pretending everything is fine.<\/p>\n\n<p>Set realistic expectations. Don\u2019t promise a quick turnaround to look good. Set timelines you can actually meet. Rebuilt trust comes from kept promises, not optimistic forecasts.<\/p>\n\n<p>Show progress. Regular updates on what\u2019s improving. Delivered features, but also fewer incidents and better velocity. Make the recovery visible.<\/p>\n\n<p>Ask for what you need. If the team needs time, resources, or protection from demands, advocate for it. Stakeholders can be reasonable when they understand the situation.<\/p>\n\n<h2 id=\"the-long-game\">The long game<\/h2>\n\n<p>Turning around a struggling team takes time. Not weeks, but months. Probably six months before things feel genuinely different. A year before the turnaround is clearly sustained.<\/p>\n\n<p>The early period is the hardest. You\u2019re fixing problems that aren\u2019t your fault and cleaning up messes you didn\u2019t make. It feels thankless.<\/p>\n\n<p>But teams that come through hard times together get strong. The engineers who stay through a turnaround are committed, and they don\u2019t take things for granted.<\/p>\n\n<p>The way up is hard, but it\u2019s rewarding in ways that leading a comfortable team never is.<\/p>\n","pubDate":"Fri, 27 Feb 2026 08:00:00 +0000","link":"https:\/\/joshhornby.com\/inheriting-a-struggling-team","guid":"https:\/\/joshhornby.com\/inheriting-a-struggling-team"},{"title":"Giving Hard Feedback","description":"<blockquote>\n  <p>This post is part of my <a href=\"\/tags\/tech-lead\">Tech Lead Series<\/a>, a collection of practical advice for engineers stepping into leadership roles.<\/p>\n<\/blockquote>\n\n<p>Giving hard feedback is uncomfortable. That\u2019s why most people avoid it. But avoiding it doesn\u2019t make the problem go away. It just delays the conversation until it\u2019s worse for everyone.<\/p>\n\n<h2 id=\"why-hard-feedback-matters\">Why hard feedback matters<\/h2>\n\n<p>People can\u2019t fix what they don\u2019t know is broken. When you see a performance issue, an attitude problem, or a skill gap and don\u2019t address it, you\u2019re denying that person the chance to improve.<\/p>\n\n<p>You\u2019re also being unfair to your team. If an engineer consistently ships late, everyone\u2019s velocity drops. Dismissive behaviour in code reviews stops people from contributing. When you don\u2019t address these problems, you\u2019re choosing the comfort of avoiding one conversation over the wellbeing of the whole team.<\/p>\n\n<p>Hard feedback is a form of respect. You\u2019re treating someone as capable of hearing the truth and acting on it. The alternative, staying quiet, assumes they can\u2019t handle it. As Camille Fournier notes in <a href=\"\/notes-on-a-managers-path\">The Manager\u2019s Path<\/a>, \u201cwhen they believe that their manager sees the good things they do, they\u2019ll be more open to hearing about the areas where they might improve.\u201d<\/p>\n\n<h2 id=\"principles\">Principles<\/h2>\n\n<p>Some ideas that guide how to approach these conversations.<\/p>\n\n<p>Sooner is better. Small issues addressed early are minor corrections. The same issues left for months become serious problems. If you notice something in week two, address it in week three, not month six.<\/p>\n\n<p>Specific is better than vague. \u201cYour work has been slipping\u201d is hard to act on. \u201cThe last three PRs had bugs that made it to production, and you\u2019ve missed two sprint commitments\u201d is specific and something they can act on.<\/p>\n\n<p>Behaviour, not character. You\u2019re addressing what someone did, not who they are. \u201cYou interrupted colleagues in the last three meetings\u201d is about behaviour. \u201cYou\u2019re rude\u201d is about character. One can be changed; the other feels like an attack.<\/p>\n\n<p>Private, not public. Hard feedback happens in <a href=\"\/running-effective-one-to-ones\">1:1s<\/a>, not in team meetings. Public criticism shames; private conversations allow for honest discussion.<\/p>\n\n<p>Direct, not cruel. You can be clear without being harsh. \u201cI need to see improvement in the next month\u201d is direct. \u201cYou\u2019re the worst performer on the team\u201d is cruel and useless.<\/p>\n\n<h2 id=\"the-conversation\">The conversation<\/h2>\n\n<p>A structure that I have found to work is:<\/p>\n\n<p>State the purpose. Don\u2019t hide what the conversation is about. \u201cI want to talk about some concerns I\u2019ve had with recent work.\u201d Let them know this is serious.<\/p>\n\n<p>Share what you\u2019ve seen. Describe it with specific examples. Facts, not judgments. \u201cIn the last sprint, two of your stories weren\u2019t completed, and the one that shipped had a bug that caused an hour of downtime.\u201d<\/p>\n\n<p>Explain why it matters. \u201cWhen stories don\u2019t get completed, the team has to pick up the slack. When bugs ship, we lose customer trust and spend time on firefighting.\u201d<\/p>\n\n<p>Ask for their view. You might not have the full picture. \u201cWhat\u2019s your take on this? Is there context I\u2019m missing?\u201d Sometimes there are good reasons. Sometimes you learn about blockers you can remove.<\/p>\n\n<p>Set clear expectations. Be specific about what needs to change. \u201cGoing forward, I need you to complete your committed stories and test more before marking things done.\u201d<\/p>\n\n<p>Agree on next steps. What happens now? A plan to fix the issues? More check-ins? Specific support you\u2019ll provide? Make it real.<\/p>\n\n<p>Write it down. Write up what was discussed and share it. This creates accountability and protects both of you if things escalate.<\/p>\n\n<h2 id=\"handling-reactions\">Handling reactions<\/h2>\n\n<p>Hard feedback often triggers emotional reactions. Some common ones and how to handle them.<\/p>\n\n<p>Defensiveness. They explain why each example isn\u2019t really their fault. Acknowledge their view without backing off. \u201cI hear that there were constraints, and those are worth discussing. But the pattern concerns me regardless of the reasons.\u201d<\/p>\n\n<p>Deflection. They point to other people\u2019s problems instead. Redirect. \u201cWe\u2019re talking about your situation right now. Other issues are separate conversations.\u201d<\/p>\n\n<p>Silence. They shut down and won\u2019t engage. Give them space. \u201cI know this is a lot to process. You don\u2019t have to respond now, but I\u2019d like to check in again tomorrow.\u201d<\/p>\n\n<p>Anger. They get upset or hostile. Stay calm. \u201cI can see this is frustrating. I\u2019m not trying to attack you; I\u2019m trying to help us find a way forward.\u201d<\/p>\n\n<p>Tears. Emotional reactions happen. Don\u2019t panic or rush to end the conversation. Pause, offer a tissue, give them a moment. The feedback still needs to be heard.<\/p>\n\n<p>Your job is to deliver the message clearly and kindly, not to manage their emotional response. Let them react, acknowledge their feelings, but don\u2019t let the reaction derail the conversation.<\/p>\n\n<h2 id=\"following-up\">Following up<\/h2>\n\n<p>The conversation is only the beginning.<\/p>\n\n<p>Check in often. Don\u2019t wait months to see if things improved. Weekly or fortnightly, ask how things are going, note progress, address continued concerns.<\/p>\n\n<p>Name improvement. When things get better, say so. \u201cI\u2019ve noticed the last two sprints have gone much better. Nice work.\u201d Positive feedback matters.<\/p>\n\n<p>Escalate if needed. If things don\u2019t improve despite clear feedback and support, you need to escalate. This means involving HR, starting formal processes, or having honest conversations about whether this role is the right fit.<\/p>\n\n<p>Keep records. Document your conversations, the expectations you set, and whether they were met. This protects both of you and makes sure nothing gets forgotten.<\/p>\n\n<h2 id=\"common-mistakes\">Common mistakes<\/h2>\n\n<p>Sandwiching. Hiding criticism between compliments waters down the message. The person hears the praise and misses the point. Be direct about what needs to change.<\/p>\n\n<p>Waiting too long. The longer you wait, the worse it gets. Address issues early.<\/p>\n\n<p>Being vague. Fuzzy feedback is useless. \u201cDo better\u201d doesn\u2019t tell anyone what to do differently. Be specific.<\/p>\n\n<p>Making it personal. Attacking someone\u2019s character guarantees defensiveness. Stick to behaviour and impact.<\/p>\n\n<p>Not following up. Feedback without follow-up is pointless. The conversation is step one, not the whole process.<\/p>\n\n<p>Avoiding it entirely. The most common mistake. Hoping that problems will go away on their own.<\/p>\n\n<h2 id=\"when-it-doesnt-work\">When it doesn\u2019t work<\/h2>\n\n<p>Sometimes, despite clear feedback and genuine support, things don\u2019t improve. This is hard but important to accept.<\/p>\n\n<p>Not everyone can do every job. Some people are in the wrong role, or have problems that feedback can\u2019t reach, or hear the feedback and choose to ignore it.<\/p>\n\n<p>When you\u2019ve been clear and supportive and improvement still doesn\u2019t happen, it\u2019s time for a different conversation. That might mean a performance improvement plan, a role change, or an exit.<\/p>\n\n<p>You gave them information and opportunity. What they do with it is their choice.<\/p>\n\n<h2 id=\"the-hardest-part\">The hardest part<\/h2>\n\n<p>The hardest part isn\u2019t the conversation itself. It\u2019s the days before, when you\u2019re dreading it. The anxiety, the script-writing in your head, the temptation to postpone.<\/p>\n\n<p>The anticipation is always worse than the reality. Once you\u2019re in the conversation, you\u2019re problem-solving, not suffering. And afterward, regardless of how it went, you\u2019ve done your job. You\u2019ve given someone the information they need to get better.<\/p>\n","pubDate":"Tue, 24 Feb 2026 08:00:00 +0000","link":"https:\/\/joshhornby.com\/giving-hard-feedback","guid":"https:\/\/joshhornby.com\/giving-hard-feedback"},{"title":"Making Technical Decisions Stick","description":"<blockquote>\n  <p>This post is part of my <a href=\"\/tags\/tech-lead\">Tech Lead Series<\/a>, a collection of practical advice for engineers stepping into leadership roles.<\/p>\n<\/blockquote>\n\n<p>Decisions that aren\u2019t documented don\u2019t exist. They live in the memories of people who were in the room, until those people leave or forget. A decision made in a Slack thread that scrolled past last month might as well never have happened.<\/p>\n\n<p>Making technical decisions is one part of the job. Making them stick is another.<\/p>\n\n<h2 id=\"why-decisions-dont-stick\">Why decisions don\u2019t stick<\/h2>\n\n<p>Even good decisions fade without reinforcement.<\/p>\n\n<p>They\u2019re not written down. Verbal agreements in meetings evaporate. Six months later, nobody remembers what was decided or why.<\/p>\n\n<p>They\u2019re written but not findable. The decision exists in a Confluence page nobody visits, a Slack message nobody searches for, a Google Doc with no clear owner.<\/p>\n\n<p>The context isn\u2019t captured. Even when the decision is documented, the reasoning isn\u2019t. A year later, someone asks \u201cwhy do we do it this way?\u201d and nobody can answer.<\/p>\n\n<p>People weren\u2019t involved. Decisions made by a small group don\u2019t feel binding to people who weren\u2019t asked. They\u2019ll revisit the decision because, from their view, it was never really made.<\/p>\n\n<p>There\u2019s no follow-up. A decision without follow-through is just a suggestion. If nobody checks whether the decision is being followed, it won\u2019t be.<\/p>\n\n<h2 id=\"match-documentation-to-the-decisions-weight\">Match documentation to the decision\u2019s weight<\/h2>\n\n<p>Not every decision needs the same treatment.<\/p>\n\n<p>Verbal agreement works for small, reversible decisions that affect only your team. \u201cLet\u2019s use this library for parsing.\u201d These can live in team memory, but expect to explain them again.<\/p>\n\n<p>Written summary works for decisions that affect multiple people or span time. A Slack message that captures what was decided and why. A brief note in your team\u2019s docs. Easy to write, easy to find later.<\/p>\n\n<p>Architecture Decision Records (ADRs) work for decisions that limit future work. An ADR is a short document that captures the context, the decision, the options you rejected, and the results. It becomes part of your project\u2019s permanent record.<\/p>\n\n<p>Request for Comments (RFCs) work for decisions that need input from many people or have high stakes. An RFC is a proposal that invites feedback before the decision is final. It documents both the decision and the discussion that led to it.<\/p>\n\n<p>Most decisions need only verbal agreement or written summary. Save ADRs and RFCs for decisions that matter: technology choices, architectural patterns, interfaces between systems.<\/p>\n\n<h2 id=\"adrs-work-because-theyre-simple\">ADRs work because they\u2019re simple<\/h2>\n\n<p>A good ADR has six sections and fits on one page:<\/p>\n\n<div class=\"language-markdown highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"gh\"># ADR-001: Use PostgreSQL for user service data<\/span>\n\n<span class=\"gu\">## Status<\/span>\nAccepted\n\n<span class=\"gu\">## Context<\/span>\nWe need a database for the new user service. The team has\nexperience with PostgreSQL and MongoDB. We expect relational\nqueries across user profiles, permissions, and audit logs.\n\n<span class=\"gu\">## Decision<\/span>\nUse PostgreSQL for the user service.\n\n<span class=\"gu\">## Results<\/span>\nRelational queries are straightforward. We lose MongoDB's\nflexible schema, which means migrations for schema changes.\n\n<span class=\"gu\">## Options rejected<\/span>\n<span class=\"p\">-<\/span> <span class=\"gs\">**MongoDB**<\/span>: Better schema flexibility, but our query\n  patterns are relational and the team knows PostgreSQL better.\n<span class=\"p\">-<\/span> <span class=\"gs\">**MySQL**<\/span>: Similar fit, but less team experience and weaker\n  JSON support.\n<\/code><\/pre><\/div><\/div>\n\n<p>Keep it short. If writing an ADR feels like a chore, it\u2019s too long.<\/p>\n\n<h2 id=\"rfcs-fail-when-theyre-theatre\">RFCs fail when they\u2019re theatre<\/h2>\n\n<p>RFCs work when they\u2019re real invitations for input, not rubber stamps.<\/p>\n\n<p>Share early. An RFC shared before you\u2019ve made up your mind gets real feedback. An RFC shared when you\u2019ve already decided just annoys people.<\/p>\n\n<p>Set clear timelines. \u201cComments by Friday\u201d creates urgency. Open-ended feedback periods drag on forever.<\/p>\n\n<p>Ask for feedback directly. Don\u2019t just post and wait. Ask specific people for input. Reach out to experts, to people affected, to the usual critics.<\/p>\n\n<p>Respond to feedback. Every comment deserves a response, even if it\u2019s \u201cthanks, but we\u2019re going in a different direction for these reasons.\u201d Ignored feedback teaches people not to bother.<\/p>\n\n<p>Make the decision. An RFC that never ends is worse than no RFC. Set a deadline, weigh the feedback, make a call, and write it down.<\/p>\n\n<h2 id=\"involvement-determines-whether-decisions-stick\">Involvement determines whether decisions stick<\/h2>\n\n<p>The decision-making process matters as much as the decision itself.<\/p>\n\n<p>Involve the right people. Not everyone, but not no one. People affected by a decision should have a chance to weigh in. Decisions made without asking get reopened.<\/p>\n\n<p>Be explicit about the process. Are we deciding today? Taking input for a week then deciding? Who makes the final call? Vagueness about the process creates vagueness about the outcome.<\/p>\n\n<p>Disagree and commit. Once a decision is made, everyone should support it, even those who disagreed. If you can\u2019t get to agreement, be clear that you\u2019re making a call despite disagreement and expect people to follow through.<\/p>\n\n<p>Don\u2019t revisit without new information. Every decision can be debated forever. Establish that decisions stay made unless new facts arise. \u201cWe already decided this\u201d should end conversations, not start them.<\/p>\n\n<h2 id=\"decisions-need-visible-attention-not-rules\">Decisions need visible attention, not rules<\/h2>\n\n<p>Follow-through doesn\u2019t mean a heavy process. It means someone is paying attention.<\/p>\n\n<p>Check in code reviews. When you see code that goes against a decision, ask about it. Sometimes there\u2019s a good reason; sometimes someone didn\u2019t know. Either way, it\u2019s a conversation.<\/p>\n\n<p>Point to decisions in discussions. When someone proposes something that clashes with a prior decision, point to the docs. \u201cWe decided X for these reasons. Has something changed?\u201d<\/p>\n\n<p>Update decisions when needed. Things change. If a decision no longer makes sense, update it explicitly. Don\u2019t let it fade away; mark it replaced and write down why.<\/p>\n\n<p>Model it yourself. Your own code should follow the decisions. If you ignore the standards, so will everyone else.<\/p>\n\n<h2 id=\"keep-decisions-close-to-the-code\">Keep decisions close to the code<\/h2>\n\n<p>ADRs belong in a <code class=\"language-plaintext highlighter-rouge\">\/docs\/adr<\/code> folder in the repo. Decisions about this system live with this system. They\u2019re versioned, searchable, and visible in pull requests.<\/p>\n\n<p>Use a consistent numbering system. ADR-001, ADR-002. Numbers make decisions easy to reference. \u201cThis implements ADR-015\u201d in a commit message links decisions to outcomes.<\/p>\n\n<p>Review active ADRs once a quarter. Are they still relevant? Are they being followed? Are any ready to be replaced?<\/p>\n\n<p>If your team has never used ADRs, don\u2019t mandate them for everything. Write one ADR for one decision and see if it helps. Build the habit bit by bit.<\/p>\n\n<h2 id=\"shared-understanding-not-bureaucracy\">Shared understanding, not bureaucracy<\/h2>\n\n<p>Technical decisions shape how your systems evolve. When they\u2019re undocumented, they get forgotten. When they\u2019re made in isolation, they get ignored. When nobody follows up, they become suggestions.<\/p>\n\n<p>The goal isn\u2019t a process for its own sake. It\u2019s building a shared record of how things work and why. New team members can understand the system\u2019s history. Future decisions build on past ones. And the PostgreSQL you decided on three months ago is still what gets used.<\/p>\n","pubDate":"Fri, 20 Feb 2026 08:00:00 +0000","link":"https:\/\/joshhornby.com\/making-technical-decisions-stick","guid":"https:\/\/joshhornby.com\/making-technical-decisions-stick"},{"title":"Scope Creep and How to Fight It","description":"<blockquote>\n  <p>This post is part of my <a href=\"\/tags\/tech-lead\">Tech Lead Series<\/a>, a collection of practical advice for engineers stepping into leadership roles.<\/p>\n<\/blockquote>\n\n<p>Scope creep doesn\u2019t announce itself. It arrives as reasonable requests. Each addition seems small. Each conversation is \u201cwhile we\u2019re building this anyway.\u201d Nobody makes a bad decision. You just make a lot of small decisions that add up to a bad outcome.<\/p>\n\n<h2 id=\"recognising-the-pattern\">Recognising the pattern<\/h2>\n\n<p>\u201cWhile you\u2019re in there\u2026\u201d The logic seems sound. You\u2019re already working on the payment system, so why not also add refund handling? Because refund handling is a week of work you didn\u2019t plan for.<\/p>\n\n<p>\u201cThis would only take a few hours.\u201d People who don\u2019t build software underestimate how long things take. A few hours becomes a few days. And even if it really is a few hours, a dozen \u201cfew hours\u201d changes add up to weeks.<\/p>\n\n<p>\u201cThe customer really needs this.\u201d Urgent requests from customers feel impossible to refuse. But customers will always want more. The question is whether this specific addition is worth delaying everything else.<\/p>\n\n<p>\u201cWe forgot to include\u2026\u201d Discovered requirements feel like corrections to an incomplete plan, not additions. But they\u2019re still additions. The plan was complete for what it covered; this is new scope.<\/p>\n\n<p>\u201cJust a small tweak.\u201d Small is relative. Small compared to the original feature can still be real work. And small things ship with testing, documentation, edge cases.<\/p>\n\n<p>The common thread: each individual addition seems fair. The problem is the pile-up.<\/p>\n\n<h2 id=\"why-it-happens\">Why it happens<\/h2>\n\n<p>Scope creep has a few common causes.<\/p>\n\n<p>When the initial scope isn\u2019t well-defined, people fill in the gaps with guesses. Those guesses get turned into requirements as work moves forward.<\/p>\n\n<p>Different stakeholders have different expectations. Without clear agreement on what\u2019s in and what\u2019s out, each stakeholder adds their own version. This is why <a href=\"\/working-with-product-managers\">working closely with your PM<\/a> on scope definition matters so much.<\/p>\n\n<p>PMs want to please stakeholders. Tech leads want to please PMs. Engineers want to solve problems. Everyone\u2019s drive is to say yes. Nobody\u2019s drive is to protect the timeline. Learning <a href=\"\/when-to-say-no\">when to say no<\/a> is one of the most important skills for a tech lead.<\/p>\n\n<p>\u201cWe\u2019ve already built most of it, we might as well add this too.\u201d The logic feels right but ignores that adding more makes it even later.<\/p>\n\n<p>When the goal is vague (\u201cmake a good dashboard\u201d), scope expands to fill available time. When the goal is specific (\u201cshow four metrics, ship in two weeks\u201d), additions are clearly out of scope.<\/p>\n\n<h2 id=\"fighting-it-early\">Fighting it early<\/h2>\n\n<p>The best time to fight scope creep is before work starts.<\/p>\n\n<p>Define scope explicitly. Write down what\u2019s included and what\u2019s not. \u201cThe dashboard will show revenue, users, conversion, and churn. It will not include filtering, export, or alerts.\u201d The explicit exclusions matter as much as the inclusions.<\/p>\n\n<p>Get sign-off on boundaries. Make sure everyone with influence agrees on the scope before work begins. When additions arise, you can point back to the agreement.<\/p>\n\n<p>Size the work with buffers. Things take longer than estimated. If you estimate two weeks and commit to two weeks, any addition blows the timeline. Build in slack so small adjustments don\u2019t become crises. <a href=\"\/estimation-pragmatism\">Good estimation<\/a> is about reducing surprises, not hitting perfect numbers.<\/p>\n\n<p>Separate must-have from nice-to-have. Prioritise hard upfront. When pressure mounts, you know what can be cut.<\/p>\n\n<h2 id=\"having-the-conversation\">Having the conversation<\/h2>\n\n<p>Once creep starts, you need to address it directly. Hoping it stops on its own doesn\u2019t work.<\/p>\n\n<p>Name what\u2019s happening. \u201cI\u2019ve noticed we\u2019ve added six features since we started. Our two-week timeline is now impossible.\u201d Make the pile-up visible.<\/p>\n\n<p>Quantify the impact. \u201cEach of these additions adds roughly two days. We\u2019re now looking at five weeks instead of two.\u201d Numbers make the problem concrete.<\/p>\n\n<p>Present the trade-offs. \u201cWe can add this feature, but it means either pushing the deadline or cutting something else. Which would you prefer?\u201d Force a decision rather than absorbing the cost silently.<\/p>\n\n<p>If the team committed to a deadline, additions should require clear trade-offs. Don\u2019t let creep quietly extend timelines. Make it a choice. And <a href=\"\/managing-up-as-tech-lead\">keep your manager informed<\/a> as scope shifts. They\u2019ll need to manage expectations upward too.<\/p>\n\n<p>When scope changes, record what changed and why. This creates accountability and helps prevent further creep.<\/p>\n\n<h2 id=\"structural-approaches\">Structural approaches<\/h2>\n\n<p>Beyond conversations, some structural approaches help.<\/p>\n\n<p>Timebox hard. \u201cWe have two weeks. What can we build in two weeks?\u201d Start from the constraint and work backward. This frames additions as taking away from something else.<\/p>\n\n<p>Keep a backlog of good ideas that aren\u2019t in scope for this release. People feel heard without adding to current work. Review the list for the next iteration.<\/p>\n\n<p>Smaller releases mean less chance for creep. A two-week feature ships before scope can expand much. A six-month project piles up changes for six months.<\/p>\n\n<p>Some teams require formal change requests for scope additions. The process itself discourages casual additions.<\/p>\n\n<p>Burn-down charts, task boards, regular demos. When everyone can see how much work remains, it\u2019s harder to pretend additions are free. <a href=\"\/hill-charts\">Hill charts<\/a> are useful here because they show uncertainty, not just completion.<\/p>\n\n<h2 id=\"when-to-accept-changes\">When to accept changes<\/h2>\n\n<p>Not all scope additions are creep. Sometimes requirements genuinely change. Sometimes you learn something that makes the original plan wrong.<\/p>\n\n<p>Changes driven by new information are legitimate: user research reveals the original approach won\u2019t work, a dependency changes, business conditions shift, or you discover a technical constraint that forces redesign.<\/p>\n\n<p>The difference is whether the change is driven by new information or by wishful thinking. \u201cWe learned customers don\u2019t use this feature that way\u201d is new information. \u201cThe VP would also like it to do this\u201d is wishful thinking.<\/p>\n\n<p>Accept legitimate changes. Push back on scope creep. The skill is knowing which is which.<\/p>\n\n<h2 id=\"the-uncomfortable-truth\">The uncomfortable truth<\/h2>\n\n<p>You cannot eliminate scope creep entirely. Some additions will get through. Some projects will expand beyond their original bounds. This is normal.<\/p>\n\n<p>What you can do is make creep visible, make trade-offs explicit, and protect the team\u2019s ability to deliver. Every time you let an addition slip in without discussion, you teach stakeholders that additions are free. Every time you name the creep and force a decision, you build a culture that respects constraints.<\/p>\n","pubDate":"Tue, 17 Feb 2026 08:00:00 +0000","link":"https:\/\/joshhornby.com\/scope-creep-and-how-to-fight-it","guid":"https:\/\/joshhornby.com\/scope-creep-and-how-to-fight-it"},{"title":"Sentry AI Tracing with Laravel's AI SDK","description":"<p>In my <a href=\"\/sentry-ai-tracing-laravel\">previous post<\/a>, I showed how to manually wrap OpenAI calls with Sentry spans to get LLM monitoring in PHP. It worked, but every AI call needed to pass through a tracing method. You had to remember to use it.<\/p>\n\n<p>Laravel now ships an <a href=\"https:\/\/laravel.com\/docs\/12.x\/ai-sdk\">official AI SDK<\/a> (<code class=\"language-plaintext highlighter-rouge\">laravel\/ai<\/code>) that changes this. The SDK fires events for every agent interaction, which means tracing becomes a listener. Write it once, and every AI call in your app gets traced automatically.<\/p>\n\n<h2 id=\"what-changed\">What changed<\/h2>\n\n<p>The old approach required wrapping each call:<\/p>\n\n<div class=\"language-php highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nv\">$response<\/span> <span class=\"o\">=<\/span> <span class=\"nv\">$this<\/span><span class=\"o\">-&gt;<\/span><span class=\"nf\">traceOpenAIRequest<\/span><span class=\"p\">(<\/span>\n    <span class=\"n\">operation<\/span><span class=\"o\">:<\/span> <span class=\"s1\">'chat_completions'<\/span><span class=\"p\">,<\/span>\n    <span class=\"n\">model<\/span><span class=\"o\">:<\/span> <span class=\"nv\">$model<\/span><span class=\"p\">,<\/span>\n    <span class=\"n\">messages<\/span><span class=\"o\">:<\/span> <span class=\"nv\">$messages<\/span><span class=\"p\">,<\/span>\n    <span class=\"n\">apiCall<\/span><span class=\"o\">:<\/span> <span class=\"k\">fn<\/span> <span class=\"p\">()<\/span> <span class=\"o\">=&gt;<\/span> <span class=\"nv\">$this<\/span><span class=\"o\">-&gt;<\/span><span class=\"n\">client<\/span><span class=\"o\">-&gt;<\/span><span class=\"nf\">chat<\/span><span class=\"p\">()<\/span><span class=\"o\">-&gt;<\/span><span class=\"nf\">create<\/span><span class=\"p\">(<\/span><span class=\"nv\">$params<\/span><span class=\"p\">)<\/span>\n<span class=\"p\">);<\/span>\n<\/code><\/pre><\/div><\/div>\n\n<p>The new SDK has agents as first-class objects. You define an agent class with instructions, tools, and a schema, then call <code class=\"language-plaintext highlighter-rouge\">prompt()<\/code>:<\/p>\n\n<div class=\"language-php highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nv\">$response<\/span> <span class=\"o\">=<\/span> <span class=\"p\">(<\/span><span class=\"k\">new<\/span> <span class=\"nc\">SalesCoach<\/span><span class=\"p\">)<\/span><span class=\"o\">-&gt;<\/span><span class=\"nf\">prompt<\/span><span class=\"p\">(<\/span><span class=\"s1\">'Analyse this sales transcript...'<\/span><span class=\"p\">);<\/span>\n<\/code><\/pre><\/div><\/div>\n\n<p>The SDK fires events at each stage. Six are useful for tracing:<\/p>\n\n<ul>\n  <li><code class=\"language-plaintext highlighter-rouge\">PromptingAgent<\/code> \/ <code class=\"language-plaintext highlighter-rouge\">AgentPrompted<\/code> for non-streamed chat calls<\/li>\n  <li><code class=\"language-plaintext highlighter-rouge\">StreamingAgent<\/code> \/ <code class=\"language-plaintext highlighter-rouge\">AgentStreamed<\/code> for streamed chat calls<\/li>\n  <li><code class=\"language-plaintext highlighter-rouge\">GeneratingEmbeddings<\/code> \/ <code class=\"language-plaintext highlighter-rouge\">EmbeddingsGenerated<\/code> for embedding calls<\/li>\n<\/ul>\n\n<p>Each event carries an <code class=\"language-plaintext highlighter-rouge\">invocationId<\/code> to correlate the start and finish.<\/p>\n\n<h2 id=\"the-listener\">The listener<\/h2>\n\n<p>Instead of wrapping individual calls, register a single event listener:<\/p>\n\n<div class=\"language-php highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"cp\">&lt;?php<\/span>\n\n<span class=\"kn\">namespace<\/span> <span class=\"nn\">App\\Listeners<\/span><span class=\"p\">;<\/span>\n\n<span class=\"kn\">use<\/span> <span class=\"nc\">Laravel\\Ai\\Events\\AgentPrompted<\/span><span class=\"p\">;<\/span>\n<span class=\"kn\">use<\/span> <span class=\"nc\">Laravel\\Ai\\Events\\AgentStreamed<\/span><span class=\"p\">;<\/span>\n<span class=\"kn\">use<\/span> <span class=\"nc\">Laravel\\Ai\\Events\\EmbeddingsGenerated<\/span><span class=\"p\">;<\/span>\n<span class=\"kn\">use<\/span> <span class=\"nc\">Laravel\\Ai\\Events\\GeneratingEmbeddings<\/span><span class=\"p\">;<\/span>\n<span class=\"kn\">use<\/span> <span class=\"nc\">Laravel\\Ai\\Events\\PromptingAgent<\/span><span class=\"p\">;<\/span>\n<span class=\"kn\">use<\/span> <span class=\"nc\">Laravel\\Ai\\Events\\StreamingAgent<\/span><span class=\"p\">;<\/span>\n<span class=\"kn\">use<\/span> <span class=\"nc\">Laravel\\Ai\\Providers\\Provider<\/span><span class=\"p\">;<\/span>\n<span class=\"kn\">use<\/span> <span class=\"nc\">Sentry\\SentrySdk<\/span><span class=\"p\">;<\/span>\n<span class=\"kn\">use<\/span> <span class=\"nc\">Sentry\\Tracing\\Span<\/span><span class=\"p\">;<\/span>\n<span class=\"kn\">use<\/span> <span class=\"nc\">Sentry\\Tracing\\SpanContext<\/span><span class=\"p\">;<\/span>\n<span class=\"kn\">use<\/span> <span class=\"nc\">Sentry\\Tracing\\SpanStatus<\/span><span class=\"p\">;<\/span>\n\n<span class=\"kd\">class<\/span> <span class=\"nc\">SentryAiTracing<\/span>\n<span class=\"p\">{<\/span>\n    <span class=\"cd\">\/**\n     * @var array&lt;string, array{span: Span, parentSpan: Span}&gt;\n     *\/<\/span>\n    <span class=\"k\">protected<\/span> <span class=\"k\">static<\/span> <span class=\"k\">array<\/span> <span class=\"nv\">$activeSpans<\/span> <span class=\"o\">=<\/span> <span class=\"p\">[];<\/span>\n\n    <span class=\"k\">public<\/span> <span class=\"k\">function<\/span> <span class=\"n\">handlePromptingAgent<\/span><span class=\"p\">(<\/span><span class=\"kt\">PromptingAgent<\/span> <span class=\"nv\">$event<\/span><span class=\"p\">):<\/span> <span class=\"kt\">void<\/span>\n    <span class=\"p\">{<\/span>\n        <span class=\"nv\">$this<\/span><span class=\"o\">-&gt;<\/span><span class=\"nf\">startSpan<\/span><span class=\"p\">(<\/span><span class=\"nv\">$event<\/span><span class=\"p\">);<\/span>\n    <span class=\"p\">}<\/span>\n\n    <span class=\"k\">public<\/span> <span class=\"k\">function<\/span> <span class=\"n\">handleStreamingAgent<\/span><span class=\"p\">(<\/span><span class=\"kt\">StreamingAgent<\/span> <span class=\"nv\">$event<\/span><span class=\"p\">):<\/span> <span class=\"kt\">void<\/span>\n    <span class=\"p\">{<\/span>\n        <span class=\"nv\">$this<\/span><span class=\"o\">-&gt;<\/span><span class=\"nf\">startSpan<\/span><span class=\"p\">(<\/span><span class=\"nv\">$event<\/span><span class=\"p\">);<\/span>\n    <span class=\"p\">}<\/span>\n\n    <span class=\"k\">public<\/span> <span class=\"k\">function<\/span> <span class=\"n\">handleAgentPrompted<\/span><span class=\"p\">(<\/span><span class=\"kt\">AgentPrompted<\/span> <span class=\"nv\">$event<\/span><span class=\"p\">):<\/span> <span class=\"kt\">void<\/span>\n    <span class=\"p\">{<\/span>\n        <span class=\"nv\">$this<\/span><span class=\"o\">-&gt;<\/span><span class=\"nf\">finishSpan<\/span><span class=\"p\">(<\/span><span class=\"nv\">$event<\/span><span class=\"p\">);<\/span>\n    <span class=\"p\">}<\/span>\n\n    <span class=\"k\">public<\/span> <span class=\"k\">function<\/span> <span class=\"n\">handleAgentStreamed<\/span><span class=\"p\">(<\/span><span class=\"kt\">AgentStreamed<\/span> <span class=\"nv\">$event<\/span><span class=\"p\">):<\/span> <span class=\"kt\">void<\/span>\n    <span class=\"p\">{<\/span>\n        <span class=\"nv\">$this<\/span><span class=\"o\">-&gt;<\/span><span class=\"nf\">finishSpan<\/span><span class=\"p\">(<\/span><span class=\"nv\">$event<\/span><span class=\"p\">);<\/span>\n    <span class=\"p\">}<\/span>\n\n    <span class=\"k\">public<\/span> <span class=\"k\">function<\/span> <span class=\"n\">handleGeneratingEmbeddings<\/span><span class=\"p\">(<\/span><span class=\"kt\">GeneratingEmbeddings<\/span> <span class=\"nv\">$event<\/span><span class=\"p\">):<\/span> <span class=\"kt\">void<\/span>\n    <span class=\"p\">{<\/span>\n        <span class=\"nv\">$parentSpan<\/span> <span class=\"o\">=<\/span> <span class=\"nc\">SentrySdk<\/span><span class=\"o\">::<\/span><span class=\"nf\">getCurrentHub<\/span><span class=\"p\">()<\/span><span class=\"o\">-&gt;<\/span><span class=\"nf\">getSpan<\/span><span class=\"p\">();<\/span>\n\n        <span class=\"k\">if<\/span> <span class=\"p\">(<\/span><span class=\"nv\">$parentSpan<\/span> <span class=\"o\">===<\/span> <span class=\"kc\">null<\/span><span class=\"p\">)<\/span> <span class=\"p\">{<\/span>\n            <span class=\"k\">return<\/span><span class=\"p\">;<\/span>\n        <span class=\"p\">}<\/span>\n\n        <span class=\"nv\">$context<\/span> <span class=\"o\">=<\/span> <span class=\"nc\">SpanContext<\/span><span class=\"o\">::<\/span><span class=\"nf\">make<\/span><span class=\"p\">()<\/span>\n            <span class=\"o\">-&gt;<\/span><span class=\"nf\">setOp<\/span><span class=\"p\">(<\/span><span class=\"s1\">'gen_ai.embeddings'<\/span><span class=\"p\">)<\/span>\n            <span class=\"o\">-&gt;<\/span><span class=\"nf\">setDescription<\/span><span class=\"p\">(<\/span><span class=\"s1\">'embeddings '<\/span><span class=\"mf\">.<\/span><span class=\"nv\">$event<\/span><span class=\"o\">-&gt;<\/span><span class=\"n\">model<\/span><span class=\"p\">)<\/span>\n            <span class=\"o\">-&gt;<\/span><span class=\"nf\">setOrigin<\/span><span class=\"p\">(<\/span><span class=\"s1\">'auto.ai.laravel'<\/span><span class=\"p\">)<\/span>\n            <span class=\"o\">-&gt;<\/span><span class=\"nf\">setData<\/span><span class=\"p\">([<\/span>\n                <span class=\"s1\">'gen_ai.system'<\/span> <span class=\"o\">=&gt;<\/span> <span class=\"nb\">strtolower<\/span><span class=\"p\">(<\/span><span class=\"nv\">$event<\/span><span class=\"o\">-&gt;<\/span><span class=\"n\">provider<\/span><span class=\"o\">-&gt;<\/span><span class=\"nf\">name<\/span><span class=\"p\">()),<\/span>\n                <span class=\"s1\">'gen_ai.request.model'<\/span> <span class=\"o\">=&gt;<\/span> <span class=\"nv\">$event<\/span><span class=\"o\">-&gt;<\/span><span class=\"n\">model<\/span><span class=\"p\">,<\/span>\n                <span class=\"s1\">'gen_ai.operation.name'<\/span> <span class=\"o\">=&gt;<\/span> <span class=\"s1\">'embeddings'<\/span><span class=\"p\">,<\/span>\n            <span class=\"p\">]);<\/span>\n\n        <span class=\"nv\">$childSpan<\/span> <span class=\"o\">=<\/span> <span class=\"nv\">$parentSpan<\/span><span class=\"o\">-&gt;<\/span><span class=\"nf\">startChild<\/span><span class=\"p\">(<\/span><span class=\"nv\">$context<\/span><span class=\"p\">);<\/span>\n\n        <span class=\"nc\">SentrySdk<\/span><span class=\"o\">::<\/span><span class=\"nf\">getCurrentHub<\/span><span class=\"p\">()<\/span><span class=\"o\">-&gt;<\/span><span class=\"nf\">setSpan<\/span><span class=\"p\">(<\/span><span class=\"nv\">$childSpan<\/span><span class=\"p\">);<\/span>\n\n        <span class=\"k\">static<\/span><span class=\"o\">::<\/span><span class=\"nv\">$activeSpans<\/span><span class=\"p\">[<\/span><span class=\"nv\">$event<\/span><span class=\"o\">-&gt;<\/span><span class=\"n\">invocationId<\/span><span class=\"p\">]<\/span> <span class=\"o\">=<\/span> <span class=\"p\">[<\/span>\n            <span class=\"s1\">'span'<\/span> <span class=\"o\">=&gt;<\/span> <span class=\"nv\">$childSpan<\/span><span class=\"p\">,<\/span>\n            <span class=\"s1\">'parentSpan'<\/span> <span class=\"o\">=&gt;<\/span> <span class=\"nv\">$parentSpan<\/span><span class=\"p\">,<\/span>\n        <span class=\"p\">];<\/span>\n    <span class=\"p\">}<\/span>\n\n    <span class=\"k\">public<\/span> <span class=\"k\">function<\/span> <span class=\"n\">handleEmbeddingsGenerated<\/span><span class=\"p\">(<\/span><span class=\"kt\">EmbeddingsGenerated<\/span> <span class=\"nv\">$event<\/span><span class=\"p\">):<\/span> <span class=\"kt\">void<\/span>\n    <span class=\"p\">{<\/span>\n        <span class=\"nv\">$entry<\/span> <span class=\"o\">=<\/span> <span class=\"k\">static<\/span><span class=\"o\">::<\/span><span class=\"nv\">$activeSpans<\/span><span class=\"p\">[<\/span><span class=\"nv\">$event<\/span><span class=\"o\">-&gt;<\/span><span class=\"n\">invocationId<\/span><span class=\"p\">]<\/span> <span class=\"o\">??<\/span> <span class=\"kc\">null<\/span><span class=\"p\">;<\/span>\n\n        <span class=\"k\">if<\/span> <span class=\"p\">(<\/span><span class=\"nv\">$entry<\/span> <span class=\"o\">===<\/span> <span class=\"kc\">null<\/span><span class=\"p\">)<\/span> <span class=\"p\">{<\/span>\n            <span class=\"k\">return<\/span><span class=\"p\">;<\/span>\n        <span class=\"p\">}<\/span>\n\n        <span class=\"nb\">unset<\/span><span class=\"p\">(<\/span><span class=\"k\">static<\/span><span class=\"o\">::<\/span><span class=\"nv\">$activeSpans<\/span><span class=\"p\">[<\/span><span class=\"nv\">$event<\/span><span class=\"o\">-&gt;<\/span><span class=\"n\">invocationId<\/span><span class=\"p\">]);<\/span>\n\n        <span class=\"nv\">$span<\/span> <span class=\"o\">=<\/span> <span class=\"nv\">$entry<\/span><span class=\"p\">[<\/span><span class=\"s1\">'span'<\/span><span class=\"p\">];<\/span>\n\n        <span class=\"nv\">$span<\/span><span class=\"o\">-&gt;<\/span><span class=\"nf\">setData<\/span><span class=\"p\">(<\/span><span class=\"nb\">array_merge<\/span><span class=\"p\">(<\/span><span class=\"nv\">$span<\/span><span class=\"o\">-&gt;<\/span><span class=\"nf\">getData<\/span><span class=\"p\">(),<\/span> <span class=\"p\">[<\/span>\n            <span class=\"s1\">'gen_ai.response.model'<\/span> <span class=\"o\">=&gt;<\/span> <span class=\"nv\">$event<\/span><span class=\"o\">-&gt;<\/span><span class=\"n\">response<\/span><span class=\"o\">-&gt;<\/span><span class=\"n\">meta<\/span><span class=\"o\">-&gt;<\/span><span class=\"n\">model<\/span><span class=\"p\">,<\/span>\n            <span class=\"s1\">'gen_ai.usage.input_tokens'<\/span> <span class=\"o\">=&gt;<\/span> <span class=\"nv\">$event<\/span><span class=\"o\">-&gt;<\/span><span class=\"n\">response<\/span><span class=\"o\">-&gt;<\/span><span class=\"n\">tokens<\/span><span class=\"p\">,<\/span>\n        <span class=\"p\">]));<\/span>\n\n        <span class=\"nv\">$span<\/span><span class=\"o\">-&gt;<\/span><span class=\"nf\">setStatus<\/span><span class=\"p\">(<\/span><span class=\"nc\">SpanStatus<\/span><span class=\"o\">::<\/span><span class=\"nf\">ok<\/span><span class=\"p\">());<\/span>\n        <span class=\"nv\">$span<\/span><span class=\"o\">-&gt;<\/span><span class=\"nf\">finish<\/span><span class=\"p\">();<\/span>\n\n        <span class=\"nc\">SentrySdk<\/span><span class=\"o\">::<\/span><span class=\"nf\">getCurrentHub<\/span><span class=\"p\">()<\/span><span class=\"o\">-&gt;<\/span><span class=\"nf\">setSpan<\/span><span class=\"p\">(<\/span><span class=\"nv\">$entry<\/span><span class=\"p\">[<\/span><span class=\"s1\">'parentSpan'<\/span><span class=\"p\">]);<\/span>\n    <span class=\"p\">}<\/span>\n\n    <span class=\"k\">protected<\/span> <span class=\"k\">function<\/span> <span class=\"n\">startSpan<\/span><span class=\"p\">(<\/span><span class=\"kt\">PromptingAgent<\/span> <span class=\"nv\">$event<\/span><span class=\"p\">):<\/span> <span class=\"kt\">void<\/span>\n    <span class=\"p\">{<\/span>\n        <span class=\"nv\">$parentSpan<\/span> <span class=\"o\">=<\/span> <span class=\"nc\">SentrySdk<\/span><span class=\"o\">::<\/span><span class=\"nf\">getCurrentHub<\/span><span class=\"p\">()<\/span><span class=\"o\">-&gt;<\/span><span class=\"nf\">getSpan<\/span><span class=\"p\">();<\/span>\n\n        <span class=\"k\">if<\/span> <span class=\"p\">(<\/span><span class=\"nv\">$parentSpan<\/span> <span class=\"o\">===<\/span> <span class=\"kc\">null<\/span><span class=\"p\">)<\/span> <span class=\"p\">{<\/span>\n            <span class=\"k\">return<\/span><span class=\"p\">;<\/span>\n        <span class=\"p\">}<\/span>\n\n        <span class=\"nv\">$providerName<\/span> <span class=\"o\">=<\/span> <span class=\"nv\">$event<\/span><span class=\"o\">-&gt;<\/span><span class=\"n\">prompt<\/span><span class=\"o\">-&gt;<\/span><span class=\"n\">provider<\/span> <span class=\"k\">instanceof<\/span> <span class=\"nc\">Provider<\/span>\n            <span class=\"o\">?<\/span> <span class=\"nb\">strtolower<\/span><span class=\"p\">(<\/span><span class=\"nv\">$event<\/span><span class=\"o\">-&gt;<\/span><span class=\"n\">prompt<\/span><span class=\"o\">-&gt;<\/span><span class=\"n\">provider<\/span><span class=\"o\">-&gt;<\/span><span class=\"nf\">name<\/span><span class=\"p\">())<\/span>\n            <span class=\"o\">:<\/span> <span class=\"s1\">'unknown'<\/span><span class=\"p\">;<\/span>\n\n        <span class=\"nv\">$agentName<\/span> <span class=\"o\">=<\/span> <span class=\"nf\">class_basename<\/span><span class=\"p\">(<\/span><span class=\"nv\">$event<\/span><span class=\"o\">-&gt;<\/span><span class=\"n\">prompt<\/span><span class=\"o\">-&gt;<\/span><span class=\"n\">agent<\/span><span class=\"p\">);<\/span>\n\n        <span class=\"nv\">$context<\/span> <span class=\"o\">=<\/span> <span class=\"nc\">SpanContext<\/span><span class=\"o\">::<\/span><span class=\"nf\">make<\/span><span class=\"p\">()<\/span>\n            <span class=\"o\">-&gt;<\/span><span class=\"nf\">setOp<\/span><span class=\"p\">(<\/span><span class=\"s1\">'gen_ai.chat'<\/span><span class=\"p\">)<\/span>\n            <span class=\"o\">-&gt;<\/span><span class=\"nf\">setDescription<\/span><span class=\"p\">(<\/span><span class=\"s1\">'chat '<\/span><span class=\"mf\">.<\/span><span class=\"nv\">$event<\/span><span class=\"o\">-&gt;<\/span><span class=\"n\">prompt<\/span><span class=\"o\">-&gt;<\/span><span class=\"n\">model<\/span><span class=\"p\">)<\/span>\n            <span class=\"o\">-&gt;<\/span><span class=\"nf\">setOrigin<\/span><span class=\"p\">(<\/span><span class=\"s1\">'auto.ai.laravel'<\/span><span class=\"p\">)<\/span>\n            <span class=\"o\">-&gt;<\/span><span class=\"nf\">setData<\/span><span class=\"p\">([<\/span>\n                <span class=\"s1\">'gen_ai.system'<\/span> <span class=\"o\">=&gt;<\/span> <span class=\"nv\">$providerName<\/span><span class=\"p\">,<\/span>\n                <span class=\"s1\">'gen_ai.request.model'<\/span> <span class=\"o\">=&gt;<\/span> <span class=\"nv\">$event<\/span><span class=\"o\">-&gt;<\/span><span class=\"n\">prompt<\/span><span class=\"o\">-&gt;<\/span><span class=\"n\">model<\/span><span class=\"p\">,<\/span>\n                <span class=\"s1\">'gen_ai.operation.name'<\/span> <span class=\"o\">=&gt;<\/span> <span class=\"s1\">'chat'<\/span><span class=\"p\">,<\/span>\n                <span class=\"s1\">'gen_ai.agent.name'<\/span> <span class=\"o\">=&gt;<\/span> <span class=\"nv\">$agentName<\/span><span class=\"p\">,<\/span>\n                <span class=\"s1\">'gen_ai.pipeline.name'<\/span> <span class=\"o\">=&gt;<\/span> <span class=\"nv\">$agentName<\/span><span class=\"p\">,<\/span>\n            <span class=\"p\">]);<\/span>\n\n        <span class=\"nv\">$childSpan<\/span> <span class=\"o\">=<\/span> <span class=\"nv\">$parentSpan<\/span><span class=\"o\">-&gt;<\/span><span class=\"nf\">startChild<\/span><span class=\"p\">(<\/span><span class=\"nv\">$context<\/span><span class=\"p\">);<\/span>\n\n        <span class=\"nc\">SentrySdk<\/span><span class=\"o\">::<\/span><span class=\"nf\">getCurrentHub<\/span><span class=\"p\">()<\/span><span class=\"o\">-&gt;<\/span><span class=\"nf\">setSpan<\/span><span class=\"p\">(<\/span><span class=\"nv\">$childSpan<\/span><span class=\"p\">);<\/span>\n\n        <span class=\"k\">static<\/span><span class=\"o\">::<\/span><span class=\"nv\">$activeSpans<\/span><span class=\"p\">[<\/span><span class=\"nv\">$event<\/span><span class=\"o\">-&gt;<\/span><span class=\"n\">invocationId<\/span><span class=\"p\">]<\/span> <span class=\"o\">=<\/span> <span class=\"p\">[<\/span>\n            <span class=\"s1\">'span'<\/span> <span class=\"o\">=&gt;<\/span> <span class=\"nv\">$childSpan<\/span><span class=\"p\">,<\/span>\n            <span class=\"s1\">'parentSpan'<\/span> <span class=\"o\">=&gt;<\/span> <span class=\"nv\">$parentSpan<\/span><span class=\"p\">,<\/span>\n        <span class=\"p\">];<\/span>\n    <span class=\"p\">}<\/span>\n\n    <span class=\"k\">protected<\/span> <span class=\"k\">function<\/span> <span class=\"n\">finishSpan<\/span><span class=\"p\">(<\/span><span class=\"kt\">AgentPrompted<\/span> <span class=\"nv\">$event<\/span><span class=\"p\">):<\/span> <span class=\"kt\">void<\/span>\n    <span class=\"p\">{<\/span>\n        <span class=\"nv\">$entry<\/span> <span class=\"o\">=<\/span> <span class=\"k\">static<\/span><span class=\"o\">::<\/span><span class=\"nv\">$activeSpans<\/span><span class=\"p\">[<\/span><span class=\"nv\">$event<\/span><span class=\"o\">-&gt;<\/span><span class=\"n\">invocationId<\/span><span class=\"p\">]<\/span> <span class=\"o\">??<\/span> <span class=\"kc\">null<\/span><span class=\"p\">;<\/span>\n\n        <span class=\"k\">if<\/span> <span class=\"p\">(<\/span><span class=\"nv\">$entry<\/span> <span class=\"o\">===<\/span> <span class=\"kc\">null<\/span><span class=\"p\">)<\/span> <span class=\"p\">{<\/span>\n            <span class=\"k\">return<\/span><span class=\"p\">;<\/span>\n        <span class=\"p\">}<\/span>\n\n        <span class=\"nb\">unset<\/span><span class=\"p\">(<\/span><span class=\"k\">static<\/span><span class=\"o\">::<\/span><span class=\"nv\">$activeSpans<\/span><span class=\"p\">[<\/span><span class=\"nv\">$event<\/span><span class=\"o\">-&gt;<\/span><span class=\"n\">invocationId<\/span><span class=\"p\">]);<\/span>\n\n        <span class=\"nv\">$span<\/span> <span class=\"o\">=<\/span> <span class=\"nv\">$entry<\/span><span class=\"p\">[<\/span><span class=\"s1\">'span'<\/span><span class=\"p\">];<\/span>\n        <span class=\"nv\">$usage<\/span> <span class=\"o\">=<\/span> <span class=\"nv\">$event<\/span><span class=\"o\">-&gt;<\/span><span class=\"n\">response<\/span><span class=\"o\">-&gt;<\/span><span class=\"n\">usage<\/span><span class=\"p\">;<\/span>\n        <span class=\"nv\">$meta<\/span> <span class=\"o\">=<\/span> <span class=\"nv\">$event<\/span><span class=\"o\">-&gt;<\/span><span class=\"n\">response<\/span><span class=\"o\">-&gt;<\/span><span class=\"n\">meta<\/span><span class=\"p\">;<\/span>\n\n        <span class=\"nv\">$span<\/span><span class=\"o\">-&gt;<\/span><span class=\"nf\">setData<\/span><span class=\"p\">(<\/span><span class=\"nb\">array_merge<\/span><span class=\"p\">(<\/span><span class=\"nv\">$span<\/span><span class=\"o\">-&gt;<\/span><span class=\"nf\">getData<\/span><span class=\"p\">(),<\/span> <span class=\"p\">[<\/span>\n            <span class=\"s1\">'gen_ai.response.model'<\/span> <span class=\"o\">=&gt;<\/span> <span class=\"nv\">$meta<\/span><span class=\"o\">-&gt;<\/span><span class=\"n\">model<\/span><span class=\"p\">,<\/span>\n            <span class=\"s1\">'gen_ai.usage.input_tokens'<\/span> <span class=\"o\">=&gt;<\/span> <span class=\"nv\">$usage<\/span><span class=\"o\">-&gt;<\/span><span class=\"n\">promptTokens<\/span><span class=\"p\">,<\/span>\n            <span class=\"s1\">'gen_ai.usage.output_tokens'<\/span> <span class=\"o\">=&gt;<\/span> <span class=\"nv\">$usage<\/span><span class=\"o\">-&gt;<\/span><span class=\"n\">completionTokens<\/span><span class=\"p\">,<\/span>\n        <span class=\"p\">]));<\/span>\n\n        <span class=\"nv\">$span<\/span><span class=\"o\">-&gt;<\/span><span class=\"nf\">setStatus<\/span><span class=\"p\">(<\/span><span class=\"nc\">SpanStatus<\/span><span class=\"o\">::<\/span><span class=\"nf\">ok<\/span><span class=\"p\">());<\/span>\n        <span class=\"nv\">$span<\/span><span class=\"o\">-&gt;<\/span><span class=\"nf\">finish<\/span><span class=\"p\">();<\/span>\n\n        <span class=\"nc\">SentrySdk<\/span><span class=\"o\">::<\/span><span class=\"nf\">getCurrentHub<\/span><span class=\"p\">()<\/span><span class=\"o\">-&gt;<\/span><span class=\"nf\">setSpan<\/span><span class=\"p\">(<\/span><span class=\"nv\">$entry<\/span><span class=\"p\">[<\/span><span class=\"s1\">'parentSpan'<\/span><span class=\"p\">]);<\/span>\n    <span class=\"p\">}<\/span>\n\n    <span class=\"cd\">\/**\n     * Reset active spans (used in testing).\n     *\/<\/span>\n    <span class=\"k\">public<\/span> <span class=\"k\">static<\/span> <span class=\"k\">function<\/span> <span class=\"n\">flush<\/span><span class=\"p\">():<\/span> <span class=\"kt\">void<\/span>\n    <span class=\"p\">{<\/span>\n        <span class=\"k\">static<\/span><span class=\"o\">::<\/span><span class=\"nv\">$activeSpans<\/span> <span class=\"o\">=<\/span> <span class=\"p\">[];<\/span>\n    <span class=\"p\">}<\/span>\n<span class=\"p\">}<\/span>\n<\/code><\/pre><\/div><\/div>\n\n<p>Laravel auto-discovers listeners by convention. The <code class=\"language-plaintext highlighter-rouge\">handlePromptingAgent<\/code> method maps to the <code class=\"language-plaintext highlighter-rouge\">PromptingAgent<\/code> event, and so on for the rest. Drop this class into <code class=\"language-plaintext highlighter-rouge\">app\/Listeners<\/code> and every agent and embedding call gets a Sentry span with the same <code class=\"language-plaintext highlighter-rouge\">gen_ai.*<\/code> attributes from before.<\/p>\n\n<h2 id=\"whats-different-from-the-manual-approach\">What\u2019s different from the manual approach<\/h2>\n\n<p>The old wrapper only traced calls that explicitly used it. Forget to wrap a call and you get no span. The listener catches everything that goes through the SDK, regardless of which agent or provider made the call.<\/p>\n\n<p>The SDK abstracts providers behind a common interface. The listener pulls the provider name from <code class=\"language-plaintext highlighter-rouge\">$event-&gt;prompt-&gt;provider-&gt;name()<\/code>, so switching from OpenAI to Anthropic doesn\u2019t need any tracing changes.<\/p>\n\n<p>Streaming was awkward before because token counts arrive at the end of the stream. The SDK handles that internally and fires <code class=\"language-plaintext highlighter-rouge\">AgentStreamed<\/code> with the full usage data already collected. The listener code is identical for both paths.<\/p>\n\n<p>Embeddings follow the same pattern but with different span data. Chat completions track both input and output tokens. Embeddings only have input tokens since there\u2019s no generated text, just vectors.<\/p>\n\n<p>Each event gets a unique <code class=\"language-plaintext highlighter-rouge\">invocationId<\/code>. The listener uses this to match start and finish events, stored in a static <code class=\"language-plaintext highlighter-rouge\">$activeSpans<\/code> array. This handles concurrent requests without race conditions.<\/p>\n\n<h2 id=\"the-same-patterns-still-apply\">The same patterns still apply<\/h2>\n\n<p>The graceful degradation check from the original post still applies. If there\u2019s no active Sentry transaction (queue workers, artisan commands), <code class=\"language-plaintext highlighter-rouge\">getSpan()<\/code> returns null and the listener exits early without errors.<\/p>\n\n<p>Parent span restoration is the same. After finishing the AI span, we reset the hub to the parent span so later operations attach to the right place in the trace hierarchy.<\/p>\n\n<p>The <code class=\"language-plaintext highlighter-rouge\">gen_ai.*<\/code> attribute conventions are unchanged. Sentry\u2019s LLM monitoring UI still picks up token counts, model versions, and provider information the same way.<\/p>\n\n<h2 id=\"beyond-sentry\">Beyond Sentry<\/h2>\n\n<p>The SDK fires events that any listener can hook into. You could add listeners for logging, cost tracking, or rate limiting alongside the tracing listener.<\/p>\n\n<p>The previous post required you to build the plumbing and remember to use it. Now the framework gives you the hooks.<\/p>\n","pubDate":"Sat, 14 Feb 2026 08:00:00 +0000","link":"https:\/\/joshhornby.com\/sentry-ai-tracing-laravel-ai-sdk","guid":"https:\/\/joshhornby.com\/sentry-ai-tracing-laravel-ai-sdk"},{"title":"When to Say No","description":"<blockquote>\n  <p>This post is part of my <a href=\"\/tags\/tech-lead\">Tech Lead Series<\/a>, a collection of practical advice for engineers stepping into leadership roles.<\/p>\n<\/blockquote>\n\n<p>Learning to say no is one of the hardest parts of becoming a tech lead. It feels like failing people. It takes time to understand that thoughtful no\u2019s are how you protect your team\u2019s ability to deliver.<\/p>\n\n<p>The instinct is to say yes. Yes to the PM\u2019s feature request, yes to the stakeholder\u2019s timeline, yes to the \u201cquick addition\u201d that\u2019s never quick. It feels like being helpful. But every yes is a commitment of your team\u2019s finite time and energy. Say yes to everything and you deliver nothing well.<\/p>\n\n<h2 id=\"why-saying-no-is-hard\">Why saying no is hard<\/h2>\n\n<p>Saying yes feels good. You\u2019re being helpful. You\u2019re being a team player. You\u2019re not the person blocking progress. The immediate social reward is powerful.<\/p>\n\n<p>Saying no feels bad. You\u2019re disappointing someone. You might be wrong. You might miss an opportunity. The immediate social cost is real.<\/p>\n\n<p>But the calculus changes when you think about consequences. Every yes is a commitment of your team\u2019s time and energy. Time spent on one thing is time not spent on another. When you say yes to everything, you\u2019re implicitly saying no to quality, to sustainability, to the things that matter most.<\/p>\n\n<p>The job of a tech lead is to protect the team\u2019s capacity to do good work. That requires saying no often.<\/p>\n\n<h2 id=\"when-to-say-no\">When to say no<\/h2>\n\n<p>Not every request deserves no. The skill is knowing when to push back.<\/p>\n\n<p>When the value doesn\u2019t justify the cost. Some features take three weeks to build and help 1% of users. Some \u201cquick changes\u201d require touching six systems. Before saying yes, understand what something actually costs. If the math doesn\u2019t work, say so.<\/p>\n\n<p>When quality will suffer. Rushing to meet an arbitrary deadline by cutting corners creates debt that slows future work. Sometimes the right answer is adjusting scope or timeline, not accepting lower quality.<\/p>\n\n<p>When the team is overloaded. You see burnout signals before anyone else: late nights, dropped balls, declining morale. Protect your team from well-meaning people who don\u2019t understand the cost of their requests.<\/p>\n\n<p>When there\u2019s a better solution. Often the first solution proposed isn\u2019t the best one. If you can solve the same problem more simply, push back on the original approach even if it\u2019s already been promised to someone.<\/p>\n\n<p>When it conflicts with priorities. Every company has more ideas than capacity. When a new request conflicts with agreed priorities, the answer is usually no unless those priorities change.<\/p>\n\n<p>When your gut says no. Sometimes you can\u2019t explain exactly why something feels wrong. Trust that instinct, at least enough to slow down and understand it. Your experience is spotting patterns in situations that haven\u2019t fully shown themselves.<\/p>\n\n<h2 id=\"how-to-say-no\">How to say no<\/h2>\n\n<p>The word \u201cno\u201d rarely needs to be spoken. Good pushback sounds like problem-solving, not rejection.<\/p>\n\n<p>\u201cHelp me understand.\u201d Start by understanding the request fully. Why does this matter? What problem does it solve? Who needs it and when? Sometimes requests dissolve under examination. Sometimes you learn something that changes your view.<\/p>\n\n<p>\u201cHere\u2019s what I\u2019m seeing.\u201d Share your view. The cost of the request, the impact on other work, the risks you\u2019re worried about. Give people the information to make an informed decision.<\/p>\n\n<p>\u201cHere are our options.\u201d Rarely is it yes or no. Usually there\u2019s a middle path: a smaller version, a later timeline, a different approach. Present alternatives that address the underlying need.<\/p>\n\n<p>\u201cNot now, but maybe later.\u201d Some requests aren\u2019t wrong, just poorly timed. Deferring isn\u2019t the same as rejecting. Add it to the backlog, set a time to revisit, make clear you\u2019re not dismissing it.<\/p>\n\n<p>\u201cYes, if.\u201d Sometimes yes depends on something else. \u201cYes, if we can move the deadline. Yes, if we cut the scope on this other feature. Yes, if we get another engineer.\u201d Conditional yes clarifies trade-offs.<\/p>\n\n<h2 id=\"saying-no-to-different-people\">Saying no to different people<\/h2>\n\n<p>The approach varies depending on who\u2019s asking.<\/p>\n\n<p>Product managers need to understand trade-offs, not just hear no. Explain the cost. Offer alternatives. Be a partner in finding the best solution. If you just block things without explanation, the relationship gets worse.<\/p>\n\n<p>Stakeholders outside your immediate team have less context. They need education more than negotiation. Explain how prioritisation works, what capacity looks like, why their request might not fit right now.<\/p>\n\n<p>Your own manager is delicate. You can\u2019t just say no to your boss. But you can share your view, flag concerns, and ask clarifying questions. \u201cI\u2019m happy to do this, but it means X won\u2019t happen. Is that the right trade-off?\u201d<\/p>\n\n<p>Your own team sometimes proposes approaches you disagree with. The dynamic is different here. You have more authority but also more responsibility to explain your reasoning. Don\u2019t just veto; teach.<\/p>\n\n<h2 id=\"the-no-that-isnt-no\">The no that isn\u2019t no<\/h2>\n\n<p>Sometimes the answer is technically yes but really no. Be careful with these.<\/p>\n\n<p>\u201cSure, but it\u2019ll take six months.\u201d If you know something won\u2019t get prioritised with that timeline, you\u2019re not really saying yes. Be honest about whether something is actually going to happen.<\/p>\n\n<p>\u201cLet me check and get back to you.\u201d A useful delay when you need time to think. A harmful one when you\u2019re just avoiding the conversation. Don\u2019t use a process as a shield.<\/p>\n\n<p>\u201cWe\u2019d need to talk to someone else.\u201d Sometimes this is fair. Sometimes it\u2019s passing the buck. Own the decision if it\u2019s yours to own.<\/p>\n\n<h2 id=\"building-the-reputation\">Building the reputation<\/h2>\n\n<p>Saying no well requires credibility. People need to believe you\u2019re saying no for good reasons, not because you\u2019re blocking or lazy.<\/p>\n\n<p>Say yes when you should. If you say no to everything, people stop listening. Show that you\u2019re willing to take on work, stretch when necessary, be flexible. Your no\u2019s carry more weight when they\u2019re not your default.<\/p>\n\n<p>Explain your reasoning. People can disagree with a no, but they should understand it. Hidden reasoning breeds resentment.<\/p>\n\n<p>Follow through on your yes\u2019s. When you commit to something, deliver. Reliable execution earns you the right to push back on new requests.<\/p>\n\n<p>Be consistent. If you say no to one person\u2019s feature and yes to another\u2019s without clear reasoning, you\u2019ll be seen as political rather than principled.<\/p>\n\n<h2 id=\"the-hard-truth\">The hard truth<\/h2>\n\n<p>You will sometimes say no and be wrong. You\u2019ll block something that would have worked, delay something that mattered, frustrate someone who had a good idea. This is unavoidable.<\/p>\n\n<p>The alternative is worse. Saying yes to everything guarantees failure. Not obvious, dramatic failure, but the slow failure of burned-out teams and missed commitments and declining quality.<\/p>\n\n<p>Your job isn\u2019t to be right every time. It\u2019s to make thoughtful decisions about where your team\u2019s finite time and energy should go. That means saying no often, even when it\u2019s uncomfortable.<\/p>\n","pubDate":"Fri, 13 Feb 2026 08:00:00 +0000","link":"https:\/\/joshhornby.com\/when-to-say-no","guid":"https:\/\/joshhornby.com\/when-to-say-no"},{"title":"Managing Up","description":"<blockquote>\n  <p>This post is part of my <a href=\"\/tags\/tech-lead\">Tech Lead Series<\/a>, a collection of practical advice for engineers stepping into leadership roles.<\/p>\n<\/blockquote>\n\n<p>My first serious mistake as a tech lead came from not managing up. The team had been struggling with a legacy system for months. I mentioned it in passing to my engineering manager, assuming she knew how bad it was. She didn\u2019t. When the system finally caused a major incident, she was blindsided. Her question afterward stung: \u201cWhy didn\u2019t you tell me this was a problem?\u201d<\/p>\n\n<p>I had told her. Once, briefly, buried in a longer conversation. I\u2019d assumed that was enough. It wasn\u2019t.<\/p>\n\n<p>Managing up isn\u2019t about politics or self-promotion. It\u2019s about giving your manager the information they need to make good decisions and support your team effectively.<\/p>\n\n<h2 id=\"what-your-manager-needs\">What your manager needs<\/h2>\n\n<p>Your engineering manager has visibility problems you don\u2019t think about. They\u2019re responsible for multiple teams, attend meetings you never see, and make trade-offs across contexts you don\u2019t have. They depend on you for ground truth.<\/p>\n\n<p>Risks and blockers. What could go wrong? What\u2019s slowing the team down? What\u2019s the thing that keeps you up at night? Your manager needs to know these things before they become crises.<\/p>\n\n<p>Team health. How are people doing? Is anyone struggling, burning out, or thinking about leaving? Your manager can\u2019t help with problems they don\u2019t know exist. This is information you gather in <a href=\"\/running-effective-one-to-ones\">your 1:1s<\/a>.<\/p>\n\n<p>Progress and context. Not just what\u2019s done, but what it means. A shipped feature is more useful information when paired with customer reaction or remaining work.<\/p>\n\n<p>Your honest assessment. When asked how things are going, \u201cfine\u201d is not helpful. What\u2019s actually happening? Where are the gaps between reality and how things look?<\/p>\n\n<h2 id=\"how-to-communicate\">How to communicate<\/h2>\n\n<p>The failure mode isn\u2019t usually silence. It\u2019s the wrong type of communication at the wrong time.<\/p>\n\n<p>Regular updates beat irregular ones. A weekly written update, even just bullet points, builds trust better than occasional long conversations. Your manager knows what\u2019s happening without having to ask. I use <a href=\"\/weekly-updates\">four questions I answer every week<\/a> to structure this.<\/p>\n\n<p>Lead with the important stuff. Don\u2019t bury risks in good news. If there\u2019s a problem, say it first. \u201cWe\u2019re on track for launch, but I\u2019m worried about the payment integration\u201d is better than three paragraphs about progress followed by a buried concern.<\/p>\n\n<p>Quantify when you can. \u201cThe team is stressed\u201d is vague. \u201cThree people have worked weekends for the last month and one mentioned looking at other jobs\u201d is specific and something they can act on.<\/p>\n\n<p>Provide options, not just problems. When raising an issue, come with thoughts on how to address it. \u201cWe have a problem\u201d puts the burden on your manager. \u201cWe have a problem, and here are three ways we could handle it\u201d starts a useful conversation.<\/p>\n\n<p>Know what they\u2019ll get asked. Your manager gets questioned by their manager, by stakeholders, by other teams. Think about what questions they might face and give them the answers. That\u2019s not manipulation, just good preparation.<\/p>\n\n<h2 id=\"advocating-for-your-team\">Advocating for your team<\/h2>\n\n<p>Part of managing up is making sure your team gets what they need. Resources, recognition, protection from unreasonable demands. Your manager can provide these things, but only if you make the case.<\/p>\n\n<p>Be specific about needs. \u201cWe need more people\u201d is a weak argument. \u201cWe need a senior backend engineer because the team has no one who can work on the payment system, and that\u2019s blocking three features on the roadmap\u201d is a case your manager can take to their peers.<\/p>\n\n<p>Connect to business outcomes. Leadership cares about business results. Frame your asks in terms of what the business gets. Not \u201cwe need to pay down technical debt\u201d but \u201cthis technical debt is causing two hours of incident response per week and will cause an outage within six months.\u201d<\/p>\n\n<p>Pick your battles. You can\u2019t fight for everything. Decide what matters most and focus your energy there. Constant requests wear down your influence.<\/p>\n\n<p>Acknowledge constraints. Your manager operates within limits you don\u2019t always see. Budget, headcount, political realities. Acknowledge these when making requests. It shows you understand their position and makes you easier to work with.<\/p>\n\n<h2 id=\"influencing-without-authority\">Influencing without authority<\/h2>\n\n<p>Tech leads often need to influence decisions they don\u2019t control. Architectural standards, team processes, resource allocation. You can\u2019t mandate these things, but you can shape them.<\/p>\n\n<p>Build credibility first. Your influence comes from your track record. Deliver consistently, show good judgment, follow through on commitments. People listen to people they trust. This is especially true in remote companies where you need to <a href=\"\/getting-in-the-room-at-remote-company\">get in the room<\/a> through consistent, visible contribution.<\/p>\n\n<p>Understand the decision-maker. What does your manager care about? What pressures are they under? What would make them look good to their manager? Frame your proposals in terms of their priorities.<\/p>\n\n<p>Make it easy to say yes. Do the work upfront. Don\u2019t bring a vague idea; bring a proposal with options and trade-offs already thought through. The easier you make the decision, the more likely you get what you want.<\/p>\n\n<p>Accept no gracefully. Sometimes the answer is no. Accept it without resentment, understand why, and move on. How you handle no affects whether you get yes next time.<\/p>\n\n<h2 id=\"common-mistakes\">Common mistakes<\/h2>\n\n<p>Assuming they know. If you haven\u2019t explicitly told your manager something, assume they don\u2019t know. They have too much going on to guess problems from hints.<\/p>\n\n<p>Waiting too long. Bad news doesn\u2019t improve with age. Tell your manager about problems early, when they\u2019re still manageable. Surprising them with a crisis destroys trust.<\/p>\n\n<p>Only communicating when you need something. If your manager only hears from you when there\u2019s a problem or a request, the relationship becomes transactional. Share wins, interesting things you noticed, things you\u2019ve learned.<\/p>\n\n<p>Complaining without solutions. Anyone can spot problems. What separates good tech leads is coming with options. Even if you don\u2019t know the answer, show that you\u2019ve thought about it.<\/p>\n\n<p>Going around your manager. If you disagree with a decision, escalating around your manager is almost never the right move. It damages trust for good. If you must escalate, tell them first.<\/p>\n\n<h2 id=\"the-underlying-principle\">The underlying principle<\/h2>\n\n<p>Managing up isn\u2019t a separate activity from your job. It\u2019s how you make your job possible. Your manager controls resources, shields you from nonsense, and advocates for your team in rooms you\u2019re not in. They can only do these things if you give them what they need.<\/p>\n\n<p>Think of it as a partnership. You bring ground-level reality. They bring organisational context. Together, you can navigate problems neither could handle alone.<\/p>\n","pubDate":"Tue, 10 Feb 2026 08:00:00 +0000","link":"https:\/\/joshhornby.com\/managing-up-as-tech-lead","guid":"https:\/\/joshhornby.com\/managing-up-as-tech-lead"},{"title":"Working with Designers","description":"<blockquote>\n  <p>This post is part of my <a href=\"\/tags\/tech-lead\">Tech Lead Series<\/a>, a collection of practical advice for engineers stepping into leadership roles.<\/p>\n<\/blockquote>\n\n<p>A designer once handed me a Figma file with a custom animation for a loading spinner. It was beautiful. It was also going to take a week to implement, on a feature that needed to ship in three days. When I asked if we could use a simpler animation, she was visibly frustrated. \u201cWhy do I bother making designs if engineering just ignores them?\u201d<\/p>\n\n<p>She wasn\u2019t wrong to be frustrated. I wasn\u2019t wrong about the constraints. We were both failing at the same thing: collaborating early enough that this conversation never needed to happen.<\/p>\n\n<h2 id=\"the-core-tension\">The core tension<\/h2>\n\n<p>Engineering and design optimise for different things. Design optimises for user experience: how it looks, how it feels, whether it delights. Engineering optimises for what we can build: can we do this reliably, keep it working, within our constraints?<\/p>\n\n<p>Both matter. Neither is more important. But they pull in different directions.<\/p>\n\n<p>This tension isn\u2019t a problem to solve. It\u2019s a dynamic to manage. The best products come from holding both perspectives in balance. That requires a relationship where you can have honest conversations about trade-offs without it feeling like a battle.<\/p>\n\n<h2 id=\"the-product-trio\">The product trio<\/h2>\n\n<p>Marty Cagan popularised the idea of the Product Trio: product manager, designer, and tech lead working together as equal partners. Each owns a different dimension of the problem.<\/p>\n\n<p>The PM owns what problem to solve and why it matters. The designer owns how the solution works for the user. The tech lead owns how it gets built. All three perspectives are needed to build something valuable, usable, and possible.<\/p>\n\n<p>The key insight is shared ownership rather than hand-offs. Strong teams make decisions together while exploring the problem, not one after another during the build. When one role dominates, teams build the wrong thing, build the right thing poorly, or fail to ship at all.<\/p>\n\n<p>I covered <a href=\"\/working-with-product-managers\">working with product managers<\/a> earlier in this series. The same principles apply here: early involvement, shaping each other\u2019s work, productive disagreement. The trio works when all three roles respect what the others bring.<\/p>\n\n<h2 id=\"collaborate-early\">Collaborate early<\/h2>\n\n<p>The spinner story happened because I wasn\u2019t involved until the design was finished. By then, the designer had invested time and creative energy. Asking for changes felt like rejection.<\/p>\n\n<p>Early involvement prevents this. When you\u2019re in the conversation from the start, constraints are shared information, not late surprises. The design evolves with what\u2019s possible in mind.<\/p>\n\n<p>What early involvement looks like:<\/p>\n\n<h3 id=\"join-the-problem-definition\">Join the problem definition<\/h3>\n\n<p>Before wireframes exist, there\u2019s a problem being explored. Be in that conversation. Your technical knowledge shapes what solutions are even possible.<\/p>\n\n<h3 id=\"share-constraints-upfront\">Share constraints upfront<\/h3>\n\n<p>If the team is using a design system with limited components, say so. If there\u2019s a performance budget that rules out certain approaches, mention it. Let the designer work with real constraints rather than discovering them later.<\/p>\n\n<h3 id=\"review-rough-ideas-not-polished-designs\">Review rough ideas, not polished designs<\/h3>\n\n<p>Looking at early sketches is more useful than seeing final mockups. Changes are easier and feel less personal when the design is still rough.<\/p>\n\n<h3 id=\"pair-on-complex-interactions\">Pair on complex interactions<\/h3>\n\n<p>For anything involving animation, state transitions, or complex data, sit together and work through it. The designer understands what they want to achieve; you understand what\u2019s achievable. Meet in the middle.<\/p>\n\n<h2 id=\"having-the-pushback-conversation\">Having the pushback conversation<\/h2>\n\n<p>Sometimes you need to say no, or at least \u201cnot like this.\u201d These conversations go badly when they feel like engineering vetoing design. They go well when they feel like problem-solving together.<\/p>\n\n<h3 id=\"start-with-understanding\">Start with understanding<\/h3>\n\n<p>Before explaining why something is hard, understand why it was designed that way. \u201cHelp me understand what this animation is trying to achieve\u201d opens a conversation. \u201cWe can\u2019t do this animation\u201d closes it.<\/p>\n\n<h3 id=\"explain-the-cost-not-just-the-answer\">Explain the cost, not just the answer<\/h3>\n\n<p>\u201cThis will take two weeks\u201d is more useful than \u201cwe can\u2019t do this.\u201d It lets the designer weigh the trade-off themselves. Sometimes they\u2019ll decide it\u2019s worth it. Sometimes they\u2019ll find another way.<\/p>\n\n<h3 id=\"offer-alternatives\">Offer alternatives<\/h3>\n\n<p>Don\u2019t just spot problems; bring solutions. \u201cWe can\u2019t do this custom animation, but here\u2019s what we can do with our existing library\u201d gives the designer something to work with.<\/p>\n\n<h3 id=\"pick-your-battles\">Pick your battles<\/h3>\n\n<p>Not every gap from the spec matters equally. A button that\u2019s slightly smaller than the mockup probably isn\u2019t worth fighting about. An interaction that breaks accessibility is. Focus your pushback on things that actually matter.<\/p>\n\n<h3 id=\"never-say-just-when-it-isnt\">Never say \u201cjust\u201d when it isn\u2019t<\/h3>\n\n<p>Dismissing design decisions as unimportant (\u201cit\u2019s just a colour\u201d) damages trust. If you\u2019re pushing back, do it respectfully. If you don\u2019t understand why something matters, ask.<\/p>\n\n<h2 id=\"the-pixel-perfect-debate\">The pixel perfect debate<\/h2>\n\n<p>Every tech lead eventually has the \u201cpixel perfect\u201d conversation. The designer wants the implementation to match their mockup exactly. Engineering is shipping something close enough. Both sides get frustrated.<\/p>\n\n<p>Here\u2019s the reality: perfect implementation of every design detail is expensive. Engineering time spent matching mockups exactly is time not spent on other features. But designs exist for a reason. Visual consistency matters. User experience details matter.<\/p>\n\n<p>The answer isn\u2019t all-or-nothing. It\u2019s deciding together what matters.<\/p>\n\n<p>Some things should be pixel perfect:<\/p>\n<ul>\n  <li>Spacing and alignment in core UI components<\/li>\n  <li>Brand colours and typography<\/li>\n  <li>Key user-facing screens like onboarding or checkout<\/li>\n<\/ul>\n\n<p>Some things don\u2019t need to be:<\/p>\n<ul>\n  <li>Internal tools or admin screens<\/li>\n  <li>Early iterations that will change based on feedback<\/li>\n  <li>Features with low usage or visibility<\/li>\n<\/ul>\n\n<p>Have the conversation explicitly. \u201cFor this feature, what\u2019s the priority: speed or polish?\u201d Get agreement before work starts, not after it ships.<\/p>\n\n<h2 id=\"building-the-relationship\">Building the relationship<\/h2>\n\n<p>Like all relationships, this one requires upkeep.<\/p>\n\n<h3 id=\"learn-their-tools\">Learn their tools<\/h3>\n\n<p>You don\u2019t need to master Figma, but understanding how design files work helps. You can export assets correctly, read specs, and ask good questions.<\/p>\n\n<h3 id=\"share-your-tools\">Share your tools<\/h3>\n\n<p>Help designers understand how the codebase works, what reusable components exist, what\u2019s easy versus hard. This knowledge makes their designs easier to build.<\/p>\n\n<h3 id=\"give-them-access\">Give them access<\/h3>\n\n<p>Let designers see the staging environment, use the product, experience what users experience. Bugs and gaps are easier to discuss when everyone can see them.<\/p>\n\n<h3 id=\"celebrate-their-work\">Celebrate their work<\/h3>\n\n<p>When a design makes the product better, say so. Designers often only hear from engineering when there\u2019s a problem. Positive feedback builds the relationship.<\/p>\n\n<h3 id=\"include-them-in-retrospectives\">Include them in retrospectives<\/h3>\n\n<p>If a feature shipped with design compromises, talk about why together. What could you do differently next time to avoid late-stage pushback?<\/p>\n\n<h2 id=\"the-goal\">The goal<\/h2>\n\n<p>The goal isn\u2019t engineering and design agreeing on everything. It\u2019s both sides understanding each other well enough to have productive disagreements. To argue about the right approach because you\u2019re both trying to build something great, not because you\u2019re protecting your territory.<\/p>\n\n<p>This takes time. It requires you to see design as a partner in building good products, not a department that hands you requirements. When the relationship is right, you\u2019ll find that the frustrating conversations become interesting problems to solve together.<\/p>\n","pubDate":"Fri, 06 Feb 2026 08:00:00 +0000","link":"https:\/\/joshhornby.com\/working-with-designers","guid":"https:\/\/joshhornby.com\/working-with-designers"},{"title":"Working with Product Managers","description":"<blockquote>\n  <p>This post is part of my <a href=\"\/tags\/tech-lead\">Tech Lead Series<\/a>, a collection of practical advice for engineers stepping into leadership roles.<\/p>\n<\/blockquote>\n\n<p>Early in my career, I worked with a PM who would appear on Monday mornings with a fully formed spec, hand it to the team, and disappear until the demo. The specs were detailed, complete with wireframes and acceptance criteria. They were also frequently wrong. By the time we\u2019d built what was specified, the requirements had changed, or we\u2019d discovered technical constraints that made the approach unworkable.<\/p>\n\n<p>Years later, I worked with a PM who did the opposite. She\u2019d share half-formed ideas before they were ready, ask for technical input on problems we didn\u2019t understand yet, and change direction based on our feedback. It felt chaotic. It was also the most productive team I\u2019ve ever been on.<\/p>\n\n<p>The difference wasn\u2019t the PMs. It was the relationship.<\/p>\n\n<h2 id=\"the-partnership-model\">The partnership model<\/h2>\n\n<p>In healthy teams, the tech lead and PM operate as partners, not as spec-writer and spec-implementer. Both own the outcome. Both contribute to the solution. The boundaries are fuzzy by design.<\/p>\n\n<p>The PM brings customer understanding, market context, and business priorities. The tech lead brings technical constraints, implementation options, and system knowledge. Neither has the full picture alone.<\/p>\n\n<p>This sounds obvious, but it\u2019s not how many teams actually work. The default mode is handoff: PM decides what, engineering decides how. This creates problems that compound over time.<\/p>\n\n<p>When you\u2019re not involved in the \u201cwhat\u201d, you build solutions to the wrong problems. When the PM isn\u2019t involved in the \u201chow\u201d, they specify things that are expensive or impossible. The handoff model optimises for clean boundaries at the cost of good outcomes.<\/p>\n\n<h2 id=\"what-good-looks-like\">What good looks like<\/h2>\n\n<h3 id=\"early-involvement\">Early involvement<\/h3>\n\n<p>You\u2019re in the conversation when problems are being defined, not just when solutions are being specified. You hear about customer feedback before it becomes a Jira ticket.<\/p>\n\n<h3 id=\"mutual-influence\">Mutual influence<\/h3>\n\n<p>The PM changes their approach based on your technical input. You change your approach based on their customer insight. Neither person\u2019s opinion automatically wins.<\/p>\n\n<h3 id=\"shared-ownership\">Shared ownership<\/h3>\n\n<p>When something ships and doesn\u2019t work, you both own the failure. When something succeeds, you both take credit. The \u201cthat\u2019s not my job\u201d reflex is gone.<\/p>\n\n<h3 id=\"productive-disagreement\">Productive disagreement<\/h3>\n\n<p>You argue about the right approach, sometimes heatedly. But you argue in service of the outcome, not to win. And you reach decisions both can commit to.<\/p>\n\n<h3 id=\"trust-in-absence\">Trust in absence<\/h3>\n\n<p>When the PM makes a call without you, you trust it was reasonable. When you make a technical decision that affects the product, they trust you thought about the user impact.<\/p>\n\n<h2 id=\"building-the-relationship\">Building the relationship<\/h2>\n\n<p>This partnership doesn\u2019t happen by itself. You have to build it.<\/p>\n\n<h3 id=\"schedule-regular-time-together\">Schedule regular time together<\/h3>\n\n<p>A weekly sync, just the two of you. Not to review tickets, but to discuss what\u2019s coming, what\u2019s unclear, what\u2019s worrying each of you. This prevents surprises and builds understanding.<\/p>\n\n<h3 id=\"learn-their-world\">Learn their world<\/h3>\n\n<p>Sit in customer calls. Read the support tickets. Understand what success looks like from their view. The more you understand their context, the better your technical decisions will be. This is what separates <a href=\"\/product-engineers\">product engineers<\/a> from those who just build to spec.<\/p>\n\n<h3 id=\"share-your-constraints\">Share your constraints<\/h3>\n\n<p>Don\u2019t let technical debt, system limits, or team capacity be invisible. If something is expensive or risky, say so early. Give them the information they need to make good trade-offs.<\/p>\n\n<h3 id=\"ask-why-not-just-what\">Ask why, not just what<\/h3>\n\n<p>When a feature request arrives, understand the problem it\u2019s solving. Often there\u2019s a simpler solution once you understand the actual need. Sometimes the problem isn\u2019t worth solving at all.<\/p>\n\n<h3 id=\"offer-alternatives\">Offer alternatives<\/h3>\n\n<p>If you can\u2019t do what they\u2019re asking, don\u2019t just say no. Come back with options. \u201cWe can\u2019t do X, but we could do Y which addresses most of the same need in half the time.\u201d<\/p>\n\n<h2 id=\"when-to-push-back\">When to push back<\/h2>\n\n<p>Part of the tech lead\u2019s job is saying no. Not to block things, but to be helpful. Some situations that call for pushback:<\/p>\n\n<h3 id=\"when-the-complexity-isnt-worth-the-value\">When the complexity isn\u2019t worth the value<\/h3>\n\n<p>A feature that takes three months to build but helps 2% of users might not be the right investment. You have visibility into the cost that the PM might not have.<\/p>\n\n<h3 id=\"when-theres-a-better-solution\">When there\u2019s a better solution<\/h3>\n\n<p>If you can solve the same problem more simply, make the case. PMs aren\u2019t married to their specs; they\u2019re married to solving customer problems.<\/p>\n\n<h3 id=\"when-quality-will-suffer\">When quality will suffer<\/h3>\n\n<p>Rushing to hit a deadline by cutting corners creates debt that slows future work. Sometimes the right answer is to adjust scope or timeline rather than sacrifice quality.<\/p>\n\n<h3 id=\"when-the-team-is-overloaded\">When the team is overloaded<\/h3>\n\n<p>You see the strain on your team more clearly than anyone. If people are burning out or cutting corners because there\u2019s too much to do, push for sustainable pace.<\/p>\n\n<p>The key is how you push back. \u201cNo\u201d is rarely useful. \u201cHere\u2019s what I\u2019m seeing and here are some options\u201d starts a conversation.<\/p>\n\n<h2 id=\"common-failure-modes\">Common failure modes<\/h2>\n\n<h3 id=\"the-adversarial-relationship\">The adversarial relationship<\/h3>\n\n<p>Tech lead and PM view each other as obstacles. Engineering thinks product doesn\u2019t understand technical constraints. Product thinks engineering always overcomplicates things. Both are probably right, and both are making it worse.<\/p>\n\n<h3 id=\"the-absent-pm\">The absent PM<\/h3>\n\n<p>The PM is spread across too many teams, so they drop off specs and disappear. Engineering makes product decisions they shouldn\u2019t be making alone, and the PM is surprised by what gets built.<\/p>\n\n<h3 id=\"the-spec-following-tech-lead\">The spec-following tech lead<\/h3>\n\n<p>The tech lead treats specs as requirements to build rather than problems to solve. They don\u2019t push back, don\u2019t offer alternatives, don\u2019t engage with the product thinking.<\/p>\n\n<h3 id=\"the-scope-creeping-conversation\">The scope-creeping conversation<\/h3>\n\n<p>Every discussion about how to build becomes a negotiation about adding features. What started as a simple improvement becomes a three-month project because neither person knows how to say \u201cnot now.\u201d<\/p>\n\n<h2 id=\"the-underlying-principle\">The underlying principle<\/h2>\n\n<p>The best tech lead-PM relationships share one thing: both people are trying to solve the same problem rather than protect their territory.<\/p>\n\n<p>When the PM sees engineering as a partner in solving customer problems, they share context generously and welcome technical input. When the tech lead sees product as a partner in building good software, they engage with customer needs rather than just technical specifications.<\/p>\n\n<p>This requires trust, which requires time. You won\u2019t have this relationship on day one. But you can start building it by showing up as a partner rather than an implementer, by engaging with the product thinking rather than just the tickets, by treating the PM\u2019s success as your own.<\/p>\n","pubDate":"Tue, 03 Feb 2026 08:00:00 +0000","link":"https:\/\/joshhornby.com\/working-with-product-managers","guid":"https:\/\/joshhornby.com\/working-with-product-managers"}]}}