I stumbled upon a very interesting Reddit conversation this week on Repositories. Specifically Domain-Driven Design style Repositories on a PHP project. Many of the questions are quite common portal-points. Portal points are where the big insights arrive. Let’s have a look!
The Domain in Question
class RentalController extends AbstractController
{
public function __construct(
private readonly UserRepository $userRepository,
private readonly RentalService $rentalService,
) {
}
public function newRental(Request $request, string $userUuid): JsonResponse
{
// make sure the request is valid
/** @var User $user */
$user = $this->userRepository->findByUuid($userUuid);
if ($user->getBalance() <= 0) {
return new JsonResponse([
'status' => false,
'message' => 'Insufficient balance',
], 409);
}
// call $this->rentalService method that continues the business logic
return new JsonResponse([...], 201);
}
}
I'm not sure if this question is appropriate for this thread, but I am going to ask it anyway. I browse this sub on a daily basis and everyday there's talks of Repository pattern, Service layer, DDD, etc. One of the most common advice I see around here is that controllers should hold no business logic, which I think is a fair point, but how much logic is allowed inside a controller?
Let's say for example we own a system that rent cars and that if a user has negative balance he's not allowed to rent a new vehicle, do you guys think the following code is okay? Or is checking user balance at the controller level a violation of DDD/best practices in general?
First of - if you spot the heavy emphasis on business logic within a controller - I commend you. Below is a quick rundown on why it matters.
Initial Reactions
The surface-level solutions are very pragmatic and easy to implement. The top comments in particular stand out:
Don’t bother with DDD on a simple project - you need to have a complex domain first for it to be worth it.
Limit the responsibilities of the controller.
Keep the Business logic testable without influence on the web interface, controller or request/response mechanisms. This is considered infrastructure in clear architecture lingo.
Emphasis on incorporating Value Objects and better terminology
The VIP is Missing - Aggregates
Developers I coach often follow DDD-inspired design patterns but don’t go much deeper than that, nevermind having read the original book thoroughly.
Domain-Driven Design is not about code design per se.
DDD is about language and intent.
In MVC frameworks like the one above engineers often tick all the boxes. Separate model mappers, dependency injection, having reasonable terminology and even a use case here and there.
But the culprit is always a lack of transaction boundary management. I’ve seen a very popular behavior pattern of putting data-mapping logic into the models and interspersing behavioural code all over the places between controllers and services.
Yet, those are exactly the responsibilities of an Aggregate. A single object to simplify all the mess that gets created in complex web applications.
My example below from the prior thread:
public function newRental(Request $request, string $userId): JsonResponse
{
// to avoid needless exception-handling, make sure
// getRentalAccountForUser returns a NullRental instead of null and
// handle that exception in the JsonRentalsResponse)
// this way you avoid deeper nesting
// This is the Aggregate
$rentals = $this->rentals->getRentalAccountForUser($userId);
// This is the Command
$order = RentalOrder::fromRequest($request);
// This is the Projection that captures all side effects and sends
// it to the UI
return JsonRentalsResponse::fromNewRental($rentals->process($order));
}
The Simplest Tactical DDD Advice
DDD is relatively simple on a design pattern level.
Aggregates and Entities capture identity and behaviour. Entities handle nested detail, Aggregates enforce invariants —business rules— between transactions
Value Objects enrich primitives
Repositories give you memory-references to Aggregates and Entities so you can call methods on them!
Factories provide initial state for all three types of objects
That’s it.
The third point on that list is often missed. Repositories primarily provide access to Aggregates you want to modify. Repositories are not query models for your data tables.
☑️ GOOD: This is a repository
class RentalsAccounts implements RentalsRepository {
public function getRentalsAccountForUser(UserId $id): RentalsAccount
{ ... }
}
☒ BAD: This is NOT a repository
class RentalsRepository extends AbstractRepository implements IRentalsRepository {
public function getCurrentBalanceForUser(int ...$user): int
{...}
}
Comparison
Notice the differences in usage. An immediate give-away for a bad one are primitive inputs and primitive outputs. The next anti-pattern you may notice is the lack of Aggregate type in the overall structure of the bad repository.
Don’t stop at your tables. Tables are for saving data. Data is stored within your entities, value objects and a part of the aggregate. The main data points being carried over are often identity modifiers. Did you notice how the original question at the very top leaked UUID implementation details?
Repositories are meant for pulling large object graphs into memory from a persistent storage layer. That’s on purpose! That in-memory representation is used to validate all invariants the aggregate has to enforce for the business to be successful.
Validating inputs and outputs with complex libraries, then relying on unique and foreign key constraints from a database is the clumsy and complex way of doing it.
References
The original blog post that sparked the debate
Previous DDD topics on the Crafting Tech Team