🤸

3.1. Flexibility - the most important property of code

If we were to identify the single most important property that your code should have, it would be "flexibility" - how easily your code and architecture can adapt when new requirements arise that don't fit into the original structure.
 
In this article with examples, I'll explain how to achieve code and architecture flexibility:

Project Lifecycle

Any project has 4 phases: creation, development, stasis, and death.
 
Let's set aside the death phase of the project, because during development we shouldn't be thinking about this phase.
 
Stasis (when we've developed the project and no longer touch it) is also not a common situation, and if you know for sure that stasis will occur, you can write this project however you want.
 
The truly important phases we need to focus on are the creation and development of the project.

Why do I describe creation and development separately?

During project creation, you take all the knowledge available, can design the system, write it, and it will work.
 
But during product development, in 70% of cases, requirements will arise that weren't built into the original architecture during creation, and you'll have to incorporate them somehow.
 
So, flexibility is how quickly your code and architecture can change to add requirements that weren't originally planned.
 
And flexibility is THE most important criterion for code quality.
 
If you quickly created a system that then requires days of refactoring to add a new feature NOT meeting the original requirements, then your system is low quality (inflexible).
 

But how can we achieve flexibility?

First, we need to understand: what exactly creates difficulties during project development?
 
There are concepts like Natural Complexity and Artificial Complexity.
 
Natural Complexity - the complexity of solving a problem through programming, dictated by external conditions: programming language specifics, database/protocol capabilities, network speeds, OS architecture, processor power, electron movement speed through wires and transistors, etc.
 
In short, everything dictated by external conditions that we can't easily control.
 
Artificial Complexity - complexity that we ourselves added to our system: division into microservices or business logic models that ultimately call each other in a cyclic graph for a single request; layers of abstraction to wade through to add one feature; side-effects that arise in code due to library magic using name conventions or self-defined hooks, etc.
 
If we dig deeper into "Artificial Complexity," we'll understand that it's all based on creating excessive abstractions and boundaries.
 
Accordingly, Natural complexity is very difficult/impossible to eliminate and will add problems to the project, but:
 
"Artificial complexity" can be eliminated if we abandon abstractions, groupings, and non-obvious behavior.
 

How to practically "abandon abstractions, groupings, and non-obvious behavior"

Let me give you examples of what you should or shouldn't do:
 
  • Don't try to abstract away from infrastructure without rock-solid reasons. If you're using a specific library/DB/MQ/etc., don't try to create additional interfaces on top of them; use them in the code where you need them.
// # Instead of creating abstract repositories const changeUserEmail = async (userRepostoty: UserRepository, req: { email: string }) => { const user = await userRepository.getByEmail(req.email) // ... } // # Use the adapter call directly in the code // or the query language itself (e.g., SQL) const registerUser = async (pg: PgConnection, req: { email: string }) => { const user = await pg.table(UserTableName).where({ email: req.email }) // ... }
 
Repository abstractions might only be needed if you 100% need to swap implementation infrastructure (for example, you're developing self-hosted software that can work with different DBs/MQs/etc.). In other cases, don't complicate your life by creating them.
 
  • Models should look exactly as they are stored in the DB. If a model doesn't contain another model (e.g., as a PostgreSQL jsonb field or MongoDB nested document), don't nest them in the structure description (just add the exact fields present in the DB), as is common in many ORMs.Describe all entities exactly as they appear in the DB.
    • // # If you have PostgreSQL, for example, instead of: type Profile = { id: string user: User } // # write it exactly as in the DB type Profile = { id: string user_id: string // Because it's not a whole user as jsonb, but just a reference to the table }
  • Don't use additional dependency management mechanics. To pass dependencies in code, you have function arguments/class constructor; use them without additional magic like IoC.
  • Minimize side-effects and non-obvious behavior. For example, there are features like preSave, postInsert, preUpdate hooks - everything that happens inside them is completely non-obvious to a developer who hasn't read their contents.This will 100% sooner or later lead to very difficult-to-track bugs and problems in setting up new developers. It would be enough to simply create functions that handle creating/changing the model and call them, which would allow you to clearly see what happens when this function is called.
  • Separate everything completely. This is a complex thought, but consider: if you divide your code into UserController and ProfileController, and then a method appears that relates to both, where will you put it?All subsequent logic will depend heavily on this decision.It's worse when this happens with three entities at once. Therefore, instead of trying to divide methods into groupings, write each method separately from each other, like: UpdateUserData, SetEmailOnProfile, DeleteUserAndProfile, and so on.
// # Instead of const ProfileController = { setEmail: (req: Request) => {}, deleteUserAndProfile: (req: Request) => {}, } // # Separate into individual handlers const SetProfileEmail = (req: Request) => {} const DeleteUserAndProfile = (req: Request) => {}
This pattern in OOP is also called Service Object
 
There could be many such examples; the formula for independently discovering opportunities to become more flexible:
 
"Where am I creating abstractions with my code, grouping logic, or executing code without explicitly declaring it?" - find these places and get rid of them.
 
This way, your project will have a minimal amount of Artificial Complexity, and you'll only face Natural Complexity problems.

What's next?

Next, we'll discuss where any system begins:
 
3.2. Process First Design
 

👈 Previous chapter
2.5. That's why (λ) FOP appeared
Next chapter 👉
3.2. Process First Design