Search code examples
oopentitydomain-driven-designrelationshipinvariants

Entity Relationship Design: Mutually Exclusive Has-A Relationship


I'm determining the best design approach for an object that can have a relationship with different types of other objects. But can only belong to one type at a time. A mutually-exclusive one-to-many has-a relationship is the best phrase I can think of to describe the situation.

Example:

I have both Projects and Procedures. Each can contain multiple Tasks. A Task can belong to a Project or a Procedure, but never more than one at the same time. A Task can be moved from a Project to a Procedure and vice-versa.

So at any one time Task has a one-to-many relationship with a Project, or a one-to-many relationship with a Procedure.

At the moment, I'm considering a Task as such:

   class Task(id, name, project_id, procedure_id):
       if project_id and procedure_id:
           throw Exception("A Task can not be assigned to a Project and a Procedure at the same time")

       self.id = id
       self.name = name
       self.project_id = project_id
       self.procedure_id = procedure_id

   def move_to_project(project_id):
       self.procedure_id = None
       self.project_id = project_id

   ...

Task would protect the invariant that it can only belong to a a single Project or Procedure. Factory methods on Project and Procedure can create the Task with the relevant relationship ID.

A Task is not in the same aggregate with either, so I want to model the relationship by identity.

It seems to me that the only way to protect this invariant is to model Task this way. Otherwise I'd end up with a giant Procedures/Projects/Tasks aggregate.

Does this seem like a sane approach or anyone have any advice on alternative design approaches that might be worth exploring?


Solution

  • You should have TaskService.AssignToProject() and TaskService.AssigneToProcedure() methods on TaskService application layer. If Task is a separate aggregate, it means it can live its own life without Procedure or Project. They can be empty for some period of time. ProcedureId or ProjectId should be assigned when TaskService.AssignToProject() or TaskService.AssigneToProcedure() are called. So in your TaskService.AssignToProject() you get Task and Project from db, and call task.AssignTo(project). Then you verify if ProcedureId is null and do what you need (throw an exception or UnassignFromProcedure()). After assignment is done, you raise TaskAssignedToProject domain event, catch it and in ANOTHER TRANSACTION call ProjectService.CommitTask(). That whould add that task to an internal collection of CommittedTasks on Project aggregate.