Is it acceptable to invoke business logic inside the repository layer?
The DDD police are not going to come and kick down your door.
But...
The motivation for the repository pattern is to "decouple application and domain design from persistence technology". So adding domain logic behind the repository interface rather defeats the purpose.
It also makes it more difficult to guess where the domain logic is. When all of the domain logic is in the domain model, the only guess is the right one. When some of the logic is instead in the repository implementation, or in the persistence store itself, the rediscovering the code that needs to change becomes more difficult.
However...
Is it ok to just pass a changeset through to the repo and let the repo do the updating like so...
You are really close to discovering a different pattern which can be effective, even though it isn't the one described by Evans.
If think carefully about the different elements, you may realize that the mutable state lives in the repository, rather than in the domain model. We talk about aggregates and entities, but those are just transient changes. The real object that changes is the repository itself.
This might suggest a design where the "domain logic" is built from pure functions, and the repository manages the mutable state.
Repository::onChange(id, domainLogic)
oldValue = this.get(id)
newValue = domainLogic(oldValue)
this.replace(oldValue, newValue)
You end up with something like a monad - an opaque box that manages the mutable state for you.
The good news? A lot of the people who have been soaking in DDD for a long time assert that the important part of the Blue book is not the sections on implementation patterns. Texts like Domain Modeling Made Functional discard the "object oriented" focus altogether, and those designs seem to be healthy.
In any given project, I think you'll be best served to pick one style and stay with it, but it is possible that there have been improvements on what we considered to be "best practices" in Java over fifteen years ago.
Does this violate the single responsibility principle? The repository is now invoking the domain model’s business logic which means it is now responsible for more than just object storage. It now has knowledge of which business processes to run and has to handle business errors which may occur also
Not quite; the "domainLogic" argument is a function whose implementation is in the domain model, the persistence component doesn't know anything about it other than the signature. It's actually the application layer that is passing the function to the repository.
How about an example -- let's try one of the shipping use cases: assigning cargo to a route. Here's roughly what it looks like in the usual idiom
Application::assignCargoToRoute(itinerary, trackingId) {
cargo = repository.getById(trackingId)
cargo.assignToRoute(itinerary)
repository.save(cargo)
}
Here's the alternative I described:
Application::assignCargoToRoute(itinerary, trackingId) {
// Notice: this returns a _function_
domainLogic = domainModel.assignToRoute(itinerary)
repository.onChange(trackingId, domainLogic)
}
Note that the "responsibilities" really are separated:
- the domain model knows about the logic, but doesn't know anything about orchestration or state
- the application knows about orchestration, but knows nothing about the logic or the state
- the repository knows about state, but nothing of orchestration or logic
Here's the underlying trick -- buried in the domain model is the potential for a pure function that looks something like:
Cargo.State assignCargoToRoute(trackingId, cargoState, itinerary)
In one case, we are "closing" over the cargoState, and accepting the other arguments later
Cargo {
Cargo.State state;
void assignToRoute(trackingId, itinerary) {
this.state = PureFunction::assignCargoToRoute(trackingId, this.state, itinerary)
}
}
In the other, we are closing over the command data, and deferring the state:
Function<Cargo.State, Cargo.State) assignoRoute(trackingId, itinerary) {
return new Function<> (Cargo.State state) {
return PureFunction::assignCargoToRoute(this.trackingId, state, this.itinerary);
}
}
The second spelling is alien, but it's really equivalent to the first. All we've done is draw the responsibility boundaries slightly differently from how they were described fifteen years ago.
... without having to expose a Transaction to the service layer
Can you tell us why you do not want the repository to have methods likebeginTransaction()
andcommitTransaction()
?begin
andcommit
. If I were to hide that behind some generic interface, a lot of functionality would be lost. I understand this to be the typical tradeoff for abstractions, but I’d want to avoid having to do that if possible. Worse comes to worse I will probably inject a function into the repository layer which the repo will then execute in a transaction, thereby avoiding the leaky abstraction problem.