Monday, February 7, 2011

No, no, no, don’t put read concerns there!

So Scott brought up a good point in my "it's not about the data" post. He pointed out that the rules for what we display are mixed up in the domain.

He’s right, take a look at this code.

   1:    public void Visit(DateTime? when = null)
   2:          {
   3:              when = when ?? DateTime.Now;
   4:              if (_lastVisit.Date != DateTime.Today)
   5:                  DomainEvents.Raise(new VisitorForFirstTimeInDay(WebIdentifier,when.Value));
   6:              
   7:              _lastVisit = when.Value;
   8:   
   9:          }


If you think about it, the ONLY reason we’re raising that event is because someone said, “Hey, I wanna count the number of visitors per day”. What if they said, “Hey, I wanna count the number of visitors per hour” or minute, or month? That code would get really ugly, really quick. The reason is that it’s making a decision that is ONLY a read model concern. There is NO point in doing that. It’s just as bad, and arguably worse, than just putting the read concerns as properties on the domain model. It defeats the whole purpose of CQRS.


Let’s fix it.


Really, what the customer cares about is how many visitors he gets. He wants (right now) one report based on some times stuff.


Instead, lets just raise and event that lets other things know that the site was visited, and we’ll make Reporters understand the report they need to write.


New Unit test.


   1:   [Test]
   2:          public void a_visit_raises_the_SiteVistedEvent()
   3:          {
   4:              //Arrange
   5:              var when = DateTime.Now.AddDays(-1);
   6:              var sessionId = String.Empty;
   7:              var eventTime = DateTime.MinValue;
   8:              DomainEvents.Register<SiteVisted>(@event=>
   9:                                                    {
  10:                                                        sessionId = @event.SessionId;
  11:                                                        eventTime = @event.When;
  12:                                                    });
  13:              
  14:              //act
  15:              _visitor.Visit(when);
  16:   
  17:              //assert
  18:              Assert.AreEqual(sessionId,_visitor.WebIdentifier);
  19:              Assert.AreEqual(when,eventTime);
  20:   
  21:          }

Ok, so now we end up ripping out some code from the Visit method, and instead just raise the appropriate event.


   1:  public void Visit(DateTime? when = null)
   2:          {
   3:              var date = when ?? DateTime.Now;
   4:              DomainEvents.Raise(new SiteVisted(WebIdentifier, date));
   5:   
   6:              _lastVisit = date;
   7:   
   8:          }

And…


capture


Ok… Now lets go fix that reporter to handle the new event.


Test…

   1:     [Test]
   2:          public void the_reportwriter_records_unique_visitor_for_his_first_visit()
   3:          {
   4:              var sessionId = "THESESSION";
   5:              var reallyEarly = DateTime.Today.AddHours(1);
   6:              var theEvent = new SiteVisted(sessionId, reallyEarly);
   7:   
   8:              _reporter.Handle(theEvent);
   9:   
  10:              var visitorsToday = _visitorsByDateRepository.GetReportFor(DateTime.Today).UniqueVisitors;
  11:              Assert.AreEqual(1,visitorsToday);
  12:          }
  13:          
  14:         
  15:          [SetUp]
  16:          public void Arrange()
  17:          {
  18:              _visitor = new Visitor("SomeSessionId");
  19:              _visitorsByDateRepository = new VisitorsPerDayRepository();
  20:              _siteVisitRepository = new SiteVisitRepository();
  21:   
  22:              _reporter = new VisitorsPerDayReportWriter(_siteVisitRepository,_visitorsByDateRepository);
  23:          }



Here’s how I made it pass.

   1:  public class VisitorsPerDayReportWriter : IDomainEventHandler<SiteVisted>
   2:      {
   3:          private readonly SiteVisitRepository _repository;
   4:          private readonly VisitorsPerDayRepository _reportRepository;
   5:   
   6:          public VisitorsPerDayReportWriter(SiteVisitRepository repository, VisitorsPerDayRepository reportRepository)
   7:          {
   8:              _repository = repository;
   9:              _reportRepository = reportRepository;
  10:          }
  11:   
  12:   
  13:          public void Handle(SiteVisted @event)
  14:          {
  15:              if (_repository
  16:                  .Get()
  17:                  .Any(s => s.When.Date == @event.When.Date && s.SessionId == @event.SessionId)) return;
  18:   
  19:              _reportRepository.GetReportFor(@event.When).UniqueVisitors++;
  20:   
  21:          }
  22:      }



I won’t bother showing you the two repos, just know that GetReportFor will always return an instance of whatever that entity is (it’ll just create one if it’s not there yet).


Now, I’m pretty sure this isn’t done, and lets just prove that with another test.


   1:  [Test]

   2:          public void the_reportwriter_does_not_double_count()

   3:          {

   4:              var sessionId = "THESESSION";

   5:              var reallyEarly = DateTime.Today.AddHours(1);

   6:              var theEvent = new SiteVisted(sessionId, reallyEarly);

   7:              var atNoon = reallyEarly.AddHours(11);

   8:   

   9:              _reporter.Handle(new SiteVisted(sessionId,reallyEarly));

  10:              _reporter.Handle(new SiteVisted(sessionId,atNoon));

  11:   

  12:              var visitorsToday = _visitorsByDateRepository.GetReportFor(DateTime.Today).UniqueVisitors;

  13:              Assert.AreEqual(1, visitorsToday);

  14:          }

 

And NOPE… just like I thought “Expected 1, Actual 2”. Let’s go fix that really quick.

 

What we’re gunna do is add another handler to record the site visit. It looks like this.

   1:   public class VisitRecorderService : IDomainEventHandler<SiteVisted>
   2:      {
   3:          private readonly SiteVisitRepository _siteVisitRepository;
   4:   
   5:          public VisitRecorderService(SiteVisitRepository siteVisitRepository)
   6:          {
   7:              _siteVisitRepository = siteVisitRepository;
   8:          }
   9:          public void Handle(SiteVisted @event)
  10:          {
  11:             
  12:              _siteVisitRepository.Add(new SiteVisit(@event.SessionId,@event.When));
  13:          }
  14:      }

Next, since we’re not sure what order events are handled, I added a convenience method to my SiteVisit repo that’ll exclude the SiteVisit that would be generated for a SiteVisit event. It looks like this.


   1:  public IQueryable<SiteVisit> GetWithoutIncluding(SiteVisted @event)
   2:          {
   3:              return _siteVisits.Where(sv => sv.SessionId != @event.SessionId || sv.When != @event.When).AsQueryable();
   4:          }

Now, I need to go fix the test to execute the the VisitRecorderService, here’s what it looks like when done.


   1:  [Test]
   2:          public void the_reportwriter_does_not_double_count()
   3:          {
   4:              var sessionId = "THESESSION";
   5:              var reallyEarly = DateTime.Today.AddHours(1);
   6:              var theEvent = new SiteVisted(sessionId, reallyEarly);
   7:              var atNoon = reallyEarly.AddHours(11);
   8:              // An IOC container will locate all handlers, but we know that they'll all 
   9:              // get located so lets manually execute them
  10:              var event1 = new SiteVisted(sessionId, reallyEarly);
  11:              _reporter.Handle(event1);
  12:              _visitRecorder.Handle(event1);
  13:              var event2 = new SiteVisted(sessionId, atNoon);
  14:              _reporter.Handle(event2);
  15:              _visitRecorder.Handle(event2);
  16:   
  17:              var visitorsToday = _visitorsByDateRepository.GetReportFor(DateTime.Today).UniqueVisitors;
  18:              Assert.AreEqual(1, visitorsToday);
  19:          }

Note that I don’t like writing unit tests like this, I’d typically mock out the behavior of the SiteVisit repository, but for the purpose of this blog post, this is good enough.


Now, I just change my report writer to this.


   1:   public class VisitorsPerDayReportWriter : IDomainEventHandler<SiteVisted>
   2:      {
   3:          private readonly SiteVisitRepository _repository;
   4:          private readonly VisitorsPerDayRepository _reportRepository;
   5:   
   6:          public VisitorsPerDayReportWriter(SiteVisitRepository repository, VisitorsPerDayRepository reportRepository)
   7:          {
   8:              _repository = repository;
   9:              _reportRepository = reportRepository;
  10:          }
  11:   
  12:   
  13:          public void Handle(SiteVisted @event)
  14:          {
  15:              if (_repository
  16:                  .GetWithoutIncluding(@event)
  17:                  .Any(s => s.When.Date == @event.When.Date && s.SessionId == @event.SessionId))
  18:              {
  19:                  return;
  20:              }
  21:   
  22:              _reportRepository.GetReportFor(@event.When).UniqueVisitors++;
  23:   
  24:          }

 
And BAM… 
capture

Hope this helps someone. Let me know if it does (or doesn’t).

No comments:

Post a Comment