Decoding Design Patterns: A Roadmap for Sensible Software

Editor’s Note: Man, I wrote this months ago, and forgot to publish it. Well, better late than never!

I’m on a bit of a kick about writing good software lately. I guess I always am, but the past few weeks in particular have me thinking about the fundamentals, and how sometimes being reminded of them — and thinking about them from a new lens — can be helpful. In that spirit, let’s talk about design patterns (a topic that has truly been beaten to death by blogs just like this) and let’s see if we can discover something new.

What are design patterns? Do I really need them?

To quote the Gang of Four (Gamma et al.): “Design patterns capture solutions that have developed and evolved over time.” Design patterns are just names that we’ve assigned to high-level, common ways to solve problems with code, codified (heh) over decades of trial and error. It’s more abstract than an algorithm, but still wraps some structure around solving problems.

Whether or not you need one or more design patterns depends on what you’re doing. If you’re doing something simple, and something you’re maintaining yourself, maybe not. If you’re doing something intricate and complex with a team, almost certainly yes. You can always get hit by Maslow’s hammer and over-use them, but they at least give shared language to complex ideas.

There are 23 canonical design patterns, so I’m not going to try to explain them all here. You don’t need to have them all memorized — I certainly don’t, and I don’t think most serious engineers do. I know them if you say the name, but I couldn’t list them all. If someone can, it would strike me as performative at best.

I will, however, go into the categories of design patterns. The GoF describe 3 in the book, and we’ll do those first.

Creational

Creational design patterns are all about creating objects. They focus on abstracting away the instantiation process, which allows for more flexibility and reusability of your code.

Some examples are the Singleton pattern, which enforces only having one (global) instance of a certain class, and the Factory Method pattern, which pushes the responsibility for instantiating an object from the class to a Factory class.

Structural

Structural design patterns are for managing how classes and objects are composed. A popular example is the Adapter pattern, which allows incompatible interfaces to work together, typically with some kind of wrapper. Another example is the Decorator patterns, which adds functionality to an object on-the-fly without altering its underlying structure.

Behavioral

Behavioral design patterns are concerned with the interactions between objects. Popular examples include the Iterator pattern, which enables traversal of a list of objects in a uniform way, and the Observer pattern, which allows objects to subscribe to events happening to another object.

So (when) should we use them?

I’ll use every engineer’s favorite cop-out answer: it depends. I promise, it really does this time. First, it depends on how big your project is — both in scope and in staff. If you’re working on a small proof-of-concept or personal tool by yourself, then do whatever you want.

If you’re working on a global-scale app with a team spread across 5 timezones, you’ll need a lot more design and scalability firepower than just these basic patterns. Most of us spend our time in-between.

I’m a big proponent of not optimizing too early. Unless you’re building something pretty well established or you’ve done a ton of market/user research (and even then), you probably won’t know which parts of your design actually need to scale at first. Your minimum viable product (MVP) should really be the minimum you need to have a viable product.

Some things will be obvious, for example if you’re building a service with significantly more subscribers than publishers, you know you need to optimize your database for writes. But especially when you’re first starting out building something, don’t waste too much engineering time optimizing things that you don’t know will need it up front.

This part is where people tend to get into trouble. We talk a lot about “technical debt” — the idea that decisions you made in the past come back to haunt you in the future, sometimes years later. This tends to happen when you make “field expedient” decisions about design and optimization early on, and those decisions don’t scale well later.

When that happens, engineers often roll their eyes, bemoan the previous engineer’s foolish/lazy/malicious/etc. decisions, curse their lot in life for taking on this technical debt, and trying to work around it. This is so commonplace that technical debt is considered a fundamental evil, and taking it on is considered by many to be a cardinal sin.

I argue that technical debt is not inherently bad; the true sin is not making timely payments on your technical debt. We don’t call people names for taking out a loan to buy a house, or a car, or to go to college. But if you miss too many house payments, your house gets foreclosed on.

What does it mean to make timely payments on your technical debt? We all probably know what it means to make untimely payments; you end up being the guy mad at the previous engineer for leaving you with a mess. But making timely payments just means updating/refactoring your design when it makes sense. The contract you make when you decide not to optimize too early, means you need to optimize on time.

Let’s do a specific (but contrived) example. Imaging you’re developing a mobile app that allows users to share short video clips. I’m sure you can think of a product like that if you try hard enough. At first, you can have a simple design where you store all the videos on your server, and serve the whole file on request to the user. This is a straightforward approach that’s quick to build, so you can get your first tranche of users fast.

But as the app’s popularity grows, you know you’re going to have a few problems. Storage costs will balloon, and the app will start to get slow. As you start getting users internationally, latency between your server and your users starts to matter more.

You can solve these issues by (for example) adopting a cloud storage solution with a CDN. That’s making a payment on your technical debt. As your app grows more, users will get upset they have to wait a long time between videos for the next one to load, so you start caching the next video in their queue on their device while the current video is playing. That’s another payment.

But you might be saying to yourself: “I’ll be making payments like that for the lifetime of my app then!” Yep. Welcome to software engineering. Sorry!

If you have thoughts, or you think I’m wrong and just have to tell me, email me!