It all depends on how you define a "notification". Most commonly, it is considered to be a specific message sent to a specific user. This would lead you to a setup akin to:
User 1->* Notification
But you seem to be defining a notification as a reusable message, no matter how many people it is send to, i.e.:
User *<->* Notification
Or, more in detail:
User 1->* UserNotification *<-1 Notification
Notice I didn't use NotificationDelivered
as I don't like that name. It suggests it's an event, but you're using it as a cross table, so I picked the default cross table name.
This leads my to believe that my domain might be too coupled
Actually, it's quite the opposite. Your domain is very uncoupled, which is why you're having to perform an additional step, which takes additional overhead. Domains which are too coupled tend to have the "benefit" that it's much easier to jump from one to the other, but it comes are many different costs down the line.
There's nothing wrong with your approach, but it does add an additional layer of complexity in favor of saving some space.
This is also complicating the use of repositories, since I have a repository for each aggregate and this would always involve two, possibly costly, transitions with the DB.
That is always the case when you deal with a many-to-many, especially when the cross table contains more data than just the two references. It's always going to either be one large join query across three tables, or two smaller queries, one of which is a join to the cross table.
There's different ways of approaching this, but it always boils down to roughly the same db interaction.
Remember that pure DDD does not prioritize DB performance.
In pure DDD, an aggregate is always fetched in its entirety, no matter how many tables it's spread across, and no effort is made towards writing many varied queries which each fetch a different subset of the aggregate's information.
This isn't always the most db-performant, as you may be fetching data from a related table that you don't end up using, but it does dramatically simplify the domain logic itself (no null checks and subsequent additional data fetching, little to no NRE possibilities), which is the prime focus of (pure) DDD.
In other words, DDD favors clarity of the domain logic over performance of the database (but there are always reasonable limits of course), and the decision you've made to separate UserNotification
from Notification
is a db-performance decision, so you've slightly realigned your DDD priorities for yourself.
Next up: how do you define your aggregates? Is UserNotification
part of User
, Notification
, or is it its own root?
That's not something I can give you a universal answer to. It very much depends on what your domain contains and primarily focuses on. If either aggregate root is already very heavy or deeply involved in your domain, you're going to want to err towards not also pushing UserNotification
into that domain.
Let's briefly discuss how I'd approach it in all three scenarios:
[User + UserNotification] + [Notification]
Here, when fetching the user you already know the notification references involved, so the only additional query to launch would be notificationService.GetByIds()
[User] + [UserNotification + Notification]
Here, the user doesn't know anything about its notifications, that's under the purview of the notification subdomain. So at the very least, you'll have to call something along the lines of notificationService.GetForUser()
.
Internally, that method will either be launching two queries, but more likely you're going to launch a single query that joins the UserNotification
and Notification
tables.
[User] + [UserNotification] + [Notification]
Here, you've painted yourself in the corner of always having to do two separate calls, as the UserNotification
and Notification
are in two separate subdomains, and therefore should not be joined in a single query (by intention of having made them separate aggregates)
You'll have to call something along the lines of userNotificationService.GetForUser()
and then a second call along the lines of notificationService.GetByIds()
.
I can't tell you how to draw these lines, because it's so very contextual based on which features your application has and how you expect them to evolve. Just as a few examples:
- For a HR application, I would expect there to be many user-related features, and therefore I'd be inclined to not stack every reference in the
User
aggregate.
- For an alerting/monitoring tool, I would expect there to be many notification-related features (not all of them relating to users specifically) and therefore I'd be inclined to not stack every reference in the
Notification
aggregate.
- If a
UserNotification
has a significant lifetime, e.g. containing an entire structure of statuses, a persistent comment thread, ... then I'd be inclined to make UserNotification
its own aggregate root so as not to push that complex root into the existing User
or Notification
subdomain.
The choice is yours.
As an aside, given the tendency for "notification" to mean a specific message to a specific user, I'd personally be inclined to change the names here to something like :
User 1->* Notification *<-1 NotificationTemplate
But that's purely a naming argument, and not relevant for the structure of your code.