System
The general desirable characteristics of the system are:- minimal complexity - overly complicated projects should be avoided. The main thing is simplicity and clarity (best = simple);
- ease of maintenance - when creating an application, you must remember that it will need to be supported (even if it is not you), so the code should be clear and obvious;
- weak coupling is the minimum number of connections between different parts of the program (maximum use of OOP principles);
- reusability - designing a system with the ability to reuse its fragments in other applications;
- portability - the system must be easily adapted to another environment;
- single style - designing a system in a single style in its different fragments;
- extensibility (scalability) - improving the system without disturbing its basic structure (if you add or change a fragment, this should not affect the rest).
System design stages
- Software system - designing an application in general form.
- Separation into subsystems/packages - defining logically separable parts and defining the rules of interaction between them.
- Dividing subsystems into classes - dividing parts of the system into specific classes and interfaces, as well as defining the interaction between them.
- Dividing classes into methods is a complete definition of the necessary methods for a class, based on the task of this class. Method design - detailed definition of the functionality of individual methods.
Main principles and concepts of system design
Lazy initialization idiom An application does not spend time creating an object until it is used, which speeds up the initialization process and reduces garbage collector load. But you shouldn’t go too far with this, as this can lead to a violation of modularity. It might be worth moving all the design steps to a specific part, for example, main, or to a class that works like a factory . One of the aspects of good code is the absence of frequently repeated, boilerplate code. As a rule, such code is placed in a separate class so that it can be called at the right time. AOP Separately, I would like to mention aspect-oriented programming . This is programming by introducing end-to-end logic, that is, repeating code is put into classes - aspects, and called when certain conditions are reached. For example, when accessing a method with a certain name or accessing a variable of a certain type. Sometimes aspects can be confusing, since it is not immediately clear where the code is called from, but nevertheless, this is a very useful functionality. In particular, when caching or logging: we add this functionality without adding additional logic to regular classes. You can read more about OAP here . 4 Rules for Designing Simple Architecture According to Kent Beck- Expressiveness - the need for a clearly expressed purpose of the class, is achieved through correct naming, small size and adherence to the principle of single responsibility (we'll look at it in more detail below).
- A minimum of classes and methods - in your desire to break classes into as small and unidirectional as possible, you can go too far (antipattern - shotgunning). This principle calls for keeping the system compact and not going too far, creating a class for every sneeze.
- Lack of duplication - extra code that confuses is a sign of poor system design and is moved to a separate place.
- Execution of all tests - a system that has passed all tests is controlled, since any change can lead to a failure of the tests, which can show us that a change in the internal logic of the method also led to a change in the expected behavior.
Interface
Perhaps one of the most important stages of creating an adequate class is creating an adequate interface that will represent a good abstraction that hides the implementation details of the class, and at the same time will represent a group of methods that are clearly consistent with each other. Let's take a closer look at one of the SOLID principles - interface segregation : clients (classes) should not implement unnecessary methods that they will not use. That is, if we are talking about building interfaces with a minimum number of methods that are aimed at performing the only task of this interface (as for me, it is very similar to single responsibility ), it is better to create a couple of smaller ones instead of one bloated interface. Fortunately, a class can implement more than one interface, as is the case with inheritance. You also need to remember about the correct naming of interfaces: the name should reflect its task as accurately as possible. And, of course, the shorter it is, the less confusion it will cause. It is at the interface level that comments for documentation are usually written , which, in turn, help us describe in detail what the method should do, what arguments it takes and what it will return.Class
Let's look at the internal organization of classes. Or rather, some views and rules that should be followed when constructing classes. Typically, a class should start with a list of variables, arranged in a specific order:- public static constants;
- private static constants;
- private instance variables.
Class size
Now I would like to talk about class size. Let's remember one of the principles of SOLID - single responsibility . Single responsibility - the principle of single responsibility. It states that each object has only one goal (responsibility), and the logic of all its methods is aimed at ensuring it. That is, based on this, we should avoid large, bloated classes (which by their nature is an antipattern - “divine object”), and if we have a lot of methods of diverse, heterogeneous logic in a class, we need to think about breaking it into a couple of logical parts (classes). This, in turn, will improve the readability of the code, since we don't need much time to understand the purpose of a method if we know the approximate purpose of a given class. You also need to keep an eye on the class name : it should reflect the logic it contains. Let's say, if we have a class whose name has 20+ words, we need to think about refactoring. Every self-respecting class should not have such a large number of internal variables. In fact, each method works with one of them or several, which causes greater coupling within the class (which is exactly what it should be, since the class should be as a single whole). As a result, increasing the coherence of a class leads to a decrease in it as such, and, of course, our number of classes increases. For some, this is annoying; they need to go to class more to see how a specific large task works. Among other things, each class is a small module that should be minimally connected to the others. This isolation reduces the number of changes we need to make when adding additional logic to a class.Objects
Encapsulation
Here we will first of all talk about one of the principles of OOP - encapsulation . So, hiding the implementation does not come down to creating a method layer between variables (thoughtlessly restricting access through single methods, getters and setters, which is not good, since the whole point of encapsulation is lost). Hiding access is aimed at forming abstractions, that is, the class provides common concrete methods through which we work with our data. But the user does not need to know exactly how we work with this data - it works, and that’s fine.Law of Demeter
You can also consider the Law of Demeter: it is a small set of rules that helps manage complexity at the class and method level. So, let's assume that we have an objectCar
and it has a method - move(Object arg1, Object arg2)
. According to the Law of Demeter, this method is limited to calling:
- methods of the object itself
Car
(in other words, this); - methods of objects created in
move
; - methods of passed objects as arguments -
arg1
,arg2
; - methods of internal objects
Car
(the same this).
Data structure
A data structure is a collection of related elements. When considering an object as a data structure, it is a set of data elements that are processed by methods, the existence of which is implied implicitly. That is, it is an object whose purpose is to store and operate (process) stored data. The key difference from a regular object is that an object is a set of methods that operate on data elements whose existence is implied. Do you understand? In a regular object, the main aspect is the methods, and internal variables are aimed at their correct operation, but in a data structure it’s the other way around: methods support and help work with stored elements, which are the main ones here. One type of data structure is Data Transfer Object (DTO) . This is a class with public variables and no methods (or only read/write methods) that pass data when working with databases, work with parsing messages from sockets, etc. Typically, data in such objects is not stored for a long time and is converted almost immediately into the entity with which our application works. An entity, in turn, is also a data structure, but its purpose is to participate in business logic at different levels of the application, while the DTO is to transport data to/from the application. Example DTO:@Setter
@Getter
@NoArgsConstructor
public class UserDto {
private long id;
private String firstName;
private String lastName;
private String email;
private String password;
}
Everything seems clear, but here we learn about the existence of hybrids. Hybrids are objects that contain methods to handle important logic and store internal elements and access methods (get/set) to them. Such objects are messy and make it difficult to add new methods. You should not use them, since it is not clear what they are intended for - storing elements or performing some kind of logic. You can read about possible types of objects here .
Principles of creating variables
Let's think a little about variables, or rather, think about what the principles for creating them might be:- Ideally, you should declare and initialize a variable immediately before using it (rather than creating it and forgetting about it).
- Whenever possible, declare variables as final to prevent their value from changing after initialization.
- Do not forget about counter variables (usually we use them in some kind of loop
for
, that is, we must not forget to reset them, otherwise it can break our entire logic). - You should try to initialize variables in the constructor.
- If there is a choice between using an object with or without a reference (
new SomeObject()
), choose without ( ), since this object, once used, will be deleted during the next garbage collection and will not waste resources. - Make the lifetime of variables as short as possible (the distance between the creation of a variable and the last access).
- Initialize variables used in a loop immediately before the loop, rather than at the beginning of the method containing the loop.
- Always start with the most limited scope and expand it only if necessary (you should try to make the variable as local as possible).
- Use each variable for only one purpose.
- Avoid variables with hidden meanings (the variable is torn between two tasks, which means its type is not suitable for solving one of them).
Methods
Let's move directly to the implementation of our logic, namely, to the methods.-
The first rule is compactness. Ideally, one method should not exceed 20 lines, so if, say, a public method “swells” significantly, you need to think about moving the separated logic into private methods.
-
The second rule is that blocks in commands
if
,else
,while
and so on should not be highly nested: this significantly reduces the readability of the code. Ideally, nesting should be no more than two blocks{}
.It is also advisable to make the code in these blocks compact and simple.
-
The third rule is that a method must perform only one operation. That is, if a method performs complex, varied logic, we divide it into submethods. As a result, the method itself will be a facade, the purpose of which is to call all other operations in the correct order.
But what if the operation seems too simple to create a separate method? Yes, sometimes it may seem like shooting sparrows out of a cannon, but small methods provide a number of benefits:
- easier code reading;
- methods tend to become more complex over the course of development, and if the method was initially simple, complicating its functionality will be a little easier;
- hiding implementation details;
- facilitating code reuse;
- higher code reliability.
-
The downward rule is that the code should be read from top to bottom: the lower, the greater the depth of logic, and vice versa, the higher, the more abstract the methods. For example, switch commands are quite uncompact and undesirable, but if you can’t do without using a switch, you should try to move it as low as possible, into the lowest-level methods.
-
Method arguments - how many are ideal? Ideally, there are none at all)) But does that really happen? However, you should try to have as few of them as possible, because the fewer there are, the easier it is to use this method and the easier it is to test it. If in doubt, try to guess all scenarios for using a method with a large number of input arguments.
-
Separately, I would like to highlight methods that have a boolean flag as an input argument , since this naturally implies that this method implements more than one operation (if true then one, false - another). As I wrote above, this is not good and should be avoided if possible.
-
If a method has a large number of incoming arguments (the extreme value is 7, but you should think about it after 2-3), you need to group some arguments in a separate object.
-
If there are several similar methods (overloaded) , then similar parameters must be passed in the same order: this improves readability and usability.
-
When you pass parameters to a method, you must be sure that they will all be used, otherwise what is the argument for? Cut it out of the interface and that’s it.
-
try/catch
It doesn’t look very nice by its nature, so a good move would be to move it into an intermediate separate method (method for handling exceptions):public void exceptionHandling(SomeObject obj) { try { someMethod(obj); } catch (IOException e) { e.printStackTrace(); } }