I know my way around a React application or a REST API but when it comes to writing domain logic there is nothing to hold your hand anymore. No framework comes with a guide on how to implement the specifics of the business.
Resources on structure and design are usually implemented in context so you need imagination and creativity to turn them into more generic rules.
Each time I find myself navigating an implementation without known patterns to help me, I rely on some fundamental programming principles. In chess, if you follow the basic ideas you will naturally end up in good positions. The same is true in software.
Like chess players put their knights out before the bishops, here are the software development principles that I follow.
Actively Reduce Complexity
Complexity is cited as the root of all problems in software and I write each line with the intent of keeping it to manageable levels. Complex code takes more time to understand and more effort to maintain. That makes it a potential hotbed for bugs that hide in the small misunderstandings and in between layers of abstraction.
The best way to deal with complexity is not to let it grow in the first place. That is easier said than done, though. We’re bombarded with information about design patterns and best practices that don’t take maintenance into account.
I’m always in favor of simpler code, even if that means a certain amount of repetition. Repetitive code is annoying but it’s not complex. Only when the abstraction is painfully obvious do I decide to create it. It’s easier to manage duplication than a wrong abstraction.
I prefer simpler implementations that read like prose instead of masterfully crafted software poems that you can’t even hope to change.
Because of that, I’ve put OOP’s hierarchies and functional programming’s monads aside to focus on simpler constructs. Nowadays most of the code I write is made up of functions and objects.
Factory Functions
Most applications work with representations of certain domain entities or models as we are used to calling them. Some are more generic, like a user model. Others like an article or an invoice are specific to the business.
Historically, these models are often implemented with classes since they’re a good way to hide complexity. But I don’t think that OOP’s hierarchical model of thinking fits into every problem and I try to avoid it.
I use factory functions to create complex objects because I find closures easier to understand. Plus, I avoid any potential problems with the this
keyword in JavaScript.
Classes tend to be longer, have more boilerplate, and encourage inheritance which can impact your design in bad ways. Inheritance can be a good model for specific problems but not every application can be modeled hierarchically.
Especially in the age of microservices where each application holds but a fraction of the domain, I rarely find the need to implement something with inheritance.
Instead, I resort to composition when I want to reuse logic across different entities. This is a flatter mental model and helps me understand the capabilities of an entity without jumping from class to class.
I always use an object as a single parameter to these functions because they tend to accept a lot of parameters. To avoid bloating them with a large number of methods, I only attach to them the functions that touch the internals, the private values of that entity.
For every other operation, I implement a simple function that accepts that entity and works with its public values.
Focus on Data Structures
One of the greatest sources of complexity is the data structures that we use. Data is the backbone of every application and mismanaging it can have disastrous effects.
If you use an array where a map would have done a better job, you’d have to compensate for the missing functionality in your data structure with more complex logic.
I wrote about this problem in a previous article. Changing the data structure was the key solution to solving a problem related to running multiple asynchronous functions that depend on one another. The effort to get a graph solution working would be enormous compared to just using a tree.
Data structures’ complexity is hard to fully isolate and it tends to leak throughout the application. After learning this I became watchful about such decisions. When I was building a turn-based card game, I spent days researching the proper data structure to implement the game engine.
I had limited research time which I spent mostly on that and it paid off. I ended up using a state machine to hold the game’s state and modify it. The idea of having predetermined states and actions that change from one to the other was a great fit with the nature of turn-based games.
The choice of data structure can be the single most impactful decision for the application’s design.
Application Structure
Structure helps the reader make sense of things. Even though we focus mostly on the design and APIs of modules and functions, the way they are organized in an application is just as important. A glance at the folders and their contents should give the developer a high-level idea of the application’s purpose.
I’m not a fan of grouping things by their technical responsibilities. I believe that functions and modules which work together should be kept together. That creates a natural application structure that mirrors the dependencies in your code.
So a look at the codebase can tell you how modules in your application interact with one another.
Responsibilities and Interfaces
Despite my dislike for OOP, I’m still a fan of the SOLID principles on an abstract level. They have summarized a great amount of wisdom and can serve as guidance when we’re doubtful about a design. I think they are more helpful when it comes to structure than arbitrary rules about clean code.
The SOLID principles shouldn’t be followed dogmatically, though.
The single responsibility principle is something I take to heart. Making a function or a module do more than it should, adds more to the complexity scale and that is something we want to avoid. Simplicity should always take precedence.
But far too often, length is mistaken for responsibility. I’d rather have a lengthy function that completely does a single operation than break it up and end up with badly designed smaller functions.
You shouldn’t break a function up based on your standards of beauty.
Functions should be split naturally, not only because of length. It’s easier to follow a focused but lengthy function than to jump around through multiple ones. If you don’t break off a logically complete operation you may not be able to think of a good name for it as well.
The other SOLID principle that I follow is the dependency inversion one. According to it, we should depend on abstractions not on concretions. In OOP this means that instead of requiring a parameter of a specific type, we should require one that implements a certain interface.
At the price of a little bit more verbosity, this decouples our codebase and makes it more testable.
The way I apply this principle is in combination with dependency injection. Instead of relying on a concrete library or object in a function, accept it as a parameter and use an interface to specify what contract it should follow.
It helps you create layers in your application, decoupling transport, domain, and data logic. I apply this principle mostly to decouple my domain logic from the other layers.
But I’m perfectly fine with the business logic and entities being more dominant in the codebase and having tighter coupling.
Simple Is Not Easy
A focus on simplicity should not be mistaken for low-effort coding. Making a simple implementation takes a lot more energy and focus than a complex one. We are predisposed to over-engineering because of the belief that power rises together with complexity.
I focus on simplicity because you can always make things more complex. That’s not true the other way around. Simplicity keeps your options open.
Software design should help you achieve a goal. And from all the programming wisdom we have, I think simplicity is the best goal to strive for.
You never play the same game of chess twice and you will never work on two identical codebases. Yet we follow the principles because they work - knights before bishops, control the center, take your king to safety.
As a software engineer, you can go far with functions, objects, and a focus on simplicity.