I would think the answer to my question is yes, but I have never really seen it in practice or any OSS project (and links to any OSS sample projects are highly welcome because the typical examples lack a lot of domain logic for me).
So, my specific case:
- My application is mostly network-driven, that is, the UI is just a web API
- If a request comes in, some logic is applied that can be described on an abstract level that a domain expert should understand
- To actually do all the steps involved by the logic, external services are used (an external account management service)
My thinking to design this was:
- Put the main logic into the domain layer by just calling interface methods
- The interface is implemented on the application layer
- My domain layer is therefore completely isolated and the application layer depends on it by implementing the service
- In addition, the request handler (from the server, also application layer) will call my domain logic
Okay, so this is my domain pseudo code (Golang):
package domain
// all dates expect format Y-m-d (2022-12-01)
type HelperFunctions interface {
getEMailConvertedToAccountName(string) string
isDateInPast(string) (bool, error)
}
type AccountService interface {
doesAccountExist(accountName string) bool
createNewAccountObjects(accountName string, validUntil string) error
updateAccountObjects(accountName string, validUntil string) error
removeRelatedAccountObjects(accountName string) error
retrieveUserIdentifier(accountName string) (string, error)
}
type UpsertUserJWTData struct {
EMail string `json:"email"`
ValidUntil string `json:"valid_until"`
}
func UpsertUserAndGetIdentifierIfValid(data UpsertUserJWTData, helperFunctions HelperFunctions, as *AccountService) (string, error) {
accountName := helperFunctions.getEMailConvertedToAccountName(data.EMail)
dateInPast, err := helperFunctions.isDateInPast(data.ValidUntil)
if err != nil {
return "", err
}
if dateInPast {
err := handleExpiredAccount(accountName, as)
return "", err
} else {
if err := handleValidAccount(accountName, data.ValidUntil, as); err != nil {
return "", err
}
identifier, err := as.retrieveUserIdentifier(accountName)
if err != nil {
return "", err
}
return identifier, nil
}
}
func handleExpiredAccount(accountName string, as *AccountService) error {
if as.doesAccountExist(accountName) {
err := as.removeRelatedAccountObjects(accountName)
return err
}
return nil
}
func handleValidAccount(accountName string, validUntil string, as *AccountService) error {
if as.doesAccountExist(accountName) {
return as.createNewAccountObjects(accountName, validUntil)
} else {
return as.updateAccountObjects(accountName, validUntil)
}
}
What I like about this is that I directly see the logic itself. Logging, access to the real external service, helper function calls to get environment variables or config stuff are all not present here. I could discuss the sequence of events with a domain expert that does not know about any details of the implementation.
I could also create an entity for the account itself, true. But it has no real relationships to other domain objects yet (might change it later). My questions here are:
- Do you think this makes sense?
- If yes, how is this called in DDD-language (or maybe in clean architecture, hexagonal, I could not find any fitting term)? I mean, it resembles a bit the template method pattern and it does not just operate on domain objects but rather it defines "what happens if" in abstract/domain terms but the actual doings are abstracted away... maybe it's rather a "use case service"? This term does not exist though, I think
- If this makes sense, why is it nearly impossible to find any examples where actual logic is implemented on domain layer in GitHub repos?
- Are needed helpers like "dateInPast" nicely moved away here? It requires a second interface to be added here, but it really reflects the logic
PS: Yeah, code is suboptimal, some details like JWT could be excluded, maybe also how the username is retrieved. Some crunching would improve it more... but I hope the scope of my question is clear.