Systems
The following are generally desirable characteristics of a system:- Minimal complexity. Overly complicated projects must be avoided. The most important thing is simplicity and clarity (simpler = better).
- Ease of maintenance. When creating an application, you must remember that it will need to be maintained (even if you personally won't be responsible for maintaining it). This means the code must be clear and obvious.
- Loose coupling. This means that we minimize the number of dependencies between different parts of the program (maximizing our compliance with OOP principles).
- Reusability. We design our system with the ability to reuse components in other applications.
- Portability. It should be easy to adapt a system to another environment.
- Uniform style. We design our system using a uniform style in its various components.
- Extensibility (scalability). We can enhance the system without violating its basic structure (adding or changing a component should not affect all the others).
Stages of designing a system
- Software system. Design the application overall.
- Division into subsystems/packages. Define logically distinct parts and define the rules for interaction between them.
- Division of subsystems into classes. Divide parts of the system into specific classes and interfaces, and define the interaction between them.
- Division of classes into methods. Create a complete definition of the necessary methods for a class, based on its assigned responsibility.
- Method design. Create a detailed definition of the functionality of individual methods.
General principles and concepts of system design
Lazy initialization. In this programming idiom, the application doesn't waste time creating an object until it is actually used. This speeds up the initialization process and reduces the load on the garbage collector. That said, you shouldn't take this too far, because that can violate the principle of modularity. Perhaps it is worth moving all instances of construction to some specific part, for example, the main method or to a factory class. One characteristic of good code is an absence of repetitive, boilerplate code. As a rule, such code is placed in a separate class so that it can be called when needed.AOP
I would also like to note aspect-oriented programming. This programming paradigm is all about introducing transparent logic. That is, repetitive code is put into classes (aspects) and is called when certain conditions are satisfied. For example, when calling a method with a specific name or accessing a variable of a specific type. Sometimes aspects can be confusing, since it is not immediately clear where the code is being called from, but this is still very useful functionality. Especially when caching or logging. We add this functionality without adding additional logic to ordinary classes. Kent Beck's four rules for a simple architecture:- Expressiveness — The intent of a class should be clearly expressed. This is achieved through proper naming, small size, and adherence to the single-responsibility principle (which we will consider in more detail below).
- Minimum number of classes and methods — In your desire to make classes as small and narrowly focused as possible, you can go too far (resulting in the shotgun surgery anti-pattern). This principle calls for keeping the system compact and not going too far, creating a separate class for every possible action.
- No duplication — Duplicate code, which creates confusion and is an indication of suboptimal system design, is extracted and moved to a separate location.
- Runs all the tests — A system that passes all the tests is manageable. Any change could cause a test to fail, revealing to us that our change in a method's internal logic also changed the system's behavior in unexpected ways.
SOLID
When designing a system, the well-known SOLID principles are worth considering:S (single responsibility), O (open-closed), L (Liskov substitution), I (interface segregation), D (dependency inversion).
We won't dwell on each individual principle. That would be a little beyond the scope of this article, but you can read more here.Interface
Perhaps one of the most important steps in creating a well-designed class is creating a well-designed interface that represents a good abstraction, hiding the implementation details of the class and simultaneously presenting a group of methods that are clearly consistent with one another. 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. In other words, if we're talking about creating an interface with the least number of methods aimed at performing the interface's only job (which I think is very similar to single responsibility principle), it is better to create a couple of smaller ones instead of one bloated interface. Fortunately, a class can implement more than one interface. Remember to name your interfaces properly: the name should reflect the assigned task as accurately as possible. And, of course, the shorter it is, the less confusion it will cause. Documentation comments are usually written at the interface level. These comments provide details about what each method should do, what arguments it takes, and what it will return.Class
- public static constants;
- private static constants;
- private instance variables.
Class size
Now I would like to talk about the size of classes. Let's recall one of the SOLID principles — the single responsibility principle. It states that each object has only one purpose (responsibility), and the logic of all its methods aims to accomplish it. This tells us to avoid large, bloated classes (which are actually the God object anti-pattern), and if we have a lot of methods with all sorts of different logic crammed into a class, we need to think about breaking it apart into a couple of logical parts (classes). This, in turn, will increase the readability of the code, since it won't take long to understand the purpose of each method if we know the approximate purpose of any given class. Also, keep an eye on the class name, which should reflect the logic it contains. For example, if we have a class with 20+ words in its name, we need to think about refactoring. Any self-respecting class should not have that many internal variables. In fact, each method works with one or a few of them, causing a lot of cohesion within the class (which is exactly as it should be, since the class should be a unified whole). As a result, increasing a class's cohesion leads to a reduction in class size, and, of course, the number of classes increases. This is annoying for some people, since you need to peruse around class files more in order to see how a specific large task works. On top of it all, each class is a small module that should be minimally related to others. This isolation reduces the number of changes we need to make when adding additional logic to a class.Objects
Encapsulation
Here we'll first talk about an OOP principle: encapsulation. Hiding the implementation does not amount to creating a method to insulate variables (thoughtlessly restricting access through individual 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 shared concrete methods that we use to work with our data. And the user doesn't need to know exactly how we are working with this data — it works and that's enough.Law of Demeter
We can also consider the Law of Demeter: it is a small set of rules that aids in managing complexity at the class and method level. Suppose we have a Car object, and it has a move(Object arg1, Object arg2) method. According to the Law of Demeter, this method is limited to calling:- methods of the Car object itself (in other words, the "this" object);
- methods of objects created within the move method;
- methods of objects passed as arguments (arg1, arg2);
- methods of internal Car objects (again, "this").
Data structure
A data structure is a collection of related elements. When considering an object as a data structure, there is a set of data elements that methods operate on. The existence of these methods is implicitly assumed. That is, a data structure is an object whose purpose is to store and work with (process) the stored data. Its key difference from a regular object is that an ordinary object is a collection of methods that operate on data elements that are implicitly assumed to exist. Do you understand? The main aspect of an ordinary object is methods. Internal variables facilitate their correct operation. But in a data structure, the methods are there to support your work with the stored data elements, which are paramount here. One type of data structure is a data transfer object (DTO). This is a class with public variables and no methods (or only methods for reading/writing) that is used to transfer data when working with databases, parsing messages from sockets, etc. Data is not usually stored in such objects for a lengthy period. It is almost immediately converted to the type of entity that our application works. An entity, in turn, is also a data structure, but its purpose is to participate in business logic at various levels of the application. The purpose of a DTO is to transport data to/from the application. Example of a 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 enough, but here we learn about the existence of hybrids. Hybrids are objects that have methods for handling important logic, store internal elements, and also include accessor (get/set) methods. Such objects are messy and make it difficult to add new methods. You should avoid them, because it is not clear what they are for — storing elements or execute logic?Principles of creating variables
Let's ponder a little about variables. More specifically, let's think about what principles apply when creating them:- Ideally, you should declare and initialize a variable just before using it (don't create one and forget about it).
- Whenever possible, declare variables as final to prevent their value from changing after initialization.
- Don't forget about counter variables, which we usually use in some kind of for loop. That is, don't forget to zero them out. Otherwise, all our logic may break.
- You should try to initialize variables in the constructor.
- If there is a choice between using an object with a reference or without (new SomeObject()), opt for without, since after the object is used it will be deleted during the next garbage collection cycle and its resources won't be wasted.
- Keep a variable's lifetime (the distance between the creation of the variable and the last time it is referenced) as short as possible.
- Initialize variables used in a loop just before the loop, not at the beginning of the method that contains the loop.
- Always start with the most limited scope and expand only when necessary (you should try to make a variable as local as possible).
- Use each variable for one purpose only.
- Avoid variables with a hidden purpose, e.g. a variable split between two tasks — this means that its type is not suitable for solving one of them.
Methods
from the movie "Star Wars: Episode III - Revenge of the Sith" (2005)
Rule #1 — Compactness. Ideally, a method should not exceed 20 lines. This means that if a public method "swells" significantly, you need to think about breaking the logic apart and moving it into separate private methods.
Rule #2 — if, else, while and other statements should not have heavily nested blocks: lots of nesting significantly reduces the readability of the code. Ideally, you should have no more than two nested {} blocks.
And it is also desirable to keep the code in these blocks compact and simple.
Rule #3 — A method should perform only one operation. That is, if a method performs all sorts of complex logic, we break it into submethods. As a result, the method itself will be a facade whose purpose is to call all the other operations in the correct order.
But what if the operation seems too simple to put into a separate method? True, sometimes it may feel like firing a cannon at sparrows, but small methods provide a number of advantages:
- Better code comprehension;
- Methods tend to become more complex as development progresses. If a method is simple to begin with, then it will be a little easier to complicate its functionality;
- Implementation details are hidden;
- Easier code reuse;
- More reliable code.
The stepdown rule — Code should be read from top to bottom: the lower you read, the deeper you delve into the logic. And vice versa, the higher you go, the more abstract the methods. For example, switch statements are rather non-compact and undesirable, but if you can't avoid using a switch, you should try to move it as low as possible, to the lowest-level methods.
Method arguments — What is the ideal number? Ideally, none at all :) But does that really happen? That said, you should try to have as few arguments as possible, because the fewer there are, the easier it is to use a method and the easier it is to test it. When in doubt, try to anticipate all the scenarios for using the method with a large number of input parameters.
Additionally, it would be good to separate methods that have a boolean flag as an input parameter, since this all by itself implies that the method performs more than one operation (if true, then do one thing; if false, then do another). As I wrote above, this is not good and should be avoided if possible.
If a method has a large number of input parameters (an extreme is 7, but you should really start thinking after 2-3), some of the arguments should be grouped into a separate object.
If there are several similar (overloaded) methods, 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 are all used, otherwise why do you need them? Cut any unused parameters out of the interface and be done with it.
- try/catch doesn't look very nice in nature, so it would be a good idea to move it into a separate intermediate method (a method for handling exceptions):
public void exceptionHandling(SomeObject obj) { try { someMethod(obj); } catch (IOException e) { e.printStackTrace(); } }
GO TO FULL VERSION