DDD functional way: Why is it better to decouple state from the behavior when applying DDD with functional language?
Asked Answered
M

2

8

I've read several articles (also the book Functional domain modeling) where they propose to decouple state of the domain object from the behavior, but I cannot understand advantage of such approach over reach domain model.

Here is an example of reach domain model:

case class Account(id: AccountId, balance: Money) {
  def activate: Account = {
   // check if it is already active, eg, enforce invariant 
   ...
  }
  def freeze: Account = ???
} 

I can chain operations with this account in following way:

account.activate.freeze

Here is example of "anemic" approach which they suggest:

case class Account(id: AccountId, balance: Money)

object AccountService {
  def activate =  (account: Account) => {
   // check if it is already active, eg, enforce invariant 
    ...
  }

  def freeze =  (account: Account) =>   {
    ...     
  }
}

And here I can chain operations like this

activate andThen freeze apply account

What is the advantage of the second approach except of "elegant" syntax?

Also, in case of reach domain model, I will enforce invariants in single class, but in case of "anemic" model, logic/invariants can spread across services

Mahau answered 1/7, 2019 at 13:4 Comment(0)
D
3

I offer two thought processes, that can help explain this puzzle:


The concept of state in your example and the book differ. (I do hope we both are referring to Functional and Reactive Domain Modeling).

Your example states of activate, and freeze are probably domain concepts, while the book talks about states that only serve as markers. They do not necessarily have a role in the domain logic and exist only to disambiguate states of the workflow. Ex. applied, approved and enriched.


Functional programming is all about implementing behaviors, that are independent of the data passed into them.

There are two aspects of note while implementing such behaviors.

A behavior can be reusable across contexts. It can be an abstract trait, a monoid if you will, that takes any type T, and performs the same operation on it. In your example, freeze could be such a behavior, applicable to Account, Loan, Balance, etc.

The behavior has no side effect whatsoever. One should be able to call the behavior again and again with the same data set and receive the same expected response without the system getting affected or throwing an error. Referencing your example, calling freeze repeatedly on an account should not throw an error.

Combining the two points, one could say it makes sense to implement a behavior as a reusable piece of code across different contexts (as a Service) while ensuring that the input is validated (i.e., validate the state of the object provided as input before processing).

By representing the acceptable state of the object as a separate type and parameterizing the model/object with this explicit type, we could enforce a static check of input, during compile time. Referring to the example provided in the book, you can only approve andThen enrich. Any other incorrect sequence will raise a compile-time error, which is far more preferable to using defensive guards to check input during runtime.

Thus, the second approach is not just elegant syntax at the end of the day. It is a mechanism to build compile-time checks, based on the state of an object.


So, while the output has the appearance of an anemic model, the second approach is taking advantage of some beautiful patterns bought forth by functional programming.

Distorted answered 2/7, 2019 at 6:19 Comment(0)
P
2

One advantage might be being able to add another link to the chain without having to modify and recompile domain model. For example, say we wanted to add another validation step to check for fraud

object AccountService {
  def fraud = (account: Account) => ...
}

then we could compose this step like so

(fraud andThen activate andThen freeze)(account)

Conceptually, adding fraud validation step did not mutate the structure of the domain model case class Account, so why bother re-compiling it? It is a form of separation of concerns, where we want to narrow down the changes to the codebase to the minimal relevant part.

Plenty answered 1/7, 2019 at 13:29 Comment(3)
Still cannot get advantage. What's the problem of recompiling domain model? If I add new method to Account model that's mean that it's a part of ubiquitous language of Account, cannot see issue here. To me, adding fraud to the service layer method seems like just moving logic in to another place, same can be achieved just adding new method to Account, plus everything will be in the one, central place, within Account which all this logic belongs to, enforcing invariants and logic will not spread across several places so seems like reach model is more intuitive and "clear" approach.Mahau
Probably froud logic does not belong to Account Aggregate itself, but to Domain Service: AccountService.froud(account).activate.freeze.. But still, what's the advantage of (fraud andThen activate andThen freeze)(account) over former approach?Mahau
fraud (as well as freeze an activate by the way) is not something essential to account. You have dozens of opinionated ways to implement it. If you put it into Account all the services that use it will depend on e.g. machine learning libraries.Sugar

© 2022 - 2024 — McMap. All rights reserved.