Skip to content

Instantly share code, notes, and snippets.

@csasbach
Created September 28, 2023 10:05
Show Gist options
  • Save csasbach/1827d2c367125c242e9390e3a24e3381 to your computer and use it in GitHub Desktop.
Save csasbach/1827d2c367125c242e9390e3a24e3381 to your computer and use it in GitHub Desktop.

Design Principles

The resources below cover a lot of ground with regard to designing code that is easy to test and maintain in the first place, as well as how to refactor existing code to accomplish the same.

Good code design is a critical ingredient of a delivery pipeline with fast feedback loops. Fast feedback loops in turn enable us as developers to deliver stable software in a truly (not just in name) Agile team. When it is quick and easy to research, design, experiment, test, deliver, and repeat; development is exciting and rewarding and customers get the features they want and the fixes they need without delay.

By contrast, bad code design will eventually sabotage all efforts related to speed and stability until the technical debt accrued becomes so great that the entire project slows to a crawl. Releases will be less frequent, and what does get released will not be stable. Eventually, morale inside the team and confidence outside the team become damaged, and that's not a fun place to be.

Suggested Reading

  • A Philosophy of Software Design, John Ousterhout, ISBN: 9781732102200
  • The Pragmatic Programmer: Your Journey to Mastery, David Thomas - Andrew Hunt, ISBN: 9780135956977
  • Clean Code: A Handbook of Agile Software Craftsmanship, Robert C. Martin, ISBN: 9780132350884
  • Clean Architecture: A Craftsman's Guide to Software Structure and Design, Robert C. Martin, ISBN: 9780134494166
  • Refactoring: Improving the Design of Existing Code, Martin Fowler, ISBN: 9780134757599
  • Beyond Legacy Code: Nine Practices to Extend the Life (and Value) of Your Software, David Scott Bernstein, ISBN: 9781680500790

Counter Points to the Suggested Readings:

It’s probably time to stop recommending Clean Code Many good points in this article. Uncle Bob commits the cardinal sin of using real code instead of contrived examples. Real code never lives up to our ideals. That’s why the first requirement is Ease. Of. Change. Clean Code could maybe use a reboot, but if you ignore the specific examples and focus on the philosophy, it’s still got a lot of valid advice.

Links

Object-Oriented Design Principles

  • SOLID This is perhaps the most well-known group of OO design principles. While not all languages necessarily map to these concepts 1:1, every OO language has paradigms that map to these principles in spirit, even if indirectly.
  • GRASP This is a less well-known set of principles but it covers a lot of things that aren't explicitly addressed in SOLID
  • Code Reuse Not so much a specific design principle as an invitation to carefully consider the impact of how and when we choose to reuse code.
  • DRY, WET, AHA These principles speak to some of the code reuse questions and offer some guidance. Most important is to realize that, though it may be the most popular philosophy and probably the right one in most situations, DRY can sometimes be a catastrophically bad design decision as well. Keep these quotes in mind:

"A little copying is better than a little dependency" -Rob Pike

"prefer duplication over the wrong abstraction" -Sandi Metz

  • KISS A fairly common-sense principle, but worth a position in any list of design principles.
  • YAGNI Many a generously titled developer/architect has been tempted by the siren call of the most-best-future-proof design. The hard truth though, is that if you don't need it today there's a strong possibility you won't need it tomorrow. Even worse, you will need it and what you designed doesn't actually fit the use case properly.

Your real goal should be code that is easy to maintain and a delivery pipeline that empowers you to deliver reliable code quickly. Then you can have absolute confidence in your ability to deliver the features you need exactly when and if you need them.

Functional Design Principles

  • Principles of Functional Programming While we are not likely to be programming in a purely functional paradigm any time soon, many OO languages have adopted some features of functional programming over the years. It is also a good idea to embrace at least the principles of functional programming, as there are some useful disciplines that can be applied even in an OO paradigm.

Namely:

Leveraging immutability to prevent entire classes of bugs with regard to race conditions and what I'll call "spooky action at a distance" that can happen at runtime within multi-threaded and/or overly complex OO code.

"Pure" functions. Not that you should get hung up on the idea that functions should be absolutely pure (though if they can be they should be) but that you should strive to make every function the "purest" version of itself it can be. Then you will find your code, even at the method/function level is capable of working with abstractions, is highly testable, is highly reliable, and is in its most simple form.

  • SOLID and Functional Programming This article does a good job of illuminating how an OO developer might modify their interpretation of functional programming to better fit within an OO paradigm.

Component Level Design Principles

  • Package Principles These principles have to do more with software architecture. Following them helps you design packages that won't leave you pulling your hair out trying to resolve dependency conflicts and the like.

Methodology

  • Extreme Programming Treat Extreme Programming in much the same way that we treat Functional Programming. We probably aren't ever going to follow it in its entirety, but there are a lot of useful concepts being promoted in this methodology, ones that are good to assimilate to some lesser or greater degree as best suits the team.

Anti-patterns

These are the opposite of design patterns. When you see these patterns in your software, you need to refactor them.

Leaky Implementation

So, you may have carefully crafted your code following all of the design principles above but your third-party data layer just upgraded to a new major version and all the APIs changed!

  • Does the third-party vendor implement a client that encapsulates the API? GREAT! Does it throw new exception types? Have the request and response data types changed? You still have problems.
  • Did you write an abstraction layer that encapsulates the client so that changes to types the client exposes will only be breaking BEHIND your abstraction layer? EXCELLENT! You are way ahead of the curve. Does your abstraction layer ever return plain text error messages that were generated inside the client or even returned from the API itself? Does your third-party package require a specific version of another dependency that is now a different version? Does your favorite feature no longer exist in this new version/platform? You still have problems.

When considering 'implementation details' you have to think both higher and lower than what the compiler, static code analysis, and OO principles tell you. There are practical implementation details like error messages, package dependencies, even whether or not a certain feature exists in one version/platform or another that might be behind your abstraction(s). These are just a few examples. The main point is, be thoughtful about EVERYTHING that constitutes an implementation detail, and make hiding those details, or at least deferring them until configuration (outside the code) a priority.

Some example solutions:

  • Error messages leaking? Wrap any and all exceptions and error messages in your own exceptions and error messages that are defined in your abstraction. Each implementation must map its internal messages into the ones supported by the abstraction. Of course, you can allow inner messages to be passed along for troubleshooting purposes, but the consumer code should never reference these!
  • Dependencies conflicting in your package manager? This isn't a problem that will manifest in every language, but in some, it could be a problem. Consider loading these secondary assemblies at runtime using configuration. Do this in your abstraction library. Any language complex enough to have this problem will also have this solution.
  • Switching database solutions and your new database doesn't have a feature your old database did? If your abstraction is a good one, you've modeled the behavior and not the specifics of the implementation, so you should be in luck. Just implement the feature yourself behind the abstraction. (I know, easier said than done, but, thanks to your great abstraction, doable!)
  • Something still feels irreconcilable in the code? Maybe implementing that missing feature is infeasible. You implemented runtime assembly loading but now you need to pick a version. You want to react to specific error messages but they aren't the same across versions. Your last refuge is always just to defer to configuration. You can always design version-specific implementations of things defined in your abstraction and then choose those implementations at runtime via configuration. When your app starts up you read in the configuration values and inject the necessary implementations or turn certain features on or off, you map certain error messages to certain values, types, or behaviors, etc.

Hidden Dependency

You are making a static reference (instantiation is a type of static reference) to something that is outside the scope of your software entity (function/class/package) that couples it to an external resource. Now your software entity is also coupled to that external resource. This causes problems:

  • Your signature/API does not communicate its dependencies. How will the consumer of this entity know they need this dependency? At best it will be a build time error, at worst, it's a runtime error. In either case, there's no guarantee the error message will be clear enough to easily resolve the issue. So now we're debugging line by line and trying to understand the inner workings of code we didn't write. Nobody enjoys this.
  • You want to test the code that consumes this entity. Let's say you already know you have this hidden dependency (if not see the bullet above). At best, you now have to set up this dependency in every test which adds weight during test writing and execution. At worst, this external resource behaves in a non-deterministic manner (it would be safe to assume most will) and now the test can fail for reasons other than those you are explicitly testing, which is the definition of 'flakiness'.
  • Whenever a change happens to the API of the external resource, now this entity will break. Troubleshooting the breaking change will be difficult for all of the reasons mentioned above. You may have numerous tests fail but all of them indirectly relate to this problem. How many places in your code did you couple yourself with this external resource? Multiply all of your problems by this factor. The product is the scope of the effort it will take to resolve them.

Design Solution:

Always expose your dependencies. Functions should take them as arguments. Objects should take them in their constructors. Packages are more difficult. When designing a package that you intend to be consumed by others, you should avoid taking dependencies on other packages. When you do you should be explicit about the level of cohesion. A package that wraps an API or third-party SDK will be tightly coupled to that resource by necessity. If you want to loosely couple your internal packages then you should have an intermediary package that exposes only abstractions of the API/SDK. These abstractions should be abstract enough to allow you to switch versions or even vendors without making changes on the consumer side of the abstraction. What remains then is to defer the choice of implementation to your configuration. This is the final piece that ensures consumers won't have to deploy new code in order to upgrade AND it's what actually exposes the dependency because now it is a part of the config API of your abstraction library.

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