Monolithic Objects
Don't model the real world, model your business!
Even the largest system starts as a small bunch of objects. The design of these objects are mirrored throughout the entire system. That is, it’s very important to model the system correctly from the beginning, from the very first object.
In this post, we will use the strategic domain-driven design to develop a simple system. We will show typical mistakes and pitfalls to be avoided and improve it step by step on the way.
We will demonstrate how easy it is to fall into the trap of monolithic thinking. We will see that even the smallest monolithic object has a great impact on the entire system, and that every big ball of mud starts as a small ball of a few muddy objects.
Domain, entities, repositories and services
Let’s design a simple booking system “Rent a car”. We typically begin with listing types of entities included in the system. We can think of entities:
- Customers
- Vehicles
- Bookings
The Customer entity has a first and last name, a customer ID, and maybe some contact information such as an e-mail address:
class Customer { Long id; String firstName; String lastName; String email; }
The Vehicle entity has an ID, a type, a color and an availability flag telling us if the Vehicle is for rent or not (under repair, discarded, etc.):
class Vehicle { Long id; String type; String color; Boolean available; }
Now, the Booking entity maps a Vehicle with a Customer for some time period:
class Booking { Long id; Date from; Date until; Vehicle vehicle; Customer customer; }
Easy. Having the domain model, we can consider a repository to each entity:
interface CustomerRepository { Customer findById(Long id); Collection<Customer> findAll(); void save(Customer customer); } interface VehicleRepository { Vehicle findById(Long id); Collection<Vehicle> findAll(); void save(Vehicle vehicle); } interface BookingRepository { Booking findById(Long id); Collection<Booking> findAll(); void save(Booking booking); }
Straight-forward. Services and controllers pretty much copy functionality of the repositories.
Code organization
For the start, we chose a layered structure of our codebase:
controllers/ BookingController.java CustomerController.java VehicleController.java domain/ Booking.java Customer.java Vehicle.java repositories/ BookingRepository.java CustomerRepository.java VehicleRepository.java services/ BookingService.java CustomerService.java VehicleService.java
Layered architecture partitions code by technical concerns. It’s the easiest way of structuring code, working just fine for the start and for small codebases. Usually, it becomes suboptimal as the system grows.
Problem with boundaries
Things become interesting when we start thinking about the Booking service, especially about a method for creating a new Booking.
class BookingService { private BookingRepository repo; public Booking findById(Long id) { return repo.findById(id); } Collection<Booking> findAll() { return repo.findAll(); } void create(???) { ??? } }
Obviously, we need instances of the Customer and Vehicle the Booking is created for. How do we get them? There are several options on offer...
Mighty controller
The first option would be to load them from the controller and pass them as a parameter to the service:
@RestController class BookingController { private BookingService bookingService; private CustomerService customerService; private VehicleService vehicleService; @PostMapping void createBooking(Long customerId, Long vehicleId) { var customer = customerService.findById(customerId); var vehicle = vehicleService.findById(vehicleId); bookingService.create(customer, vehicle); } ... }
This will work, the service gets what it needs, everything’s fine. The problem with this approach is that it puts too much responsibility on the controller. A controller has a single job: processing user input (request) into output (response). Any orchestration is not a job for a controller and it should not be. Consider another controller of a command-line application, do we want to duplicate this behavior there? Probably not, because it is not a controller-specific job and has no place in the controller code, or at least the resulting fragmentation of domain logic is not ideal.
Broken encapsulation
Saying that, we can try the second side and use repositories from within the service:
class BookingService { private BookingRepository bookingRepo; private CustomerRepository customerRepo; private VehicleRepository vehicleRepo; ... void create(Long customerId, Long vehicleId); var customer = customerRepo.findById(customerId); var vehicle = vehicleRepo.findById(vehicleId); if (vehicle.available()) { bookingRepo.save(new Booking(customer, vehicle)); } } }
Well, this will work, too. The problem here is that a repository is a mere implementation detail of a particular service. Using a repository of another service means breaking the encapsulation of that service. What’s more, a service usually implements some business rule upon the data. Calling the repository directly we skip those rules entirely and can potentially break business invariants, which leads to working with invalid objects. This is definitely not what we want.
Coupled services
There is one more option: calling services from a service:
class BookingService { private BookingRepository bookingRepo; private CustomerService customerService; private VehicleService vehicleService; ... void create(Long customerId, Long vehicleId); var customer = customerService.findById(customerId); var vehicle = vehicleService.findById(vehicleId); if (vehicle.available()) { bookingRepo.save(new Booking(customer, vehicle)); } } }
We have fixed all the previous problems, this solution seems to be the best one. At least the best in the current settings. There is still one issue with this approach: it’s desperately monolithic.
Service should be autonomous, that is, a service must have all it needs to carry out a particular business capability. Unfortunately, this is not our case: the Booking service needs other services to create a new Booking. This is, due to an incorrect cohesion, the services are tightly coupled to each other, which is always a sign of a monolithic design. With a setting like that, we can’t talk about services anymore.
You can raise an objection saying that the whole system is a service, but that’s just not true. Because a lot of modeled information is actually not necessary for the Booking. For instance, a customer's name or vehicle’s color are super redundant in the booking process, they do belong to the Customer service and Vehicle service, because the application we develop is a system of multiple services rather than a single service. The problem is that our objects are modeled in a logically monolithic way.
Are we there yet?
An obvious fix would be to remove redundant entities and replace them with mere references:
class Booking { ... Long vehicleId; Long customerId; }
In a real development, using domain primitives such as VehicleId
and CustomerId
objects rather than Longs would create much more secure code. For sake of simplicity we stick with Long
here.
We have made the Booking entity independent, unfortunately, this doesn’t help us much further, because the Availability information from the Vehicle is still needed in the Booking service. We’re trapped and there is no way out!
void create(Long customerId, Long vehicleId); var available = ??? if (available) { bookingRepo.save(new Booking(customerId, vehicleId)); } }
Truly domain-driven
The tricky part of designing a service is not to fall in the trap of modeling as the real world. Business is just a specialized and very concrete subset of the real world with special rules and different meanings.
Let’s think about the concept of Availability. In the real world, it belongs naturally to the Vehicle entity. In our business domain, however, there is no meaning of Availability in the Vehicle service whatsoever. Availability has a meaning only in the Booking service, this is, it truly belongs to the Booking service:
class Availability { Long vehicleId; Boolean available; }
Explicit boundaries
Now the Booking service has everything it needs to create a new Booking without asking another service for additional information. All services can be now developed and deployed independently. We can make their boundaries explicit by organizing code into domain specific packages:
booking/ controllers/ BookingController.java domain/ Availability.java Booking.java repositories/ AvailabilityRepository.java BookingRepository.java services/ BookingService.java customer/ controllers/ CustomerController.java domain/ Customer.java repositories/ CustomerRepository.java services/ CustomerService.java vehicle/ controllers/ VehicleController.java domain/ Vehicle.java repositories/ VehicleRepository.java services/ VehicleService.java
Putting the same domain concepts together made our code much clearer. Technical packages seem to be no more necessary, we can get rid of them completely:
booking/ Availability.java AvailabilityRepository.java Booking.java BookingController.java BookingRepository.java BookingService.java customer/ Customer.java CustomerController.java CustomerRepository.java CustomerService.java vehicle/ Vehicle.java VehicleController.java VehicleRepository.java VehicleService.java
Exposed behavior
We have successfully broken our small monolith into decoupled services with explicit boundaries. We can go even further by exposing just specific behavior instead of a single monolithic service API.
What is it good for? Consider a client interested in only listing actual Bookings. Why should such a client depend on the whole service? Better would be to cut the use-case off the still-pretty-monolithic service and expose it explicitly. Same for all other use-cases:
booking/ CreateBooking.java FindAllBookings.java FindBooking.java ...
The use-case driven API can be implemented entirely by the Booking service class or by multiple independent classes. Clients are not forced to depend on things they don’t need. This concept is known as the Interface segregation principle.
Conclusion
Business usually differs from the real world. Business represents just a specific and very opinionated view of the real world. These differences must be understood and modeled in the code. This is the essence of domain-driven design.
Typical mistake is to model a real world entirely and try to fit it into the business afterwards. Such an approach often leads to wrong boundaries and tightly coupled code.
Business-oriented thinking must be applied from the very beginning, from the very first objects. Monolithic systems are made of monolithically designed objects. The overall system architecture reflects objects it is composed of.
Behavior-driven rather than entities-driven design is a good strategy to avoid typical pitfalls of monolithic objects. All in all, objects are about behavior, not about data.
Happy objecting!