Design – Entities vs. Services in domain driven design (DDD)

designdomain-driven-designobject-oriented-design

I wanted to get some feedback on the design of a chat application I recently developed. The application is written in PHP, but the language probably isn't important here.

The main components are User, Conversation, and Message:

class User {
    public function getId(): int {}
    public function getUsername(): string {}
    public function getFirstName(): string {}
    public function getLastName(): string {}

    // token used with a 3rd party API for sending messages
    public function getToken(): ?string;
    
    // if a user doesn't have a token they can't be messaged
    public function isOnline(): bool {}

    public function __construct(int $id, string $username, ...) {}
}

class Conversation {
    public function getId(): int {}
    public function getUsers(): User[] {}

    public function __construct(int $id, array $users) {}
}

class Message {
    public function getId(): int {}
    public function getText(): string {}
    public function getConversation(): Conversation {}
    public function getAuthor(): User {}

    public function __construct(int $id, string $text, Conversation $conversation) {}
}

I also have some services:

class MessageSender implements MessageSenderInterface
{
    private LoggerInterface $logger;

    public function send(Message $message): void {
        foreach ($message->getConversation()->getUsers() as $user) {
            if (!$user->isOnline()) {
                $this->logger->warn('User is offline and cannot be messaged');
            }

            if ($user->equals($message->getAuthor())) {
                // continue; don't send messages to authors
            }
            
            $messageData = [
                'to' => $user->getToken(),
                'from' => $message->getAuthor()->getUsername(),
                'text' => $message->getText(),
            ];
            // send the message through some external API
        }
    }
}

Most of the work is done through the MessageSender, but I'm wondering if the domain might be better encapsulated with something like this:

class Message {
    public function getId(): int {}
    public function getText(): string {}

    public function __construct(int $id, string $text, Conversation $conversation) {}

    public function send(MessageSenderInterface $sender, LoggerInterface $logger) {
        ... send logic in here
    }
}

You can see that by moving the send functionality inside of the Message object we totally get rid of two exposed properties (getConversation and getAuthor are gone) and could effectively remove the service altogether. But as a result, the message object now knows about loggers and message senders, even if they are just interfaces.

What does DDD say about this? I tend to prefer exposing less data and like the encapsulation the second option provides.

Best Answer

Some DDD practitioners suggest that it is ok to inject technology layers dynamically into your domain model. Modeled that way, your domain code will not be directly dependent on technology components and only talk through abstract interfaces.

But I would suggest that you keep only the domain related code in the domain model layer and organize all technology interactions (DB, Message Brokers, Loggers) in your service layer. These are typically Application Services and Command Handlers in the DDD/CQRS lingo.

Here are some reasons why placing code that interacts with technology components in the domain model is probably a bad practice:

  1. You use DDD to reduce complexity, but injecting technology aspects into your domain model will obscure your vision of real business logic. It will get lost in translation when most of your code handles technology concerns like loading and persisting data or sending messages.
  2. Your domain model ends up having some knowledge about workflows in your technology components. It knows when to persist data, how to persist data, and when to send messages, among other stuff.
  3. You will need a different layer anyway to perform validations spanning the aggregate collection (checking for uniqueness of email address, for example).
  4. Your domain cannot be tested in isolation. You are always organizing technology components (or mocks) when you test domain logic.

Mapping this thought process to your example, the logic to decide whether to send the message would be in the domain layer. The code to format the event data and dispatch to the message broker would be in the service layer.

On a separate note, one wouldn't organize these three components (User, Conversation, and Message) in this way in a DDD application. You would think about transaction boundaries and create aggregates around data objects.

A User would be an aggregate, with its own enclosed objects and behavior. A Conversation would be another aggregate and enclose Message objects within it, and all message related interactions would be via the Conversation aggregate.

Because they are separate aggregates, you would not embed User objects into a Conversation aggregate. You would only have references (user identifiers). You would have a read model that tracks which users are online in a Conversation and use it to dispatch messages.

I suggest you go through the EventSourcery course for a good understanding of these concepts. The code for the course is actually in PHP.


Update 1:

Your Message object is reaching back to the Conversation object to gather users to do its work, so it makes sense to enclose it within the Conversation object.

I will talk about two concepts that may not be part of your architecture right now but would help: Application Services and Domain Events.

You would introduce an intermediate "Application Service" layer between your controller and the Domain Layer.

The App Service will be responsible for invoking the (injected) infrastructure services, calling the domain layer, and persisting/loading necessary data. The Controller's responsibility is only to gather the request params (gather user input in your case), ensure authentication (if needed), and then make the call to the Application Service method.

Application Services are the direct clients of the domain model and act as intermediaries to coordinate between the external world and the domain layer. They are responsible for handling infrastructure concerns like ID Generation, Transaction Management, Encryption, etc. Such responsibilities are not concerns of the Controller layer either.

Let's assume MessageSender is transformed into an Application Service. Here is an example control flow:

  1. API sends the request with conversation_id, user_id (author), and message.
  2. Application Service loads Conversation from the database. If the Conversation ID is valid, and the author can participate in this conversation (these are invariants), you invoke a send method on the Conversation object.
  3. The Conversation object adds the message to its own data, runs its business logic, and decides which users to send the message.
  4. The Conversation object raises events, to be dispatched into a message interface (these are collected in a temporary variable valid for that session) and returns. These events contain the entire data to reconstruct details of the message (timestamps, audit log, etc.), and don't just cater to what is pushed out to the receiver later.
  5. The Application Service persists the updated Conversation object and dispatches all events raised during the recent processing.
  6. A subscriber listening for the event gathers it, constructs the message in the right format (picking only the data it needs from the event), and performs the actual push to the receiver.

With this structure, you have a good implementation of the Open-Closed Principle.

  1. Your Conversation object changes only if you are changing business logic (like who should receive the message).
  2. Your Application service will seldom change because it simply loads and persists Conversation objects and publishes any raised events to the message broker.
  3. Your Subscriber logic changes only if you are pushing additional data to the receiver.

Update 2: Pseudocode

Application Service:

class MessageSender(ApplicationService):
    def send_message(request):
        // Deconstruct request object and call method
        conversation = ConversationRepo.find_by_id(request.id)

        // Call a method on the aggregate that generates events and updates the aggregates state
        conversation.send_message(request.from_user_id, request.content)

        // Application Service saves the aggregate
        ConversationRepo.save(conversation)

        // Any events raised are dispatched once the conversation has been successfully saved
        for event in conversation.events:
            message_interface.dispatch(event)
        

Domain Model:

class User(Aggregate):
    id: int
    username: str
    first_name: str
    last_name: str

    token: str
    is_online: bool

class Message(Entity):
    id: int
    author_id: int
    content: str
    sent_at: time

class Conversation(Aggregate):
    id: int
    users: list
    messages = list

    events = list // not persisted

    def send_message(from_user_id, content):
        for user in self.users:
            if not user.is_online:
                logger.warn("User is offline and cannot be messaged")
            
            if user.id == from_user_id:
                // continue; do not send messages to authors

            messages.append(Message(author_id=from_user_id, content=content, sent_at=datetime.utcnow()))
            self.events.add(SendMessageEvent(to=user.token, from=user.username, text=content))
Related Topic