Skip to content

Instantly share code, notes, and snippets.

@hbmartin
Created June 18, 2025 17:18
Show Gist options
  • Select an option

  • Save hbmartin/c169c55d3cffeed0ca4a66f0f2f65bf1 to your computer and use it in GitHub Desktop.

Select an option

Save hbmartin/c169c55d3cffeed0ca4a66f0f2f65bf1 to your computer and use it in GitHub Desktop.
A readable translation of https://grugbrain.dev/ with links to other perspectives

Software Development Principles: A Pragmatic Guide

A practical approach to software development based on years of experience

Introduction

This collection represents practical lessons learned from many years of software development. While not claiming exceptional intelligence, these insights come from extensive experience building and maintaining software systems, along with the mistakes that inevitably accompany that journey.

These principles are intended for developers at all levels, but particularly those who value pragmatic solutions over theoretical perfection. Some may find this approach overly simplistic, but experience suggests that simple, maintainable solutions often outperform complex, "elegant" ones.

The goal is to share practical wisdom that can help avoid common pitfalls and build better software, even if it challenges some widely-accepted industry practices.

The Primary Challenge: Managing Complexity

Complexity is the primary enemy of software development success. It's the single biggest factor that makes systems unmaintainable, unreliable, and impossible to extend or modify safely.

Complexity is extremely harmful. It enters codebases through well-intentioned developers and project managers who either don't recognize its dangers or underestimate its impact.

Systems start simple and understandable, allowing productive development. But complexity accumulates insidiously—one day you can implement features easily, the next day the simplest changes break unrelated functionality in mysterious ways.

Unlike visible problems, complexity is a systemic issue that pervades code architecture, making its presence felt through increased development time, more bugs, and decreased developer confidence when making changes.

The most effective defense against complexity is disciplined restraint in what we choose to build and how we choose to build it.

Alternative perspectives:

Strategic Use of "No"

The most powerful tool against complexity is saying "no" to features, abstractions, and architectural decisions that aren't essential.

Examples of strategic rejection:

  • "No, we won't build that feature"
  • "No, we won't add that abstraction"
  • "No, we won't adopt that new framework"

This approach conflicts with career advancement, where saying "yes" and taking on more responsibility typically leads to promotion and higher compensation. However, from a technical excellence perspective, restraint often produces better outcomes.

Learning to say "no" effectively requires practice and can be difficult for developers who want to be helpful and avoid disappointing stakeholders. The skill becomes easier with experience, even if it doesn't maximize financial rewards.

Alternative perspectives:

Pragmatic Compromise

Sometimes rejection isn't viable due to business constraints or stakeholder requirements. In these situations, compromise becomes necessary.

When forced to implement complex features, apply the 80/20 principle: deliver 80% of the value with 20% of the complexity. This approach may not include every requested feature and might not be aesthetically perfect, but it works and provides most of the business value while keeping complexity manageable.

Often, it's better to implement the simplified solution without extensive discussion with stakeholders. Project requirements frequently change, stakeholders move on to other priorities, or the original requestor leaves the organization before the feature is completed.

This approach generally serves everyone's interests better than fully-featured implementations that take significantly longer to deliver.

Alternative perspectives:

Code Organization and Refactoring

Proper code organization (refactoring and modularization) is crucial but difficult to prescribe generically since every system has unique characteristics. However, one key principle is avoiding premature factorization.

Early in projects, requirements and system architecture are fluid and abstract. Attempting to create the "right" abstractions before understanding the problem domain often leads to incorrect factorization that becomes difficult to change later.

Instead, allow systems to develop organically until clear separation points emerge. Good separation points have narrow interfaces with the rest of the system—small numbers of functions or abstractions that hide internal complexity effectively.

Watch for these natural boundaries to emerge from the codebase, then refactor gradually as experience with the system grows. There's no universal rule for timing; recognizing good separation points is a skill developed through practice and patience.

Experienced developers often prefer to wait rather than create premature abstractions. Senior developers sometimes create elaborate abstractions early in projects, then move on to other work, leaving maintenance to others.

When working with senior developers prone to over-abstraction, try redirecting their energy toward UML diagrams (which don't affect the codebase) or demand working demonstrations of their proposals. Working demos force abstract thinking to confront practical implementation realities.

Remember that senior developers have valuable expertise—the goal is channeling that expertise productively rather than letting it create unnecessary complexity.

Calling early implementations "prototypes" can make this approach more palatable to stakeholders and senior team members.

Alternative perspectives:

Testing Strategy

Testing is essential for maintaining code quality and preventing regressions, but testing approaches often become dogmatic rather than pragmatic.

Test-driven development (TDD) advocates writing tests before implementation, even before understanding the problem domain. This approach can be counterproductive when you're still learning what the software should do.

A more practical approach involves writing most tests after the prototype phase, when the code structure has begun to stabilize. However, this requires discipline—it's easy to skip testing because "it works on my machine."

The ideal testing strategy combines three types of tests:

Unit tests are useful early in development to establish basic functionality, but they become brittle as implementation details change. They catch some bugs but miss many that arise from component interactions. Don't expect unit tests to remain valuable long-term as code evolves.

End-to-end tests demonstrate that the complete system works correctly, but they're difficult to debug when they fail. Teams often ignore consistently failing end-to-end tests, which defeats their purpose.

Integration tests provide the best balance: high-level enough to verify system correctness, low-level enough to debug effectively when they fail. Focus testing effort on integration tests as system boundaries stabilize.

Avoid excessive mocking in tests—use it sparingly and only at coarse-grained boundaries.

One exception to the "test after implementation" rule: when fixing bugs, always write a regression test first, then fix the bug. This workflow is more effective than the general TDD approach.

Alternative perspectives:

Development Methodologies

Agile development is neither terrible nor excellent—it's a reasonable way to organize development work, possibly better than alternatives.

The primary danger lies with methodology zealots who attribute project failures to "not doing Agile correctly" rather than acknowledging that no methodology solves all problems. This creates a convenient excuse for consultants to sell more training and process improvement.

Success in software development depends more on prototyping, good tooling, and hiring capable developers than on following any particular process. Agile can help, but it can also become harmful when applied too rigidly.

No methodology is a silver bullet that solves all software development challenges.

Alternative perspectives:

Refactoring Best Practices

Refactoring is valuable and often necessary, especially later in projects when code structure has matured. However, many refactoring efforts fail catastrophically, causing more harm than benefit.

The size and scope of refactoring efforts correlate strongly with their failure rate. Large refactorings are more likely to fail than small ones.

Keep refactoring efforts small and maintain working software throughout the process. Ideally, the system should remain functional at each step, with each change completed before beginning the next.

End-to-end tests are crucial during refactoring, though they can be difficult to debug when they break.

Avoid introducing excessive abstraction during refactoring. Historical examples like J2EE and OSGi demonstrate how complex abstractions intended to manage complexity often make systems more complex instead. These "solutions" can require years of expert effort to remove while making feature development impossible.

Alternative perspectives:

Chesterton's Fence Principle

G.K. Chesterton observed: "Don't ever take a fence down until you know the reason why it was put up."

Applied to software: don't remove code just because you don't understand its purpose. Experienced developers learn not to delete code indiscriminately, regardless of how ugly it appears.

All programmers desire elegant, perfect code, but real-world systems often require ugly, practical solutions. What looks like poor code may address edge cases, integration requirements, or business logic that isn't immediately obvious.

Humility is valuable when encountering unfamiliar code. The instinct to "clean up" code often leads to hours of debugging with no improvement, or worse, degraded functionality.

This doesn't mean never improving systems, but it does mean taking time to understand existing code before modifying it, especially in larger, more complex systems.

Tests often provide valuable clues about why seemingly unnecessary code exists.

Alternative perspectives:

Microservices Architecture

Microservices take the most difficult problem in software development—correctly organizing system boundaries—and add network latency and distributed system complexity on top.

This approach seems counterproductive from a complexity management perspective.

Alternative perspectives:

Development Tools

Tools are fundamental to productive software development. Invest time learning the tools available in your environment—two weeks spent mastering tools often doubles development productivity.

Essential tool categories:

Code completion in IDEs prevents the need to memorize extensive APIs. Some languages (like Java) are nearly impossible to use productively without intelligent code completion.

Debuggers are invaluable for understanding system behavior and finding bugs. Learn advanced debugger features like conditional breakpoints, expression evaluation, and stack navigation. Debuggers often teach more about computer systems than formal computer science education.

Continuous tool improvement should be an ongoing priority. Never stop improving your development environment and workflow.

Alternative perspectives:

Type Systems

Type systems provide significant value primarily through development-time assistance: code completion, API discovery, and basic correctness checking.

The primary benefit of type systems is hitting a key and seeing available methods and properties. This represents 90% or more of the practical value.

While type system theorists emphasize correctness as the main benefit, note that many type system experts don't ship production software regularly. Code that never ships is technically correct but not practically useful.

Avoid excessive abstraction in type system usage. Generic programming and advanced type features can create code that's theoretically elegant but practically incomprehensible. This is especially dangerous with generics—limit their use primarily to container classes where they provide clear value.

The complexity demon loves generic abstractions. Always remember that the primary value of type systems is development-time assistance, not abstract theoretical correctness.

Alternative perspectives:

Expression Complexity Management

Early in careers, many developers try to minimize lines of code by writing complex, condensed expressions:

if(contact && !contact.isActive() && (contact.inGroup(FAMILY) || contact.inGroup(FRIENDS))) {
  // ...
}

Over time, experience shows that explicit, verbose code is easier to debug:

if(contact) {
  var contactIsInactive = !contact.isActive();
  var contactIsFamilyOrFriends = contact.inGroup(FAMILY) || contact.inGroup(FRIENDS);
  if(contactIsInactive && contactIsFamilyOrFriends) {
    // ...
  }
}

This approach provides several benefits:

  • Easier debugging through inspection of intermediate values
  • Self-documenting variable names
  • Clearer conditional logic
  • Better debugger stepping experience

The second example is significantly easier to debug and understand, despite requiring more lines of code.

Alternative perspectives:

DRY (Don't Repeat Yourself) Principle

DRY is a powerful and generally good principle, but it requires balance. As developers gain experience, strict adherence to DRY becomes less important than other considerations.

Simple, obvious code duplication can be preferable to complex abstractions involving callbacks, closures, or elaborate object models when the complexity cost outweighs the duplication cost.

This represents a difficult balance. Code duplication still creates maintenance concerns, but experience shows that simple repeated code is often better than complex DRY solutions.

The key is distinguishing between harmful duplication and acceptable duplication based on the complexity of the alternative.

Alternative perspectives:

Separation of Concerns vs. Locality of Behavior

Separation of Concerns (SoC) advocates organizing code by separating different aspects into distinct sections. The canonical web development example separates style (CSS), markup (HTML), and logic (JavaScript) into different files.

An alternative principle, Locality of Behavior (LoB), suggests placing code close to the elements it affects. When code is co-located with the elements it modifies, understanding system behavior becomes easier.

With strict separation of concerns, understanding how a button works often requires examining multiple files across different directories. This increases cognitive load and development time.

Co-locating related code makes system behavior more immediately apparent and reduces the effort required to understand functionality.

Alternative perspectives:

Closures and Functional Programming

Closures are valuable for the right applications, primarily abstracting operations over collections of objects.

Like salt, type systems, and generics, closures provide value in small amounts but can cause problems when overused. JavaScript's "callback hell" demonstrates what happens when closure-based APIs become too complex.

Use closures judiciously and avoid deeply nested callback structures that become difficult to understand and debug.

Alternative perspectives:

Logging Strategy

Logging is crucial for production systems, especially in cloud deployments. Some developers consider logging expensive and unimportant—this is a mistake.

Effective logging practices:

  • Log all major logical branches in code (if statements, loops)
  • Include request IDs when requests span multiple machines to enable log correlation
  • Make log levels dynamically controllable for runtime debugging
  • Enable per-user log levels when possible for targeted debugging

The last two points are particularly valuable for debugging production issues.

Logging infrastructure often has complex configuration (Java logging frameworks are notorious examples), but investing time in proper logging setup pays significant dividends during production debugging.

Logging should be taught more extensively in computer science education.

Alternative perspectives:

Concurrency Management

Concurrency is inherently complex and error-prone. When possible, rely on simple concurrency models:

  • Stateless web request handlers
  • Simple remote job worker queues with independent jobs
  • Well-established patterns and frameworks

Optimistic concurrency control works well for web applications.

Occasionally, thread-local variables are useful, particularly in framework code.

Some languages provide good concurrent data structures (like Java's ConcurrentHashMap), but they still require careful implementation to use correctly.

Languages like Erlang have good reputations for concurrency, though their syntax may be unfamiliar.

Alternative perspectives:

Performance Optimization

Donald Knuth's famous observation remains relevant: "Premature optimization is the root of all evil."

Always base optimization decisions on concrete, real-world performance profiles showing specific issues. Assumptions about performance bottlenecks are frequently wrong.

Don't focus exclusively on CPU performance and Big O notation. Network latency often dominates performance characteristics—a single network call can equal millions of CPU cycles. This is particularly relevant for microservice architectures.

Developers often see nested loops and immediately think "O(n²) complexity!" without measuring whether this actually causes performance problems in practice.

Alternative perspectives:

API Design

Good APIs don't require excessive cognitive effort from users. Unfortunately, many APIs are poorly designed for common use cases.

Poor API design typically results from:

  • Designing based on implementation details rather than usage patterns
  • Over-abstraction and excessive complexity

Most developers want simple operations: writing files, sorting lists, making network requests. APIs should make common cases simple while still supporting complex scenarios when necessary.

Design APIs in layers: simple APIs for common cases, more complex APIs for advanced scenarios.

In object-oriented languages, put methods on the objects they operate on rather than in separate utility classes. Java's stream API exemplifies poor design: requiring conversion to streams, complex collectors, and understanding of generic type parameters for simple operations like filtering a list.

Alternative perspectives:

Parsing and Language Development

For creating programming languages, recursive descent parsing is the most intuitive and maintainable approach. Many computer science programs only teach parser generator tools, which produce difficult-to-understand, bottom-up code that obscures the recursive nature of grammars and makes debugging nearly impossible.

Recursive descent parsing is used in most production parsers despite being overlooked in academic settings. Learning that parsing isn't exclusively for experts can be liberating.

Bob Nystrom's "Crafting Interpreters" is an excellent resource for learning recursive descent parsing and is available free online, though purchasing the book is recommended to support quality educational content.

Alternative perspectives:

Design Patterns: The Visitor Pattern

The Visitor Pattern is generally problematic and should be avoided.

Alternative perspectives:

Front-End Development

Many web developers unnecessarily split applications into separate front-end and back-end codebases using SPA frameworks and API-driven architectures, even for simple applications that just need to store form data or display static content.

This approach creates two separate complexity domains instead of one. Front-end complexity tends to grow more rapidly and become more problematic than back-end complexity.

Back-end developers generally try to keep things simple and maintainable, while front-end developers often introduce significant complexity even for straightforward applications.

This trend seems driven by industry leaders like Facebook and Google rather than technical necessity.

Alternatives like HTMX and Hyperscript can maintain low complexity by using simple HTML and minimal JavaScript, avoiding the complexity inherent in large front-end frameworks.

While React provides good job opportunities and works well for certain applications, it inevitably introduces significant complexity regardless of the developer's intentions.

Alternative perspectives:

Technology Fads

Software development, particularly front-end development, experiences frequent technological fads and trends.

Back-end development tends to be more stable because most problematic approaches have been tried and abandoned over decades of development. Front-end development continues experimenting with previously tried approaches because the domain is younger and more volatile.

Approach revolutionary new technologies with skepticism. Experienced developers have been working on computer systems for decades, and most ideas have been attempted at least once.

This doesn't mean avoiding all new technologies or dismissing innovation, but it does mean being selective and understanding that many "revolutionary" approaches are recycled ideas from earlier eras. Much development time is wasted on repackaged solutions that don't represent genuine improvements.

Alternative perspectives:

Overcoming Fear of Looking Uninformed

Senior developers should feel comfortable saying publicly: "This is too complex for me to understand easily."

Many developers fear appearing uninformed (FOLD - Fear of Looking Dumb), which becomes a major source of complexity in software systems. When senior developers admit confusion, it gives junior developers permission to acknowledge when they don't understand something as well.

Removing this fear significantly reduces the complexity demon's power over development teams, especially affecting junior developers who may feel pressure to pretend they understand complex systems.

When making such admissions, maintain a thoughtful demeanor and be prepared for condescending responses from developers who want to appear more knowledgeable. Having examples of past failed projects by overly complex approaches can be useful in these discussions.

Alternative perspectives:

Impostor Syndrome

Impostor syndrome is common in software development. Most developers alternate between feeling completely competent and feeling completely incompetent.

Even experienced developers with successful projects and recognition frequently feel uncertain about their abilities and worry about making mistakes that disappoint their colleagues.

This may be inherent to programming work. If everyone experiences impostor syndrome, then perhaps no one is actually an impostor—it's just the nature of working in a complex, rapidly-changing field.

Developers who have read this far and relate to these concerns will likely succeed in programming careers, despite ongoing frustration and uncertainty.

Alternative perspectives:

Recommended Reading

Valuable resources for further learning:

Conclusion

Remember: Complexity is extremely, extremely harmful to software development success.

The key to building maintainable, reliable software lies in actively resisting unnecessary complexity at every level of development, from individual expressions to overall system architecture.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment