On Behavior-Driven Software Development and Data Abstraction
When we start a development project, we usually begin by creating entity objects that represent the data or data structure. The next step is to create services that perform CRUD (Create, Read, Update, Delete) operations on these entities. We can then immediately create the associated repositories, services, and controllers and make them available externally. We might also create DTOs. It seems ready to use now, doesn’t it?
Let’s pause here for a moment. In essence, entities are tools that help us store the software’s state. But how does state form? States are created or changed as a result of specific actions. For example, a user records an invoice, approves it and sends it to accounting, cancels the invoice, and so on. In fact, we can describe these as behaviors. And as we can see, when we develop software, we’re essentially creating implementations that represent and execute user behaviors. If we can organize the software in a way that aligns with these behaviors, the Ubiquitous Language—a concept introduced by the Domain-Driven Design approach and one I consider very important—can be effectively established.
While the CRUD operations we create by default can sometimes simplify things, they can also lead to significant confusion. Especially in the parts of the software where business logic is predominant, this approach can cause the business logic to spread to other layers of the application. Or it can result in the business logic being manipulated by other parts of the application.
For example, let’s assume we have a service like the one below. The Invoice entity has a field named “status.” Depending on this status, the Invoice can be set to an approved or canceled state. Whether the record is approved or canceled is considered the final state. In this or similar use cases, the operations to be performed based on various states within this service result in the business rules that this service should abstract being carried over to the places calling this method.
private Address shipmentAddress;
private Status status;
public enum Status{ NEW, APPROVED, CANCELED}
}
@Service
public class InvoiceService {
private final InvoiceRepository repository;
public Invoice update(Invoice invoice){
return repository.save(invoice);
}
...create
...delete
...get
}
In addition, even if business rules are encapsulated within the `update` method, directly passing the `Invoice` entity can still pose a significant problem. While the data should be stored in accordance with the business rules under this service’s responsibility, the fact that all details of the entity are accessible from the outside means that the service’s business rules and responsibilities may be exposed externally without the service being aware of it.
So, what can we do?
- Behavior-driven development
- Data abstraction / Encapsulation
- Side Effects / Functional programming / Immutability
Behavior-Driven Software Development
As mentioned earlier, the software’s state is created or changed by specific actions. We can characterize the approval of an invoice as a “user behavior.” If we can simulate behaviors such as approving or canceling in the manner described below, we contribute to establishing a common language between the software and the business domain. This helps define the scope of the behavior’s responsibilities.
@Service
public class InvoiceService {
private final InvoiceRepository repository;
public Invoice approveInvoice(final String invoiceId){
....business validations
invoice.setStatus(APPROVED);
return repository.save(invoice);
}
}
Similarly, ensuring that every user action has an appropriate entry point or points within the services not only simplifies software maintenance but also makes it easier to identify and resolve errors. At the same time, from a behavior-oriented perspective, this approach significantly contributes to grouping functions appropriately in terms of cohesion.
Data Abstraction / Encapsulation
I mentioned that an entity represents the software’s state. If we abstract this state, structural changes in the software’s state will not affect the outside world. For example, we might at some point split the state into two parts; with data abstraction, we eliminate the effects of this change. Furthermore, the better we control the data exchanged with the outside world, the more we can mitigate the effects of externally received data that might alter behavior. For instance, if “status” were received from the outside, the if statements within the method that depend on “status” would become vulnerable to manipulation. So, how do we achieve this? Classes acting as DTO objects can help us.
@Builder
public class InvoiceWriteDTO {
private final String customerId;
private final String shipmentAddressId;
}
public class InvoiceReadDTO extends InvoiceWriteDTO {
private String id;
private String customerNameTitle;
private String shipmentAddressId;
private Status status;
}
@Service
public class InvoiceService {
private final InvoiceRepository repository;
public InvoiceReadDTO createANewInvoice(final InvoiceWriteDTO invoice){
....business validations
final var invoice = new Invoice();
invoice.setStatus(APPROVED);
invoice.set...
return mapToReadDTO(repository.save(invoice));
}
}
I now have a `create` method, and I have completely abstracted the business data within this method. I have also restricted all data that will be exchanged with the outside world. The data structure that the user must provide to the software to create an invoice is completely clear and limited. The data structure that the user can view is also clear and limited.
Side Effects / Immutability / Functional Programming
I’ve discussed behaviors and preventing external manipulation. Another aspect of this topic is “side effects.” Mutable objects passed between methods and services can cause the side effect problem. This also violates the rules of data abstraction and encapsulation. According to the functional approach, a method must always return the same result when called with the same parameters, no matter what.
@Service
public class InvoiceService {
private final InvoiceRepository repository;
public InvoiceReadDTO createANewInvoice(final InvoiceWriteDTO invoice){
....business validations
final var invoice = new Invoice();
invoice.setStatus(APPROVED);
invoice.set...
invoiceDetailService.caculateTotalAmount(invoide);
return mapToReadDTO(repository.save(invoice));
}
}
@Service
public class InvoiceDetailService {
public void calculateTotalAmount(Invoice invoice){
invoice.setStatus(NEW)
}
}
Yukarıda “invoiceDetailService.calculateTotalAmount” metodu içerisindeki status değişikliği bir side effect oluşturur ve davranışı manipüle eder. Mutable objeler ile yapılan işlemlerde bu tarz problemlerin oluşmamasına dikkat edilmelidir. Bu problemi aşağıdaki gibi çözebiliriz;
@Service
public class InvoiceDetailService {
public BigDecimal calculateTotalAmount(Invoice invoice){
...
return totalAmount;
}
}
To avoid this problem, you can use the Stream API for lists, which was introduced in Java 8. You should also work with immutable objects whenever possible.
In conclusion:
Grouping our code into modules based on user behavior and encapsulating these functions as much as possible will help us develop software that is easier to maintain and more understandable.

Dilaver DEMİREL