Good Encapsulation Usage
Introduction
Software development is a challenging task. A software engineer does more than just coding when ensuring quality. They must express the software product and its rules clearly in the code, allowing the development team to understand the product’s behavior throughout its usage journey.
Beyond that, another challenge is maintaining consistency in the code, preventing bugs, and ensuring that when new changes are required, the modified rule is easy to update without breaking others.
To achieve this, we must adopt good coding practices. A well-known set of principles helps software engineers improve development quality. Experienced engineers are familiar with concepts like KISS, YAGNI, DRY, and SOLID. But how about revisiting something even more fundamental—the object-oriented programming concept of encapsulation?
Encapsulation
Among another concepts of Oriented Object Programming like polymorphism, inheritance, and abstraction, we are going to talk about encapsulation.
In a nutshell, encapsulation tell us to not directly manipulate the object attributes. Instead, we use methods to do so.
Below, notice that we do not manipulate the balance attribute directly. Instead, it is accessed and modified through the object's operations, ensuring control and preventing inconsistencies. This approach enforces encapsulation by restricting direct access to the internal state and providing controlled interactions via methods.
import java.math.BigDecimal;
import java.math.RoundingMode;
public class BankAccount {
private BigDecimal balance;
public BankAccount(BigDecimal initialBalance) {
if (initialBalance == null || initialBalance.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException("Initial balance must be non-negative");
}
this.balance = initialBalance.setScale(2, RoundingMode.HALF_EVEN);
}
public BigDecimal getBalance() {
return balance;
}
public void deposit(BigDecimal amount) {
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("Deposit amount must be positive");
}
balance = balance.add(amount).setScale(2, RoundingMode.HALF_EVEN);
}
public void withdraw(BigDecimal amount) {
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("Withdrawal amount must be positive");
}
if (amount.compareTo(balance) > 0) {
throw new IllegalArgumentException("Insufficient funds");
}
balance = balance.subtract(amount).setScale(2, RoundingMode.HALF_EVEN);
}
}
Do you use encapsulation well?
Imagine you've got a class User that has a relation with another one called Contact.
public class User {
private String name;
private List<Contact> contacts;
public List<Contact> getContacts() {
return this.contacts;
}
//getters and setters
}
public class Contact {
private String phone, email;
//constructors, getters and setters
}
So you have a service class to add a contact to a user:
public class MyService {
private UserRepository userRepository;
public void addContact(Long userId, String phone, String email) {
User user = userRepository.findById(userId);
user.getContacts().add(new Contact(phone, email));
userRepository.update(user);
}
}
Do you see a problem here?
We hurt the encapsulation by knowing the details of User class, getting its contact list add add a element there. Besides that, we are directly manipulating the attribute of another object.
So, how can we make it better? We must create a method addContact inside the User Class. So, we avoid our service to know the implementation details. Let's see:
public class User {
private String name;
private List<Contact> contacts;
public List<Contact> getContacts() {
return this.contacts;
}
public void addContact(String phone, String email) {
Contact contact = new Contact(phone, email);
this.contacts.add(contact)
}
//getters and setters
}
This way we make a good usage of the encapsulation.
Do you see how we can improve it more? Here is a tip: We still can use the first version of the addContact method. So, any new collegue can get the list and direcly add a new contact in it.
We can solve it by developing a copy of the contacts. It keeps the user contact list consistency.
领英推荐
public class User {
private String name;
private List<Contact> contacts;
public List<Contact> getContacts() {
return new ArrayList<>(this.contacts);
}
public void addContact(String phone, String email) {
Contact contact = new Contact(phone, email);
this.contacts.add(contact)
}
//getters and setters
}
We have almost did the best we can. Because we should force another developers to use the addContact method we create. The data is now consistency, but another developers can still face problems calling the contact list and then add.
So, lets return a copy of the list that do not let anyone add in it!
public class User {
private String name;
private List<Contact> contacts;
public List<Contact> getContacts() {
return Collections.unmodifiableList(this.contacts);
}
public void addContact(String phone, String email) {
Contact contact = new Contact(phone, email);
this.contacts.add(contact);
}
//getters and setters
}
Now, anyone who wants to add a new contact, must call our addContact method!
Our service method becomes cleaner and makes a good usage of the encapsulation.
public class MyService {
private UserRepository userRepository;
public void addContact(Long userId, String phone, String email) {
User user = userRepository.findById(userId);
user.addContact(phone, email);
userRepository.update(user);
}
}
Beyond Encapsulation: Avoiding Primitive Obsession
In this article, we assumed that data and inputs were already normalized and correct. However, in real-world development, we must assume the opposite.
Looking at the code samples above, the reader might question whether the data could introduce issues into the system. For example, the phone attribute in the Contact class could accept an invalid value like "123foo".
By defining phone as a primitive type (even though String is technically a class in Java), we introduced a code smell that led to this issue. Instead, we should have considered using a dedicated class to enforce the correct phone number format. @Fredi
public class PhoneNumber {
private final String value;
public PhoneNumber(String value) {
if (!value.matches("\\d{10,15}")) {
throw new IllegalArgumentException("Invalid phone number format");
}
this.value = value;
}
public String getValue() {
return value;
}
}
And now the Contact class would become :
public class Contact {
private String email;
private PhoneNumber phone;
//constructors, getters and setters
}
This kind of design flaw is known as Primitive Obsession, a common code smell that occurs when primitive types are overused instead of domain-specific objects.
Surely, you can consider that email could follow the same approach. By creating a dedicated class for the email attribute, we can enforce validation rules and ensure consistency throughout the system, just as we did with the phone number.
Conclusion
A solid understanding and proper application of object-oriented programming fundamentals enable software developers to write concise and clear code. This directly impacts code quality, making it more expressive of business rules. Additionally, it enhances maintainability, facilitates testing, and simplifies future modifications.
Acknowledgments
I would like to express my gratitude to Fredy Gadotti, Renan Castro, and Bruno Parreira for their valuable feedback and insightful reviews throughout the writing process. Their input greatly contributed to improving the quality of this article.
Additionally, I extend my thanks to Bee Engineering for encouraging and supporting this initiative.