Over the years, I've learned valuable lessons about software engineering, often the hard way. These principles have shaped how I approach development, and I hope they'll be useful to you too. While some might seem obvious, they're easy to forget when you're deep in code. Here are essential rules I try to follow.
This post is an example of Why do I write blog posts. I've shared this advice multiple times with colleagues, friends, and mentees. Rather than repeating myself, I decided to write a blog post and share the link whenever someone asks for guidance.
#1. You're not paid to write code - you're paid to solve problems
Remember that writing code is a means to an end, not the goal itself. Your value as a software engineer comes from understanding business problems and delivering solutions that create value for users and the organization.
Sometimes the best solution involves no code at all. Maybe a process change would work better. Maybe an existing tool could be configured differently. Maybe the problem doesn't actually need solving. Before jumping into implementation, take time to understand the real problem, work backward from the desired outcome, and consider all possible approaches.
#2. There is no "best" solution, only trade-offs
Software engineering is all about making decisions with incomplete information. Every choice you make has pros and cons, and what works for one project might be terrible for another.
Should you use a microservices architecture? It depends on your team size, scalability needs, and operational complexity you can handle. Is NoSQL better than SQL? It depends on your data structure, query patterns, and consistency requirements. Should you write tests first? It depends on your context, team expertise, and project constraints.
The experienced engineer doesn't search for the "best" solution. Instead, they evaluate trade-offs based on current constraints: team skills, timeline, budget, scalability needs, and maintainability requirements. Document why you chose one option over another. Future you (or your teammates) will appreciate understanding the context behind decisions. Always know what you're optimizing for: performance, maintainability, time to market, team velocity, or something else entirely.
When you find yourself going in circles evaluating various trade-offs with no clear "good solution" emerging, it usually means you haven't defined enough constraints on the problem. Add more constraints: What's the budget? What's the deadline? What's the team size? What's the acceptable level of technical debt? What's the expected scale? Clear constraints turn endless debates into clear decisions.
#3. Less code is better code
More code means more to maintain, more to test, more to debug, and more places for bugs to hide. The best code is often the code you don't write. Every line of code should justify its existence.
Before adding a new feature or abstraction, ask yourself: Is this really needed? Can I solve this with existing code? Am I solving a problem I actually have, or one I think I might have in the future? Remove unused code without hesitation. Dead code creates confusion, increases maintenance burden, and gives a false sense of what the system actually does.
#4. Every dependency is a liability
Every dependency you add to your project is a commitment. You're trusting that library's quality, security, maintenance, and compatibility with future versions of your platform. Keep third-party dependencies minimal and well-managed.
Dependencies can become abandoned, contain security vulnerabilities, break in subtle ways with updates, or bring in transitive dependencies you never intended to include. The npm ecosystem is particularly notorious for this, with packages having hundreds of dependencies for simple tasks. Also, dependencies may prevent you from updating other dependencies because of incompatibilities.
Regularly audit your dependencies. Remove unused ones. Evaluate the health of the projects you depend on (recent commits, active maintainers, security track record). Consider the total cost of ownership, not just the initial convenience.
#5. Ship early, iterate often
Perfect is the enemy of done. The longer you wait to ship, the longer you delay getting real user feedback, the feedback that actually matters. First make it work, then make it right, then make it fast.
Early releases help you validate assumptions, discover what users actually need (versus what you think they need), and course-correct before you've invested months in the wrong direction. It's painful to throw away code, but it's more painful to spend six months building the wrong thing.
Don't wait for feedback until after you've built something. User interviews, prototypes, and mockups can validate ideas before writing a single line of code. Sometimes a simple conversation reveals that the feature you planned isn't what users need at all. Build feedback loops into your development process to grow as an engineer.
#6. Design for change because software is never finished
There's no finish line in software. User needs evolve, technologies change, platforms update, and security vulnerabilities are discovered. Accepting this reality changes how you build software.
Design for change. Make it easy to modify, extend, and maintain. Build with extensibility and clarity in mind. Refactor continuously and incrementally rather than waiting for a "big rewrite." Balance YAGNI ("You Aren't Gonna Need It") with thoughtful design that won't paint you into a corner. Make your changes reversible when possible, allowing you to roll back quickly if issues arise.
The "set it and forget it" mindset leads to technical debt, security risks, and eventually, a complete rewrite. Be aware of technical debt and repay it incrementally before it compounds.
#7. Write code for humans first, computers second
Code is read far more often than it's written. You might spend an hour writing a function, but dozens of developers might read it hundreds of times over the years. Write code that other developers will enjoy working with.
Maintainability means clear naming, simple logic, good structure, and appropriate comments. Choose clarity over cleverness. Choose descriptive naming over explanatory comments. Favor explicit code over implicit magic. It means avoiding clever tricks that save two lines but require five minutes to understand. It means thinking about the developer who will debug this at 2 AM when the production system is down.
#8. Complexity kills projects - keep it simple
That clever abstraction you're so proud of? That intricate design pattern you implemented? You'll regret complexity when you're on-call at 3 AM when the system is down and customers are angry.
Complexity has a cost. Every abstraction layer, every indirection, every "flexible" design makes the system harder to debug and reason about. Prefer boring, straightforward solutions that are easy to understand under pressure. Complexity doesn't scale; simplicity does. KISS: Keep It Simple, Stupid.
The simplest design that works usually wins. Build systems you can explain on a whiteboard. Abstraction should hide complexity, not create it. Respect the principle of least surprise: your code should behave the way other developers expect it to behave.
Note that over-engineering is part of the developer journey. You have to go through it to appreciate simplicity. But once you do, you'll never want to go back 😉
#9. Fix root causes, not symptoms
When you find a bug, resist the urge to patch it with a quick fix. Take time to understand why it happened. Band-aid solutions lead to brittle systems with layers of patches stacked on patches.
Find the root cause. Fix it properly. Yes, it takes longer. Yes, it might require refactoring. But you'll prevent related bugs and build a more robust system. Fixing a class of bugs at the root is far more efficient than repeatedly addressing individual symptoms.
See also: The Five Whys technique — an iterative "why?" method for root‑cause analysis. For practical debugging strategies, see this post: I fixed a bug. What should I do now?.
#10. Understand the "why" before moving on
When something isn't working or behaves differently than you expected, don't just keep changing things until it seems to work. This "trial and error until it works" approach is tempting but dangerous. It leaves you with code you don't understand, potential bugs lurking beneath the surface, and missed opportunities to learn.
Make sure you figure out why it was behaving the way it was. What assumption was wrong? What did you misunderstand about the framework, library, or language? What was the actual cause of the unexpected behavior? Without understanding the root cause, you're not really advancing your skills or building reliable software.
Take the time to investigate. Read the documentation. Debug systematically. Ask questions. Run experiments to test your hypotheses. The understanding you gain will help you avoid similar issues in the future and deepen your knowledge of the tools you're using. Randomly changing things until something works is not debugging; it's hoping. And hope is not a strategy.
#11. Don't fall in love with your code
Your code is not your baby. It's not a reflection of your worth as a developer. It's a tool to solve a problem, and if there's a better tool or better way, you should be willing to let it go. Your code will probably get rewritten, and that's okay.
Being emotionally attached to your code makes you defensive in code reviews, resistant to change, and blind to better solutions. Good engineers regularly delete their own code, refactor their designs, and admit when their first approach wasn't the best. Remove unused code without hesitation. Dead code is technical debt.
#12. Seek to understand before judging others' code
It's human nature. When you look at someone else's code, you see all the ways it's different from how you would have written it. It seems messy, over-complicated, or poorly structured.
Before you judge, try to understand the context. What constraints did they face? What requirements did they have? What was the codebase like when they started? Often, what seems like a poor decision makes sense when you understand the full story. Understand the business behind the code. Technical decisions make more sense when you understand the business problems they solve.
That said, if the code is truly problematic, focus on the code, not the developer. Review code with empathy. You're building people, not just software. Suggest improvements respectfully, and remember that your code will be someone else's "inherited mess" someday.
#13. Document the "why" behind your decisions
Six months from now, you won't remember why you chose this approach over that one. Neither will your teammates. Neither will the new hire trying to understand the system. Design and decisions lose context over time.
Document important decisions, especially when you had to choose between reasonable alternatives. Use Architecture Decision Records (ADRs), RFCs, comments, or design docs. Explain the context, the options you considered, and why you chose this path. It doesn't have to be formal, just enough to jog your memory later.
Good documentation isn't about documenting every line of code. It's about capturing the "why" behind non-obvious decisions, the trade-offs you evaluated, and the constraints you were working with. Simplify onboarding through clear documentation; your docs are part of your product.
When asking yourself "what would have the most impact today?", consider documentation. Good documentation has a multiplier effect: it unblocks teammates, reduces interruptions, speeds up onboarding, and prevents repeated mistakes. An hour spent documenting can save dozens of hours for your team. It's not glamorous work, but it's often the highest-leverage activity you can do.
Example: Instead of just writing code, document: "We chose PostgreSQL over MongoDB because our data is highly relational, we need ACID transactions for financial records, and our team has 5 years of PostgreSQL experience but no MongoDB experience. We evaluated MongoDB but decided the learning curve and migration risk outweighed the benefits."
#14. Write meaningful commit messages and clear communication
Your commit history tells the story of your project. Good commit messages help you understand what changed and why, making debugging, code review, and onboarding much easier.
A commit message like "fix bug" tells you nothing. A message like "Fix null reference in user service when optional phone number is missing" tells you what changed, where, and provides context for future developers.
Provide clear communication not just in commits, but also in pull requests, tickets, and documentation. Keep pull requests small and manageable so they're easier to review and less likely to introduce bugs. Clear communication is a hallmark of senior behavior and benefits everyone on the team.
Tip: AI tools can help generate better commit messages by summarizing code changes, but always review and edit them to ensure accuracy and clarity. Pull request titles and descriptions can also serve this purpose effectively, especially in collaborative environments where multiple changes are grouped together.
#15. Automate everything that can be automated
Tabs vs. spaces. Braces on the same line or next line. Camel case or Pascal case. These debates waste time and create friction. Establish coding standards early, automate enforcement with formatters and linters, and move on. Be consistent with your coding standards.
But automation goes far beyond formatting. Automate testing, deployments, security scans, dependency updates, and any repetitive task. CI/CD isn't optional. It's the foundation of reliable software delivery. Invest in CI/CD right from the start, not as an afterthought.
Build pipelines with safeguards: automated rollbacks, comprehensive testing, and bake times to reduce the blast radius of potential issues. Forget about "it works on my machine". If it works in your automated pipeline, it works everywhere.
#16. Code reviews improve more than just quality
Code reviews aren't just about catching bugs. They're one of the best ways to share knowledge across your team and improve both the code and the people writing it.
Junior developers learn patterns and practices. Senior developers stay aware of what's happening in different parts of the codebase. Everyone learns about new areas they haven't worked in. Knowledge silos decrease, and the team becomes more resilient. Review code with empathy, remembering you're building people, not just software.
Keep pull requests small and manageable. Large PRs are hard to review thoroughly and more likely to introduce issues. A focused, well-scoped PR is easier to understand, review, and merge.
#17. Never stop learning and questioning assumptions
Technology moves fast. The frameworks, languages, and tools you know today will be different in five years. Stagnation is career death in this industry. Continuous learning is non-negotiable.
But learning isn't just about chasing new technology. Don't chase new tech; chase understanding. Learn fundamentals: algorithms, data structures, networking, security, and software design principles. Learn adjacent skills: communication, mentoring, project management. Broad knowledge makes you a better developer.
Maintaining live production systems gives the best learnings. Run toward the fire; this is where you grow. Face the hard problems, debug the production incidents, and learn from real-world constraints. This is where theory meets reality.
#18. Ask for help when you're stuck
Being stuck for hours because you're too proud to ask for help is a waste. Your teammates want to help. They've been stuck on similar problems. They have different perspectives that might immediately spot what you're missing. Asking for help is a strength, not a weakness.
The key is asking good questions. Show what you've tried. Explain your current understanding. Provide context. This makes it easier for others to help and shows you've done your homework. Feedback loops build great engineers.
Example: Instead of "My code doesn't work, help!", try: "I'm trying to connect to the API, but I'm getting a 401 error. I've verified the API key is correct and the endpoint URL matches the documentation. Here's my code. Am I missing something about the authentication flow?"
#19. Estimations are never true - communicate as ranges
Estimation is hard. Really hard. There are always unknowns, unexpected complications, and tasks that seemed simple but weren't. Estimations are never perfectly accurate.
Treat estimates as educated guesses, not commitments carved in stone. Include uncertainty in your estimates (ranges or confidence levels). Track your estimates versus actuals to improve over time. And most importantly, communicate early when you discover an estimate was off. Communicate trade-offs clearly; that's senior behavior.
Example: Instead of saying "This will take 3 days," try "I estimate this will take 3-5 days, assuming there are no issues with the third-party API integration, which I haven't worked with before. I'll update you after my initial spike if I discover complications."
#20. You can't operate what you can't observe
Production systems need visibility. Without proper observability, you're flying blind when things go wrong, and they will go wrong. Log precisely, not excessively. Add logs, metrics, traces, and alarms that matter.
Design systems with observability in mind from the start. Instrument your code to track key metrics, performance, errors, and user behavior. Make sure you can answer questions like: Is the system healthy? Where are the bottlenecks? What's the user experience? When did this start failing?
Operational excellence isn't optional. It's the difference between fixing issues in minutes versus hours. Between knowing about problems before your users do versus learning from angry support tickets. Reliability matters more than new features. Always.
#21. Never trust user input - validate and sanitize everything
Every input is a potential attack vector. Users make mistakes, typos happen, and malicious actors actively try to break your system. Never trust user input, whether it comes from forms, APIs, file uploads, or URL parameters.
Validate all inputs at the boundary. Check types, formats, ranges, and allowed values. Sanitize data to prevent injection attacks. Use parameterized queries, encoding, and established security libraries rather than rolling your own solutions.
#22. Errors should fail loudly and immediately
Silent failures are debugging nightmares. When something goes wrong, make it obvious. Fail fast, fail loud, and provide clear error messages that help identify the problem quickly.
Don't swallow exceptions. Don't return null when something failed. Don't log an error and continue as if nothing happened. When a precondition isn't met, when data is invalid, when a required service is unavailable, stop execution and report the problem clearly.
Good error handling means failing fast with clear messages, providing context about what went wrong and why, and making it easy to trace the error to its source. This saves hours of debugging time.
#23. Measure first, optimize second
Premature optimization is the root of much evil. Developers often spend time optimizing code that isn't actually a bottleneck, making it more complex without meaningful performance gains.
Before optimizing, measure. Profile your application. Identify actual bottlenecks based on data, not intuition. You might be surprised to find the slow part isn't where you thought it was. Once you've identified the real performance issues, optimize those specific areas and measure again to verify improvement.
This doesn't mean ignore performance entirely. Write reasonably efficient code from the start, but don't sacrifice clarity and maintainability for micro-optimizations that don't matter. Know what you're optimizing for: latency, throughput, memory, or development time.
#24. Change one thing at a time
When debugging or optimizing performance, resist the urge to change multiple things simultaneously. If you modify several things at once, you won't know which change actually fixed the bug or improved performance. This applies whether you're fixing a production issue, optimizing a slow query, or debugging a failing test.
Make one change, measure the result, and then proceed. This disciplined approach might feel slower initially, but it saves time by giving you clear cause-and-effect relationships. You'll build a mental model of what actually matters and avoid the frustration of undoing a batch of changes because one of them broke something else.
This principle extends beyond debugging. When refactoring, change structure or behavior, but not both at once. When deploying, release one feature at a time to isolate issues. When experimenting, vary one parameter and hold others constant. Scientific method applies to software engineering.
#25. Design APIs that are easy to use correctly and hard to misuse
Good API design prevents bugs before they happen. When you design a function, class, or service interface, think about how it will be used and how it might be misused.
Use types to enforce constraints (required vs. optional, valid ranges, allowed states). Make invalid states unrepresentable. Use clear naming that indicates purpose and behavior. Provide good defaults. Make common cases simple and complex cases possible. Return meaningful errors that guide developers toward correct usage.
Favor composition over inheritance. Composition is more flexible and creates fewer coupling issues. Minimize coupling between components and maximize cohesion within them. Each module should have a clear, focused purpose.
#26. Be a good human - it's more important than technical skills
Software engineering is a team sport. Technical brilliance means nothing if you're difficult to work with, don't communicate effectively, or don't support your teammates.
Be kind. Be patient. Share credit. Admit mistakes. Help others grow. Listen more than you talk. Respect different perspectives and work styles. Recognize that everyone has struggles you don't see. Your attitude and collaboration skills matter more for your career than any technical skill.
Review code with empathy. Communicate trade-offs clearly. Support your teammates when they're struggling. Celebrate their successes. Build psychological safety where people feel comfortable asking questions, admitting they don't know something, or raising concerns.
Example: When a teammate makes a mistake that causes a production issue, focus on fixing the problem and preventing it from happening again, not on blame. Ask "How can we improve our process?" instead of "Why did you do that?" This builds trust and encourages people to report problems early instead of hiding them.
#Conclusion
These rules aren't strict laws. They're guidelines based on experience, mistakes, and lessons learned. Take what resonates with you, adapt it to your context, and remember that software engineering is as much about people and communication as it is about code.
The best engineers aren't those who write the most clever code. They're the ones who deliver value, collaborate effectively, learn continuously, and make everyone around them better. Keep these principles in mind, but also develop your own based on your experiences. Every project, every team, and every challenge will teach you something new.
Do you have a question or a suggestion about this post? Contact me!