Here is an interesting problem I was faced with recently while working on a Case Management system. The core entity of this system is the Case, which represents a group of people working on a single incident or investigation. Each case is made up of a number of Activities, and these in turn are made up of a number of Tasks. A requirement of the system is that Cases, Activities and Tasks can all have notes attached to them. When the user asks for the notes against a case they want to also see all the notes attached to the case’s activities and tasks. The model looks like this:
There were other requirements that also needed us to collect information from the entire aggregate. The functionality to navigate the aggregate - to go from a case to its activities and then to its tasks - needed to be separate from whatever we needed to do at each entity. In the notes case, we wanted to collect all the notes, but when calculating the time taken on a case, we wanted to add up all the time taken on each individual task.
This is a perfect scenario for the visitor pattern. It is designed to separate out the navigation of relationships from the action that needs to be performed at each node.
All our domain entities implement a layer supertype called Entity. We added the ability for the entity to accept a visitor and for child collections to be registered:
public interface IEntity { TVsistor AcceptVisitor<TVsistor>(TVsistor visitor) where TVsistor : IDomainVisitor; } public class Entity : IEntity { private IEnumerable<IEntity> childEntities; public TVsistor AcceptVisitor<TVsistor>(TVsistor visitor) where TVsistor : IDomainVisitor { visitor.Visit(this, VisitChildren); return visitor; } protected void RegisterChildCollection(IEnumerable<IEntity> childCollection) { childEntities = childEntities == null ? childCollection : childEntities.Concat(childCollection); } private void VisitChildren(IDomainVisitor visitor) { if (childEntities == null) return; foreach (var childEntity in childEntities) { childEntity.AcceptVisitor(visitor); } } }
The Domain Visitor interface is very simple, a Visit method accepts an entity to visit and a visitChildren delegate so that the visitor can optionally visit the entity’s children in whatever order it wants:
public interface IDomainVisitor { void Visit(IEntity entity, Action<IDomainVisitor> visitChildren); }
Here is our domain model, notice that we register Activities as a child collection of Case and Tasks as a child collection of Activity. We could also have registered each Notebook’s Notes collection as well, but so far there’s no requirement to include notes in any of the traversals of case. Note also the Notes property of Case. It creates and accepts a NotebookVisitor (see below) that collects all the notes from the graph:
public class Case : Entity, IHaveNotes { private readonly IList<Activity> activities = new List<Activity>(); public IList<Activity> Activities { get { return activities; } } public Case() { RegisterChildCollection(Activities); } public Notebook Notebook { get; set; } public IEnumerable<Note> Notes { get { return AcceptVisitor(new NotebookVisitor()).Notes; } } } public class Activity : Entity, IHaveNotes { private readonly IList<Task> tasks = new List<Task>(); public IList<Task> Tasks { get { return tasks; } } public Activity() { RegisterChildCollection(Tasks); } public Notebook Notebook { get; set; } } public class Task : Entity, IHaveNotes { public Notebook Notebook { get; set; } } public interface IHaveNotes { Notebook Notebook { get; set; } } public class Notebook { private readonly IList<Note> notes = new List<Note>(); public IList<Note> Notes { get { return notes; } } } public class Note { public string Text { get; set; } }
Finally here’s the NotebookVisitor, it simply checks if the currently visited entity has notes and then appends them to its notes collection:
public class NotebookVisitor : IDomainVisitor { public IEnumerable<Note> Notes { get; private set; } public void Visit(IEntity entity, Action<IDomainVisitor> visitChildren) { var iHaveNotes = entity as IHaveNotes; if (iHaveNotes != null) { Notes = Notes == null ? iHaveNotes.Notebook.Notes : Notes.Concat(iHaveNotes.Notebook.Notes); } visitChildren(this); } }
Here’s a test that shows all this working:
public void NotebookVisitor_visits_all_notes_in_graph() { Func<string, Notebook> createNotebook = text => new Notebook { Notes = {new Note {Text = text}} }; Func<string, Task> createTask = text => new Task { Notebook = createNotebook(text) }; var @case = new Case { Activities = { new Activity { Tasks = { createTask("Task 1 note"), createTask("Task 2 note") }, Notebook = createNotebook("Activity 1 note") }, new Activity { Tasks = { createTask("Task 3 note"), createTask("Task 4 note") }, Notebook = createNotebook("Activity 2 note") } }, Notebook = createNotebook("Case 1 note") }; foreach (var note in @case.Notes) { Console.WriteLine(note.Text); } }
Which prints out:
Case 1 note Activity 1 note Task 1 note Task 2 note Activity 2 note Task 3 note Task 4 note
If you have lots of repeated code in your application that has loops within loops navigating down object graphs in order to collect some information or perform some action, take a look at the visitor pattern, it may save you a lot of typing.
7 comments:
There is a typo there Mike. It should be IHazNotes.
Surely ICanHazNotes is the true name..
why not just use an ObservableCollection with override of NotifyPropertyChanged? to it notifies parent event when properties on collection items changes?
Hi Ben, Anonymous(1)
Excellent suggestion, I've just gone and LOLCoded my entire source :)
Hi Anonymous(2),
So you would have the Case maintain a copy of the article notes, and the article maintain a copy of the taks notes? No, I don't like it.
Great article, thanks! =)
My question isn't so much to do with the article, though, and more to do with the model image - what did you use to make it?
I think Task is a kind of Activity, not a "has many" relationship. We often have other activities such as Call, Email, Chat, Visit.
Hi Erik,
The model image is just a screen shot of the visual studio class designer. In visual studio, right click on a project in the solution explorer, choose add -> new item, and then choose 'Class Diagram'. The really nice thing is that not only does it update automatically when you change the code, but if you change the diagram, the code changes. Great for architecture astronauts :)
Post a Comment