Ender Dincer

Mar 9, 2023

Clean Code Principles

1. Introduction

In this article, I will share principles that I, personally trust and follow to achieve reliable, readable, maintable and extendable Java/Kotlin code. I have learned most of them the hard way and have never stopped following them since. For these principles to work, they should be accepted by all developers in a development team and following them should be mandatory. Pull requests that are not following these principles should be rejected with a reference to the appropriate principle. In other words, this should be treated as a contract.

At first sight, the list can look overwhelming, and yes following all will take some time, but I promise it will save more time than it takes in the long run. Finally, I’m not claiming all principles in the article are perfect or suitable for all application types, there are applications where some principles can’t be applied.

2. Naming

2.1 Naming Conventions

For each type below, there are naming conventions accepted by the industry. The naming convention for variables that are final, static, non-static or non-final is camel case.

Copy

public double price = 2.0;
protected final String firstName = "Bob";
private static int counter = 0;

Copy

1Output: -

Copy

val price = 2.0;
protected val firstName = "Bob";
private val counter = 0;

Copy

1Output: -
For variables that are static and final the naming convention is all uppercase words separated with underscore.

Copy

public static final String EMPTY_STRING = "";
private static final int NUM_OF_DAYS_IN_A_WEEK = 7;

Copy

1Output: -

Copy

object Constants {
    const val EMPTY_STRING = ""
    private const val NUM_OF_DAYS_IN_A_WEEK = 7
}

Copy

1Output: -
Methods, regardless of their access or non-access modifiers, has camel case naming convention.

Copy

public void processFile(File file) {}
private int getAge() {}
static String mergeStrings(String string1, String string2) {}

Copy

1Output: -

Copy

fun processFile(file: File?) {}
private fun getAge(): Int {}
fun mergeStrings(string1: String?, string2: String?): String? {}

Copy

1Output: -
The naming convention for classes, abstract classes, interfaces is Pascal case regardless of their access or non-access modifiers.

Copy

public class RouteManager {}
public sealed interface Executable permits Program {}
private static class Employee {}
public abstract class AbstractFileProcessor {}

Copy

1Output: -

Copy

class RouteManager
interface Executable
private class Employee
abstract class AbstractFileProcessor

Copy

1Output: -

2.2 Meaningful Names

The names can follow all the naming conventions correctly but if they don’t have meaningful names then they will be a maintenance problem. The names given to variables, classes and methods should clearly state the purpose. All developers should think the same thing after reading a variable name or they shouldn’t be guessing the intention of a class. In addition, the name should consist of domain terminology to the extent that non-technical members of the team should be able to understand the flow by taking a quick look at the code.

One rule that applies to all below is, of course, not to use single letter names. The simplest example in which naming conventions are applied but the name has no meaning, revealing no objective.

Variable names should match the type of the variable. If the type of the variable is primitive (except boolean) or their corresponding objects then it should be a noun or the phrase shouldn't contain a verb.

Copy

// good example
final long remainingAmountInKg = 22L;

// bad example
String getAccountName = "Bob's account"

Copy

1Output: -

Copy

// good example 
val remainingAmountInKg = 22L;

// bad example 
var getAccountName = "Bob's account"

Copy

1Output: -
If the variable is a boolean then it should start with one of the following: is, has, should, can.

Copy

boolean hasWings = false;
boolean isCleanCode = true;
Boolean shouldRestart = null;
boolean canFly = false;

Copy

1Output: -

Copy

var hasWings = false
val isCleanCode = true
var shouldRestart: Boolean? = null
var canFly = false

Copy

1Output: -
If the variable is a reference to a lambda or a method reference then it should be a verb or the phrase should contain a verb.

Copy

final Function<Integer, String> convertIntegerToString = ((Integer num) -> String.valueOf(num));
final Function<String, String> trimString = String::trim;

List<String> stringList = Stream.of(1, 2, 3)
        .map(convertToString)
        .map(trimString)
        .toList();

Copy

1Output: -

Copy

val convertIntegerToString = { num: Int -> num.toString() }
val trimString = { string: String -> string.trim() }

val stringList = sequenceOf(1, 2, 3)
    .map { convertIntegerToString(it) }
    .map { trimString(it) }
    .toList()

Copy

1Output: -
Classes can be named in two different ways. If the class is a data carrying class like a DTO (Data Transfer Object) then the name should be a noun.

Copy

public class Student {}
private static class ServerResponse {} 
public class TemporaryConfiguration {}

Copy

1Output: -

Copy

Copy

1Output: -
If the class is used in the service layer or if it is responsible of "doing" something then the noun should have an agentive suffix like -er, -or, -ist, -ian, etc. or it should end with "service".

Copy

// good examples
public class CsvProcessor {}
public class ReportGenerator {}
public class AccountValidationService {}
public class StringHelper {}
public class EntityManager {}

// bad examples
public class CsvProcessing {}
public class ReportGen {}
public class AccountValidation {}
public class StringUtil {}

Copy

1Output: -

Copy

// good examples
class CsvProcessor
class ReportGenerator
class AccountValidationService
class StringHelper
class EntityManager

// bad examples
class CsvProcessing
class ReportGen
class AccountValidation
class StringUtil 

Copy

1Output: -
Method names should include an appropriate verb that matches the purpose of the method.

Copy

public void sendEmail(Email email) {}
static double calculate(int num1, int num2, char operator) {}

Copy

1Output: -

Copy

fun sendEmail(email: Email?) {}
fun calculate(num1: Int, num2: Int, operator: Char): Double {}

Copy

1Output: -
Methods that return boolean should follow the same naming principle with boolean variables.

2.3 Revealing Implementation Details in Names

Modern applications consist of many abstraction layers. Each layer should have its own terminology and each layer's low level details should not be exposed to other layers. A typical application using Spring framework consist of a presentation/communication layer (REST APIs, Kafka Producers/Consumers), a service layer, a domain layer, a data access layer, and an infrastructure/configuration layer - mostly abstracted by the framework. For example, keywords like Singleton, Prototype, Bean shouldn't reach other layers and stay in the infrastructure layer. There are some implementation details that should not be used at all, in any layer. I have seen codebases where interface names have the "I" suffix or prefix and implementing classes have the "Impl" suffix like below. These names don't tell anything about the purpose but only show inheritance relationships.

Copy

// bad examples
public interface EventHandlerI {}
public interface IEventRouter {}

public class EventHandlerImpl implements EventHandlerI {}

// good examples
public interface EventHandler {}

public class OrderEventHandler implements EventHandler {}
public class BasketEventHandler implements EventHandler {}

Copy

1Output: -

Copy

// bad examples
interface EventHandlerI
interface IEventRouter

class EventHandlerImpl : EventHandlerI

// good examples
interface EventHandler

class OrderEventHandler : EventHandler
class BasketEventHandler : EventHandler

Copy

1Output: -
Interfaces should have a general name about the behaviour. Implementing classes should use the interface name as a suffix and should add a phrase stating how they specialise in that behaviour like above. If there's a need for a fallback or a default implementation then those classes can be simply named like below:

Copy

public class DefaultEventHandler implements EventHandler {}
public class FallbackEventHandler implements EventHandler {}

Copy

1Output: -

Copy

class DefaultEventHandler : EventHandler
class FallbackEventHandler : EventHandler

Copy

1Output: -
Models and entities are often named badly too. There is no need to add model or entity suffixes to entities.

Copy

// bad examples
public class StudentEntity {}
public class CustomerModel {}
public class EntityAccount {}
public class RepoBookEntity {}

// good examples
public class Student {}
public class Customer {}
public class Account {}
public class Book {}

Copy

1Output: -

Copy

// bad examples
class StudentEntity
class CustomerModel
class EntityAccount
class RepoBookEntity

// good examples
class Student
class Customer
class Account
class Book 

Copy

1Output: -
On the other hand, in some cases it is very natural to reveal technical details in the name. The builder pattern is a good example for using design pattern related words in the names.

Copy

public Employee {
     // ...
     public static EmployeeBuilder {
        // ...
	 }
}

Copy

1Output: -

Copy

// Named constructor arguments are used instead of the builder pattern Kotlin.

Copy

1Output: -
EmployeeBuilder literally does what it says, it builds the Employee object. Builders are usually inner classes that are in the class they build. It is there to replace the constructor so it becomes an essential element of the class and shouldn't be separated to another layer.

Another example is DTOs. DTOs, inbound and outbound, are essential to protect the data contract between services. Using suffixes like "Request", "Response" or "Dto" is fine, as long as they are not exposed to lower layers of the application like domain or infrastructure. Why is a Dto suffix fine but not an Entity suffix? Because there is no such thing as a student entity or a student model in real life but a student DTO is a technical abstraction. It does precisely what it says, it is a data transfer object. Naming DTOs this way also helps developers to be more cautious about modifying DTOs as introducing a breaking change to the data contract is very easy.

Copy

public class GetUserRequest {}
public class CreateUserRequest {}
public class UserDto {}

Copy

1Output: -

Copy

data class GetUserRequest()
data class CreateUserRequest()
data class UserDto()

Copy

1Output: -

2.4 Useless Suffixes

Suffixes like data, info and details add no value to the names, therefore they should be avoided. They are too generic. What's difference between a User, UserData and UserInfo? What does CustomerDetails tell? What kind of details does it cover? Address, account or contact details? These kind of suffixes that does not have any value should not be used in names. Not even for object composition.

2.5 Design Pattern Names

Using the right name for design patterns is crucial too. I have seen names that do not match the design pattern used at all. An example for the wrong name use can be using adaptor suffixes for dto converters like UserEntityToUserDtoAdaptor. Each time someone new joins to the team, existing members will have to explain the "different" naming convention used in the codebase. Instead, names known and accepted by the community should be used.

2.6 Consistency in Abbreviations

Abbreviations should be treated as new words as well, for example CustomerDao is correct but CustomerDAO is not according to this rule.

This is my preferred way but if the team prefers keeping the abbreviations all uppercase that is fine too as long as only one is allowed in the team, not both.

Copy

// option 1
public class CustomerDto {}
public class CustomerDtoMapper {}

final String callerId = "123";

// option 2
public class CustomerDTO {}
public class CustomerDTOMapper {}

final String callerID = "123";

Copy

1Output: -

Copy

// option 1
data class CustomerDto()
class CustomerDtoMapper {}

val callerId = "123"

// option 2
data class CustomerDTO()
class CustomerDTOMapper {}

val callerID = "123"

Copy

1Output: -

3. Methods

3.1 Number of Arguments

The fewer parameters a method has, the more readable and maintainable it is.

I will start with an example where some parameters of the function can be derived from other parameters.

Copy

public record JobApplication(
    Applicant applicant,
    Instant appliedOn,
    String status,
    boolean isCaptchaFailed
) {}

public record Applicant(
    String firstName,
    String lastName,
    String phoneNumber,
    String resumeUrl
) {}

Copy

1Output: -

Copy

public class JobApplication{
    private Applicant applicant;
    private Instant appliedOn;
    private String status;
    private boolean isCaptchaFailed;
    // constructor, getters, and setters
}

public class Applicant{
    private String firstName;
    private String lastName;
    private String phoneNumber;
    private String resumeUrl;
    // constructor, getters, and setters
}

Copy

1Output: -

Copy

data class JobApplication(
    val applicant: Applicant,
    val appliedOn: Instant,
    val status: String,
    val isCaptchaFailed: Boolean
)

data class Applicant(
    val firstName: String,
    val lastName: String,
    val phoneNumber: String,
    val resumeUrl: String
)

Copy

1Output: -

Copy

public static boolean validateApplication(JobApplication jobApplication, String resumeUrl, String phoneNumber) {
    if (resumeUrl == null) {
        return false;
    }
    if(phoneNumber == null) {
        return false;
    }
    if (jobApplication.isCaptchaFailed()) {
        return false;
    }
    return true;
}

Copy

1Output: -

Copy

fun validateApplication(jobApplication: JobApplication, resumeUrl: String?, phoneNumber: String?): Boolean {
    if (resumeUrl == null) {
        return false
    }
    if (phoneNumber == null) {
        return false
    }
    if (jobApplication.isCaptchaFailed) {
        return false
    }
    return true
}

Copy

1Output: -
The method is "validateApplication", it is not "validateApplication AndResumeUrl AndPhoneNumber". Additonally, validating application already includes those validations. So it can be refactored like below:

Copy

public static boolean validateApplication(JobApplication jobApplication) {
    if(jobApplication.applicant() == null){
        return false;
    }
    if (jobApplication.applicant().resumeUrl() == null) {
        return false;
    }
    if(jobApplication.applicant().phoneNumber() == null) {
        return false;
    }
    if (jobApplication.isCaptchaFailed()) {
        return false;
    }
    return true;
}

Copy

1Output: -

Copy

fun validateApplication(jobApplication: JobApplication): Boolean {
    if (jobApplication.applicant() == null) {
        return false
    }
    if (jobApplication.applicant().resumeUrl() == null) {
        return false
    }
    if (jobApplication.applicant().phoneNumber() == null) {
        return false
    }
    if (jobApplication.isCaptchaFailed) {
        return false
    }
    return true
}

Copy

1Output: -
Similar to the above, if a function has multiple parameters that can be grouped together in one context then the function can accept an object instead of many parameters. Likewise, if there are many flags that can be reduced to a single flag which is easier to understand, then that reduced flag should be passed to the function.

3.2 Types of Arguments

Interfaces or parent classes should be used instead of concrete child classes in input parameters. For example, instead of using ArrayList as an argument, List or Collection can be used. This way the method can accept other collection types. Another example can be accepting Map instead of TreeMap or LinkedHashMap.

Copy

// bad examples
public void deleteAddresses(ArrayList<Address> addresses) {}
public void deleteAddresses(HashSet<Address> addresses) {}
public void processAddressesWithUserIds(TreeMap<String, Address> userIdsToAddresses) {}

// good examples
public void deleteAddresses(List<Address> addresses) {}
public void deleteAddresses(Collection<Address> addresses) {}
public void processAddressesWithUserIds(Map<String, Address> userIdsToAddresses) {}

Copy

1Output: -

Copy

// bad examples
fun deleteAddresses(addresses: ArrayList<Address>) {}
fun deleteAddresses(addresses: HashSet<Address>) {}
fun processAddressesWithUserIds(userIdsToAddresses: TreeMap<String, Address>) {}

// good examples
fun deleteAddresses(addresses: List<Address>) {}
fun deleteAddresses(addresses: Collection<Address>) {}
fun processAddressesWithUserIds(userIdsToAddresses: Map<String, Address>) {}

Copy

1Output: -

This can also allow passing lambda functions if the interfaces are functional interfaces.

Use general classes/interfaces for input parameters.

3.3 Returning Null

Returning null from a function can have a special meaning but whenever a function can return null it introduces more work - because returned objects should be null-checked which means invitation to NPEs. There are some better options than returning null. My favourite is Optionals.

Copy

public static Integer findMaxInCollection(Collection<Integer> numbers) {
    if (numbers == null) {
        return null;
    }
    if (numbers.isEmpty()) {
        return null;
    }
    return numbers.stream().max(Comparator.comparing(Function.identity())).get();
}

Copy

1Output: -

Copy

// Kotlin has nullable types so Optionals are redundant.

Copy

1Output: -
The method above can be refactored for Optionals as follows:

Copy

public static Optional<Integer> findMaxInCollection(Collection<Integer> numbers) {
    if (numbers == null) {
        return Optional.empty();
    }
    if (numbers.isEmpty()) {
        return Optional.empty();
    }
    return numbers.stream().max(Comparator.comparing(Function.identity()));
}

Copy

1Output: -

Copy

// Kotlin has nullable types so Optionals are redundant.

Copy

1Output: -
Let's see the scope where this method is called.

Copy

var integers = List.of(1, 2, 3, 4, 5);

// Optional example 1
findMaxInCollection(integers).ifPresentOrElse(
        this::printMaxInt,
        this::logIntegerNotFound
);

// Optional example 2
var optionalMaxInt = findMaxInCollection(integers);
// here the returned object states that the value might not be present
if (optionalMaxInt.isPresent()) {
    printMaxInt(optionalMaxInt.get());
} else {
    logIntegerNotFound();
}


// with null checks
var maxInt = findMaxInCollection(integers);
// at this point you don't know if maxInt can be null or not
// but with optional you know you can get an empty optional as well.
if (maxInt == null) {
    logIntegerNotFound();
} else {
    printMaxInt(maxInt);
}

Copy

1Output: -

Copy

// Kotlin has nullable types so Optionals are redundant.

Copy

1Output: -

Before finishing Optionals, a quick disclaimer: Never use Optionals as function arguments or constructor parameters they should be used as return types only if one of the possible return types is "nothing". Also Optionals take more memory as they are objects themselves wrapping the object returned.

Another option is simply throwing an exception that explains the problem like InvalidArgumentException. In addition, if the method returns a collection then returning an empty collection is much better than returning null. Finally, returning null should be avoided, instead, optionals, exceptions or empty collections should be preferred.

3.4 Single Responsibility in Methods

Single responsibility principle applies to all units of the application. Methods, classes, even serverless functions and microservices should be responsible of doing only one thing. It is usually easy to spot functions that do multiple things. If there are different levels of abstractions, if blocks of code can be grouped and isolated, if there are multiple try-catch-finally statements, if there are nested for/while loops... Then the function does too many things. There is another one I see very often which I will show with an example as this is a bit different than others.

Copy

public boolean startServer(ServerConfigs serverConfigs){}

// ...
if (startServer(serverConfigs)) {
    // ...
}

Copy

1Output: -

Copy

fun startServer(serverConfigs: ServerConfigs): Boolean {}

// ...
if (startServer(serverConfigs)) {
    // ...
}

Copy

1Output: -

It can feel logical to return the server status after start-up but this is mixing two things. A function can be a command like "start the server" or it can be a query like "is the server healthy" not both at the same time. This is a common pattern called Command and Query Separation (CQS).

Following the single responsibility principle will eliminate questions like "How many lines of code is ideal for a function?" as limiting the function to do only thing will drammatically decrease its size.

3.5 Pure Functions

A function is called pure when it has no side effects. A side effect can be logging to the console, sending a Kafka message, sending an email, modifying the input parameters, starting a new thread, opening/closing a port, creating/updating a record in the database etc. Side effects are state changes that is caused by the function. Majority of side effects are hard to test and debug.

Copy

public void addPassingStudents(List<Student> students, List<Student> passingStudents) {
    for (Student student : students) {
        if (student.getGpa() > 70) {
            passingStudents.add(student);
        }
    }
}

Copy

1Output: -

Copy

fun addPassingStudents(students: List<Student>, passingStudents: MutableList<Student>) {
    for (student in students) {
        if (student.getGpa() > 70) {
            passingStudents.add(student)
        }
    }
}

Copy

1Output: -
The above example, "addPassingStudents", has a side effect. It modifies the collections. It can be refactored like below:

Copy

public List<Student> findPassingStudents(List<Student> students) {
    return students.stream()
            .filter(student -> student.getGpa() > 70)
            .collect(Collectors.toList());
}

Copy

1Output: -

Copy

fun findPassingStudents(students: List<Student>): List<Student> =
    students.asSequence()
        .filter { it.getGpa() > 70 }
        .toList()

Copy

1Output: -
Pure functions should be preferred over functions with side effects. If the functions must have side effects there should only be one. (Logging is the only side effect that can be ignored.)

4. Comments

Names that follow conventions and state the intention clearly, do not need comments. If you follow all mentioned principles then you don’t need comments for your variables and classes. Likewise, a well-defined small function that has a single responsibility doesn’t need extra explanation then its own name and body.

Using comments might seem like a good idea at first as it is easy to explain what’s going on in plain English. However, comments hide bad code. Even worse, they can become misleading after some time. Comments don’t compile so there’s no way for the IDE to warn you after modifying your method. You have to remember modifying the comments as well which often doesn’t happen.

Comments can be used during feature development, bug fixing but they shouldn't exist in production-grade code, they shouldn't even reach pull request reviews.

The only example I could find where comments are useful is legal comments as mentioned by Robert C. Martin in his book Clean Code. An example can be seen below.

Copy

/*
 * Copyright (c) [year] [company name].
 *
 * This software is the confidential and proprietary information of [company name]. You shall
 * not disclose or use it in whole or in part without the express written consent of [company name].
 *
 * Licensed under the [company name] License, Version [version number] (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.[company website].com/licenses/[license name]
 *
 * Unless required by applicable law or agreed to in writing, software distributed under the License
 * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and limitations under the License.
 */

Copy

1Output: -

5. Guard Clauses

In general, a guard clause is an early return or throw statement placed at the beginning of a function that checks for a specific condition that, if not met, makes it impossible to execute the remaining code safely or correctly. They help reduce the complexity of the code. Use guards clauses instead of nested if-else statements.

Copy

public void processOrder(Order order) {
    if (order.getStatus().equals("pending")) {
        if (order.getPaymentStatus().equals("paid")) {
            if (order.getShippingAddress() != null) {
                // process the order
            }
        }
    }
}

Copy

1Output: -

Copy

fun processOrder(order: Order) {
    if (order.getStatus().equals("pending")) {
        if (order.getPaymentStatus().equals("paid")) {
            if (order.getShippingAddress() != null) {
                // process the order
            }
        }
    }
}

Copy

1Output: -
The method above has too many nested if statements. It can be refactored, using guard clauses, like below:

Copy

public void processOrder(Order order) {
    if (!order.getStatus().equals("pending")) {
        return;
    }
    if (!order.getPaymentStatus().equals("paid")) {
        return;
    }
    if (order.getShippingAddress() == null) {
        return;
    }
    // Process the order
}

Copy

1Output: -

Copy

fun processOrder(order: Order) {
    if (!order.getStatus().equals("pending")) {
        return
    }
    if (!order.getPaymentStatus().equals("paid")) {
        return
    }
    if (order.getShippingAddress() == null) {
        return
    }
    // Process the order
}

Copy

1Output: -
Logs before each return statement can be added to specify why the function returned without processing the order.

6. Immutability

Immutability simply means that once an object is created its state cannot be changed. An example immutable User class can be seen below.

Copy

public record User(
        String id,
        String email,
        String fullName,
        int age
) {}

Copy

1Output: -

Copy

public class User {
    private final String id;
    private final String email;
    private final String fullName;
    private final int age;

    public User(String id, String email, String fullName, int age) {
        this.id = id;
        this.email = email;
        this.fullName = fullName;
        this.age = age;
    }

    public String getId() { return id; }
    public String getEmail() { return email; }
    public String getFullName() { return fullName; }
    public int getAge() { return age; }
}

Copy

1Output: -

Copy

data class User(
    val id: String,
    val email: String,
    val fullName: String,
    val age: Int
)

Copy

1Output: -

One thing to be careful here is that all fields should be immutable as well. A mutable collection or an object would break the immutability.

Making immutability the default in the codebase has many advantages. Immutable objects are simple, predictable, thread-safe and can be cached because their properties do not change. Immutability works well with pure functions as well, it can even be said that they complete each other. Pure functions do not modify the input parameters but it is even better if the parameters can't be modified by nature.

To create a new updated immutable object from an existing immutable object, Lombok's toBuilder annotation can be used. For Kotlin, there is a built-in method called copy().

Copy

import lombok.Builder;

@Builder(toBuilder = true)
public class User {
    // ...
}

Copy

1Output: -

Copy

// Use copy() for the same behaviour

Copy

1Output: -

Copy

public static void main(String[] args) {
    final var user = new User("1345" ,"test@mail.com", "John Doe", 23);
    final var updatedUser = user.toBuilder().age(33).build();
}

Copy

1Output: -

Copy

fun main() {
    val user = User("1345" ,"test@mail.com", "John Doe", 23)
    val updatedUser = user.copy(age = 33)
}

Copy

1Output: -

Immutable classes should be preferred over mutable ones.

7. Util Classes (Helper Classes)

A class, that cannot be instantiated and has only static methods which have no state is called a utility class or helper class.

Utility classes break so many principles. The first problem is tight coupling. The classes that depend on utility classes are tightly coupled. The class should depend on an interface or a class that is extendible.

Furthermore, testing becomes problematic. Although not impossible, it is difficult to test static methods. There are libraries that can mock static methods but why introduce new dependencies to the application?

Another issue is that use of AOP (Aspect Oriented Programming) with utility classes which is a frequently used pattern in Spring framework. Spring uses dynamic proxying to handle cross-cutting concerns which won't work with class full of static methods.

Final problem I will mention, and the one should scare developers the most, is the fact that a utility class has no boundries, it can grow forever. Say you have a CheckoutHelper. Any new method that has no state and is in the checkout domain can go into CheckoutHelper and it goes. After time, the class becomes huge and hard to maintain. This can even lead to helper classes that help other helper classes - a total mess.

Utility classes, as defined above, should be avoided. My favourite comment on utility classes belongs to this discussion on StackOverflow where a user named VGR says: "Notice that we call a toaster, toaster. We do not call it a BreadUtil."

8. Dependency Injection

Dependency injection is a design pattern in which an object's dependencies are passed in as external dependencies, rather than being created within the object itself. There are different types of injections such as constructor injection, setter injection and field injection.

The reason why I gave dependency injection its own section is because Spring framework makes it so easy to use all injection types. But great power comes with great responsibility! Using field injections with @Autowired annotation might be convenient but constructor injection has much better advantages than just ease of use.

Constructor injection allows dependencies to be marked as final resulting in compile-time safety unlike field or setter injection. It improves the testability of the class. Dependencies are explicitly defined as constructor parameters, meaning they are visible and easier to manage. It also allows different dependency injection frameworks to be used together. Therefore, constructor injection should be preferred over other injection types.

9. Using Design Patterns

"Design patterns are reusable solutions to recurring software design problems that have been proven effective and efficient through repeated use." (Gang of Four, Design Patterns) As stated, design patterns are great tools. They should be used when the opportunity comes, however, when they are used just for the sake of using them, they create unnecessary maintenance overhead.

Copy

// bad examples

public interface CustomerService {}
public class CustomerServiceImpl implements CustomerService {}

public interface CustomerController {}
public class CustomerControllerImpl implements CustomerController {}

public interface CustomerDao {}
public class CustomerDaoImpl implements CustomerDao{}

Copy

1Output: -

Copy

// bad examples

interface CustomerService
class CustomerServiceImpl : CustomerService

interface CustomerController
class CustomerControllerImpl : CustomerController

interface CustomerDao
class CustomerDaoImpl : CustomerDao

Copy

1Output: -
The above is the most common example I have seen. If there is only a single implementation of the interface there is no need to create an interface. Avoid introducing unnecessary abstraction to the codebase.

10. Packaging Structure

There are two common packaging structure conventions: package by layer and package by feature.

Copy

├── com.app
    └── controller
        ├── OrderController
        └── UserController
    └── domain
        ├── Order
        └── User
    └── repository
        ├── OrderRepository
        └── UserRepository
    └── service
        ├── OrderService
        └── UserService

Copy

1Output: -

Copy

├── com.app
    └── order
        ├── Order   
        ├── OrderController
        ├── OrderRepository
        └── OrderService
    └── user
        ├── User   
        ├── UserController
        ├── UserRepository
        └── UserService

Copy

1Output: -
Both have advantages and disadvantages but packaging structure should be consistent throughout the codebase, using both should be avoided. Additionally, if the codebase already follows one, it is not worth refactoring one to switch to the other. Refactoring can be done if there are inconsistencies in the packaging structure. For example, the main structure is package by layer but there are some packages that are organised by feature. This is an opportunity for refactoring by-feature packages to by-layer packages. An additional example could be a large and complex monolithic system that requires restructuring. In such cases, implementing the package by feature approach can assist in the separation of distinct features or workflows into individual microservices or serverless functions.

11. Test Coverage and Refactoring

There is a strong relationship between test coverage and refactorability of a codebase. When a codebase has high test coverage developers can confidently make changes to the codebase without worrying about breaking existing functionality.

In contrast, low test coverage can make it difficult to refactor a codebase because there is a higher risk of introducing bugs or breaking existing functionality. Without automated tests in place, developers may be hesitant to make changes to the codebase out of fear that they will cause problems down the line. This can lead to a stagnant codebase that is difficult to maintain and update over time.

12. Open Source Libraries

Open source libraries are usually developed, maintained, tested and documented by many developers which means the code is often of high quality. There is no need to re-invent the wheel. Development teams should always look for opportunities to use open source libraries. This will allow them to focus on the code quality of the unique features they build. Good examples are Apache Commons and Google Guava.

13. Conclusion

To sum up, adhering to clean code principles is crucial for building high-quality software. While implementing all of these principles may require some extra effort, the benefits of a well-designed, easy-to-read and maintainable codebase are well worth it in the long run. By consistently following these principles, developers can create software that not only works well but is also a pleasure to work with.

Sources


Clean Code by Robert C Martin

link

Effective Java, 3rd Edition by Joshua Bloch

link

Design Patterns: Elements of Reusable Object-Oriented Software by Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides

link

Spring AOP Documentation

link

©

enderdincer.com