📜 ⬆️ ⬇️

Code architecture

In this article I want to share my personal experience related to the proper organization of code (architecture). Proper architecture greatly simplifies long-term support.
This is a very philosophical topic, so I can offer nothing more than my subjective analysis and experience.

Problems, symptoms


My initial experience as a programmer was rather cloudless — I rivet business cards without any problems. I wrote the code, as I now call it “in line” or “web”. On small volumes and simple tasks, everything was fine.

But I changed jobs, and I had to develop one single website for 4 years. Naturally, the complexity of this code was incomparable with the business cards from my previous work. At some point, the problems just fell on me - the amount of regression went off scale. There was a feeling that I was just walking in a circle - while I was repairing “here”, I broke something “there”. And the poet is “here” and “there” tritely changed places and the circle was repeated.
')
I lost the confidence that I was in control of the situation - with all my desire to prevent the bugs, they slipped. All these 4 years, the project has been actively developed - we improved the existing functionality, expanded, completed it. I saw and felt how the specific cost of each new refactoring / revision grows - the total amount of code increased, and the costs of any revision increased accordingly. Trite, I went to the threshold, through which I could not step over, continuing to write the code “in line”, without using architecture. But at that moment, I did not understand this yet.

Another important symptom was books and video tutorials that I was reading / watching at the time. The code from these sources looked “glossy” beautiful, natural and intuitive. Seeing such a difference between textbooks and real life, my first reaction was the idea that this is normal - in life it is always more difficult than in theory, there is more routine and specifics.

Nevertheless, the product at work needed to be expanded, improved, in general, to move on. At that very moment, I began to actively participate in one open source project. And in the aggregate, these factors pushed me onto the path of architectural thinking.

What is architecture?


One of my university lecturers used the phrase “must be designed to maximize the number of objects and minimize the number of connections between them”. The longer I live, the more I agree with him. If you look at this quotation, it is clear that these 2 conditions are mutually exclusive to some extent - the more we split some system into subsystems, the more connections we have to introduce between them in order to “connect” each of the subsystems with the rest of the actors. Finding the optimal balance between the first and second is a kind of art that, like other arts, can be mastered through practice.

A complex system is split into subsystems at the expense of interfaces. In order to select a subsystem from a complex system, you need to define an interface that will declare the boundaries between the first and second. Imagine, we had a complex system, and it seems that some subsystems are tangible inside it, but they are “smeared” in different places of the main system and there is no clear format (interface) of interaction between them:

image

We calculate, de facto, we have 1 system and 0 connections. With the minimization of connections, everything is fine :) But the number of systems is very small.

And now, someone has done the code analysis, and clearly identified 2 subsystems, defined the interfaces on which the communication is conducted. This means that the boundaries of the subsystems are defined and the scheme is as follows:



Here we have: 3 systems and 2 connections between them. Please note that the amount of functionality remains the same - the architecture neither increases nor decreases functionality, it is just a way to organize code.

What is the difference between the two alternatives? When refactoring in the first case, we need to “comb” 100% of the code (the whole green square) to make sure that we have not made any regression. With the same refactoring in the second case, we first need to determine which system it belongs to. And then our entire refactoring will be reduced to combing only one of the 3 systems. The task is simplified in the second case! Due to the successful fragmentation of architecture, it is enough for us to concentrate only on a part of the code, and not on all 100% of the code.

This example shows why it is profitable to split into the maximum number of objects. But there is also the second part of the quote - minimizing the connections between them. But what if the new revision that came to us from the authorities affects the interface itself (the red bridge between the 2 systems)? Then things are bad - changes in the interface imply changes at both ends of this bridge. And just the less we have connections between systems, the less likely it is that our refactoring will affect any interface at all. And the simpler each of the interfaces, the easier it will be to make the necessary changes on both sides of the interface.

Interface in the broadest sense


I consider the interface to be the key to the correct application of architecture, because it defines the format of interaction and, accordingly, the boundaries of each system. In other words, the number of subsystems and their connectedness (the number of connections) depend on the selected interfaces. Consider it closer.

First of all, he must be honest . There should be no communication between systems outside the interface. Otherwise, we will slide to the original version - diffusion (yes, it is also in programming!) Will merge 2 systems back into one common system.
The interface must be complete . An actor on one side of the interface should have no idea about the internal structure of the actor on the other side of the bridge - no more than what the interface on which they interact, i.e. the interface should describe the partner on the “other side of the bridge” in a full (sufficient for our needs) manner. By making the interface complete initially, we significantly reduce the chances of having to edit the interface in the future — remember, making changes to the interface is the most expensive operation, because it implies changes in more than one subsystem.

The interface does not have to be declared as an interface from the OOP. I believe that there is enough honesty, completeness of the interface and your clear understanding of this interface. Moreover, the interface such as I mean it in the framework of this article is something wider than the interface from the PLO. What matters is not the form, but the essence.

It will be appropriate to mention the architecture of microservices. The boundaries between each of the services are nothing more than an interface, which I describe in this article.
As an example, I want to give a file usage count in inode on * nix (file reference count): there is an interface - if you use a file, then increase its counter by 1. When you’ve finished using it, decrease its counter by 1. When the counter equals zero , it means that no one uses this file, and it needs to be deleted. Such an interface is indescribably flexible, since He does not impose absolutely no restrictions on the internal structure of the actor who can use it. Both the use of files within the file system and the file descriptor from the executable programs organically fit into this interface.

Solve the problem on an abstract rather than a specific level.


Obviously, the ability to choose the right interface is a very important skill. My experience tells me that very often a good interface comes to mind when you try to solve a problem at an abstract (general) level, rather than the current (concrete) manifestation of it. Albert Einstein once said that the correct formulation of a problem is more important than its solution. In this light, I completely agree with him.

What solution of the task “open the front door” seems to you more correct?

  1. Go to the door;
  2. Take a bunch of keys from your pocket;
  3. Select the desired key;
  4. Open the door for them.

Or:

  1. Go to the door;
  2. Call the “keystore” subsystem and get an accessible keychain from it;
  3. Call the subsystem “search for the right key” and request from it the most suitable key to the current door from the bunch of available keys;
  4. Open the door with the key offered by the subsystem of finding the right key.

The abstractness of the second algorithm is several times higher than the first, and as a result, its completeness is also higher. Tritely, the second algorithm is much more likely to remain relevant even 50 years later, when the concept of “keys” and “doors” will differ from today's :)

Looking at the problem from an abstract point of view, full interfaces naturally come to our mind. After all, solving a particular manifestation of a problem, the maximum that we can come up with in terms of an interface is just its private projection onto our particular problem. Looking at an abstract problem, we are more likely to see the full interface, and not its manifestation for some specificity.

At some point, you begin to see these abstract operations for their specific manifestations (implementations). This is already great! But do not forget that you need to minimize the number of connections - this means that there is a risk of getting too far into the wilds of abstraction. It is absolutely not necessary to include in your architecture all the abstractions that you see in the analysis. Include only those that justify their presence by adding additional flexibility or by crushing an overly complex system into subsystems.

Physics


There is such a science, and I love it on a par with programming. In physics, many phenomena can be viewed at different levels of abstraction. The collision of two objects can be considered as Newton's dynamics, but can be considered as quantum mechanics. Air pressure in a balloon can be considered as micro and macro thermodynamics. Probably, physicists came to this model for good reason.

The fact is that the use of different levels of detail in the architecture of the code is also very beneficial. Any subsystem can be recursively split further into sub-subsystems. The subsystem will become a system, and we will search for subsystems in it. This is a divide and conquer approach. Thus, a program of any complexity can be explained at a convenient interlocutor's level of detail in 5 minutes over a beer by a friend to a programmer or by a chief netechnary at a corporate meeting.

As an example, what happens in our laptop when we turn on a movie? Everything can be viewed at the media player level (we read the contents of the film, decode it into video, show it on the monitor). It can be viewed at the operating system level (read from the block device, copy to the necessary memory pages, “wake up” the player process and run it on one of the cores), but you can also at the disk driver level (i / o to optimize the queue for the device, scroll to the desired sector, read the data). By the way, in the case of an SSD disk, the last list of steps would be different - and that's the beauty, because in operating systems there is an interface of a block storage device, we can poke out a magnetic disk, stick a USB flash drive and we will not notice much of a difference. Moreover, the interface of the block device was invented long before the appearance of CDs, flash drives and many other modern storage media - that this is not an example of a successful abstract interface that has lived and remained relevant for a single generation of devices? Of course, someone might argue that the process was reversed - new devices were forced to adapt to an existing interface. But if the interface of the block device were frankly bad and inconvenient, it would not stand on the market and be absorbed by some other alternative.

The human brain cannot hold many concepts / objects in one's head at the same time, regardless of whether we are talking about physics or about programming or something else. Accordingly, try to organize the vertical hierarchy of your architecture so that you have no more than a dozen actors at any level of abstraction. Compare two descriptions of the same system:
We process incoming orders here. First, there is a process of validation - we check the availability of the ordered goods in the warehouse, check the correctness of the delivery address, the success of the payment. Then the notification process starts - the operator receives an SMS with information about the new order. The head of the department receives an email with summary information.
Or:

We process incoming orders here. First, the validation system works out - we check the accuracy and correctness of all data. Well, in principle, if you are interested, we have internal validation (availability in stock and so on) and external (correctness of the information specified in the order). If the validation is successfully completed, the notification system is launched — this link will provide complete information about the notifications.
Do you feel the vertical orientation of the second description compared to the first? Additionally, the second description highlights the abstractions “validation” and “notification” more vividly than the first.

How do people usually fly to the moon?


Right! They first design a rocket (a rocket, as a whole, and separately each of its components). Then they build a plant for the production of each component and a plant for the final assembly of the rocket from the produced components. And then they fly to the moon on the collected rocket. Feels like a parallel?

The output is a huge number of components that can be reused for other related purposes. And then there are the factories that these components massively produce. And the success of the entire enterprise is most dependent on successful design (when they forgot to make a module for oxygen regeneration in the project, and the rocket is already on the launch pad, things are bad), a little less on the quality of the plants built (the plants can somehow be calibrated and tested ) and least of all from a specific instance of a rocket that is on the launcher - if something happens to it, it will be easy to re-create it on the basis of the existing infrastructure. Soon we will learn to clone people, and then even with unsuccessful launches there will be no talk about human losses :)

In programming, everything is exactly the same. On the shoulders of iron falls the role of factories - to execute our code. But the role of designing (creating architecture) and specific implementations (building plants) falls on the programmer’s shoulders. Very rarely, these 2 stages somehow clearly stand out from the general coil. And to think about these 2 stages separately is very useful, moreover, in other areas it even looks illogical. After all, who will immediately build a plant, without first deciding what the plant will produce?

Architecture benefits


I here only summarize the concepts that I tried to describe above. With successful use of architecture, we have:


Signs of a successful architecture


The success of the architecture can not be assessed unambiguously "yes" or "no." Moreover, the same architecture can be successful in one project (specification) and failure in another project, even if both projects are nominally operated in the same subject area. At the time of design, you are required to have a deep and comprehensive understanding of the process that you automate / model with code.

Nevertheless, I dare to suggest some common features of successful architectures:




Tips for building a successful architecture


Try to ask 3 questions when analyzing the architecture task: What are we doing? Why do we do this? How do we do it? The interface is responsible for “what?”, For example, “we notify the user about the event”. The consumer is responsible for “what for?” - the code that calls the subsystem, and the specific implementation of the interface (service provider) answers the question “how”.

Try to arrange any self-sufficient operation in the form of a subroutine (function, method, or something else, depending on the tools available to you). Even if this is just one line of code, and it is used once in your program. So you separate the architectural code (list of abstract actions) from the implementation. In such a context, this function acts as an interface, and we immediately receive the consumer (calls the function) and the supplier (the implementation of the function). Example:

function process_object($object) { $object->data['special'] = TRUE; $object->save(); send_notifications($object); } 

or

 function process_object($object) { $object->markAsSpecial(); $object->save(); send_notifications($object); } 

Use more levels of detail in your architecture. With intensive “vertical” crushing, you will have a wide choice of components of different caliber. When you start solving another task within such a project, you will have the choice to either use some kind of high-level system (quickly, possibly at the expense of solution flexibility), or to “add” a solution from a low-level component that more accurately falls under the business need. Naturally, if possible, you will prefer high-level components, but you will always have the freedom to assemble some critical section of lower-level components. For example, you may have a high-level component “notify the user about the event.” She, on the basis of the settings in the user profile, selects a long or short version of the notification and sends it either by SMS or by mail. Such a high-level component uses 2 lower-level ones: “send a text message to the X number with Y content” and “send an email to the X address with Y content”. The next time you need to notify the user about any event, most likely you will use the high-level component. But you still have the option to send text messages and letters to bypass the high-level component using the low-level layer directly - let's say this can be useful for you with a critical notification — it would be better to send this directly to your phone by circumventing the user's settings due to the criticality of the situation. The more levels of detail you highlight, the more you will have that freedom. This is like an atomic bomb and a point airstrike — sometimes it is more convenient to bomb the heights of the mainland, and sometimes it is more convenient to strike 10 point strikes against strategic targets. It's easier with a bomb (a higher level of abstraction is to just poke a finger on the mainland you need), with an airstrike more hassle (you need to highlight these 10 strategic objects), but having a choice of two alternatives is always better than a single option.

Your imagination works many times faster than your fingers - validate and “try on” the architecture on a piece of paper before you begin to implement it in code. It will be a shame to understand after 5 hours of coding that the interfaces you have invented do not cover the needs of the subject area, and you could have foreseen this problem by spending 20 minutes analyzing the architecture and “checking” the architecture on paper. At some moments I spend a full day sitting and looking at the sky - thinking up and running around architecture on paper.

Do not overload your interfaces. In pursuit of the completeness of the interface, we can include redundant elements in it, but here we can inadvertently spoil the porridge with oil. The more elements the interface includes, the less freedom it leaves to the one who will implement it. Just do not forget that it is possible, at some point you will need to change this interface in the light of some new business tasks. The simpler the interface, the easier it is to change it and the actors on both sides of this interface.

It may sound paradoxical, but an overloaded interface will be less complete than a perfectly load balanced interface. Unnecessary details narrow the interface, but do not expand it, because some details lose their physical meaning in some other context. For example, we could “overdo it” and, in our system for notifying a user of any event, introduce the concept of a time zone: “notify a user of an event with or without his time zone”. In some context, this will be the right interface, and in some wrong. Suppose the users of our system begin to live on the moon and there is no concept of a “time zone” in the sense in which earthlings are accustomed to it. Then this additional load in the interface will be redundant and will act to the detriment of the entire architecture.

Don't forget about performance and scalability at the time of architecture design. Ideally, the interfaces should be as simple as possible - let's say a couple of functions that allow you to change and delete an entity from the repository. By packaging only 2 functions into the interface, we get a high level of abstraction - we can use a relational database and NoSQL for physical data storage. But if there are thousands of such entities, it becomes obvious that they need to be manipulated at the DBMS level, not the application. Then you need to consciously include in the interface the database structure where these entities are stored. Otherwise, the interface will be beautiful, but incomplete , because, taking into account the performance requirements, the full interface should provide a fast and efficient tool for mass interaction with entities.

Creation of architecture


The ability to correctly understand the subject area, to identify successful interfaces, I refer to the art. In my personal case, I learned this craft through the practice and contemplation of the architects of other authors, always passing the investigated architecture through the prism of my own critical thinking.

The next time you need to solve a relatively large task, move away from the computer and sit with a piece of paper for an hour. In the beginning, perhaps no thoughts will go to your head, but you honestly keep thinking about the problem and the abstractions / interfaces that can be hidden inside the problem. Do not be distracted - the immersion depth and concentration are very important, so that you can think out and put together all the actors and their connections in your imagination as precisely as possible.

When you see someone else's (or your own, but some time previously implemented) architecture, and you need to make changes to the code, try to analyze whether it is convenient to make these changes when the architecture is flowing, whether it is flexible enough. What can be improved in it?

Upd .: I wrote this article when I was preparing to speak at one conference. Video can be viewed here - meduza.carnet.hr/index.php/media/watch/12326

Source: https://habr.com/ru/post/353698/


All Articles