When discussing "domain" layers, one of the most common questions/confusions is between what constitutes domain logic and application logic, as these layers are now separate.
I blame the past development scene for introducing the amalgam called "business logic" that lumped domain and application logic together, and I strongly suspect that developers who were taught about "business logic" struggle to now separate what should actually be domain/application logic.
Skipping ahead a few steps here, my attempt at defining the distinction is this:
Domain logic is a "button" that, when you press it, performs a well-defined actions based on provided parameters. It is used to define specific behaviors of your service.
Importantly, domain logic is built in a clean room. It does not distract itself with second-guessing the input it receives, except if second-guessing is the domain object's sole purpose (e.g. a validator).
Application logic is the orchestration of service behaviors in order to correctly service the request that triggered it. If your domain logic is a button, then the application logic is the operator who knows which buttons to push when and how. This sounds vague, and it somewhat is because the content of the orchestration is highly contextual and can take many different forms.
Importantly, application logic is the shield that cleans the input so that it can be handed off to the domain logic, because the domain logic need to operate in a clean room.
This brings us on to validation, where does it belong?
Well, annoyingly, it's not that simple. Different kinds of validations exist, and they belong in different places. Validating if the user ID value you received is actually a GUID (and not the string "banana"), validating if the user ID refers to an actually existing user, and validating if this user is able to befriend the other user are different responsibilities of different layers.
Therefore, it's not as straightforward to answer this. Some of these validations are so trivial that encapsulating them in an additional validation layer may be one layer too many, or it might be very necessary if this codebase intends to grow in the future. I cannot judge that.
You want to validate early so as to prevent unnecessary work that could have been avoided. Your current example lands somewhere in the middle, it can't be done on the API level as it requires deeper access, but at the same time you don't want to do it in the deeper domain level as it's very likely that this means you've been loading a lot of data without even doing a basic sanity check on the input. You need to balance these considerations.
Without further details on your specific context, I suggest defaulting to defining the validators on the application level, and using them as the "cleaners" before you hand things off to your domain logic. This comes with a few benefits:
- You can group all your validation rules for one service method/command/..., effectively giving you the ability to define specific validation for specific endpoints, with reusability in mind.
- The application layer has access to the infrastructure layer, which you're going to need to make use of if your validation is stateful (i.e. comparing to existing state) and not just a pure function.
- It remains possible for an application-level validator to implement a lower level domain method that performs a domain-specific operation that is needed in order to confirm the validation.
- For example, if this is a matchmaking application, you might only allow friendships between users if they meet a minimum match "score" based on your proprietary matchmaking calculations. If so, then the actual calculations that yield the score are domain logic, and the actual minimum target is either domain or config logic. However, the actual check if the given score meets the given minimum value is a concern for the application-level validator.
Whether you allow your validator to call your persistence layer, or if you prefer to pre-fetch the data and pass it to the validator, is up to you. I prefer the former, but not everyone does.
It's not a hill I'm going to die on, but my personal opinion here is that an application-level service is allowed to access a persistence-level datastore, then the application-level validator is allowed to access that same persistence-level datastore. The access is regulated across the whole layer, I don't bother making within-layer distinctions on who gets to access what.
Lastly, I'm going to point out briefly that performance is a real world consideration that matters.
Things like the repository pattern make a lot of sense for in-memory collections, or data stores that you can access with a negligible cost.
However, data sizes have grown and, at the same time, data store technology has become significantly better at locally optimizing itself. This leads us to the conclusion that it makes no sense to pull massive lists of data into memory to perform a trivial operation on them.
It's important to consider, however, that application logic patterns tends not to prescribe offloading such work to the data store, because this query logic can be describe to be application logic. We must instead understand that the performance considerations are an additional priority which pushes us away from having the application logic control all of the actual logic.
So let's get to your actual example.
- The user must not already be your friend
- You and the other user must not have common friends
- In Bucharest it must be raining
First, let's look at the persistence layer to optimize for data store performance. Each requirement maps to a very specific data operation.
- Persistence client
IUserRepository with method:
User GetFriend(int userId, int friendId)
null if the user does not have this friend.
- You can return a bool if you prefer. I don't think loading a single entry is meaningfully wasteful so I prefer not having both a "return the object" and a "return if the object exists" method, but this is your call to make.
- Persistence client
IUserRepository with method:
User FindUsersWithTheseFriends(int friendIds)
- A "common" friend is a friend who has both of these users in their friend list. Therefore, this method finds any people who have all of the given users in their friend list.
- Same comment about returning a bool versus returning the actual result. Your call.
- Persistence client:
IWeatherService with method
WeatherInfo GetCurrentWeatherFor(Location l)
- I'm ignoring the details here and assuming that you have an external API which tells you the weather in a given location.
Next up is the application layer:
- Injects a
IUserRepository and an
- Has a
Validate(int userId, int friendId) method to perform the validation.
- During validation, calls the above methods and confirms that the output is what you expect it to be.
UserService with a
Befriend(int userId, int friendId) method
- Has access to a
BefriendValidator and calls it
- If not valid, returns negative response.
- If valid, it instantiates the
User(userId) domain object and calls its
- Returns the outcome of the befriending.
Lastly, we haven't discussed the internals of the
User domain object, but this is much too broad for me to answer for you. There are myriad possibilities here, depending on what kind of application you are building.
- Does the friend need to consent to being a friend?
- Is friendship a one-way or two-way relationship?
- Does your application calculate a friendship score and subsequently categorizes what kind of friendship this is?
- Is there any comms sent to either the user or the friend about this?
Your domain object does whatever your application is designed to do, and because the sanity check has already occurred in the application layer, it is able to simply do the work without needing to second-guess the input that it received. If it blows up, then that's a failing on the application layer's behalf of not having sufficiently sanity-checked the input.