Thursday, February 12, 2009 6:27 AM Kazi Manzur Rashid

Domain Model (Developing KiGG v2.0 Part 1)

As mention in my previous post that I will be discussing the technical side of KiGG. So this is the beginning and it will be a multi-part series. I will try to put as much detail as possible, do let me know if I missed anything.

Just for a recap, KiGG is Web 2.0 style social news application where I am trying to exercise some of the best practice like TDD, DDD, SOLID etc with Microsoft supported tooling. If you want to see it in action just visit http://dotnetshoutout.com.

KiGG is already a fully functional application, but here I am starting from scratch, so the actual code might not look the same with following but it will show you how it is evolved as I am going to post more on it over the time.

Let us begin with the core functionalities:

  • User should be able to submit story.
    • User will select a category and can specify multiple tags when submitting story.
    • User should not be able to submit a story which already exists (based upon Url).
    • The system will ensure the story is a .NET related story, otherwise it will need moderator approval prior make it visible to everyone.
  • User should be able to promote story.
    • When the story has not been promoted by him/her.
    • When the story has not been marked as spam by him/her.
  • User should be able to demote story.
    • When the story has not been submitted by him/her.
    • When the story has been previously promoted by him/her.
  • User should be able to mark story as spam.
    • When the story has not been promoted by him/her.
    • When the story has not been marked as spam by him/her.
    • When story is not published.
  • User should be able to post comment.
    • The system will ensure the comment is not a spam.
  • User should be able to subscribe/unsubscribe comments of a Story.
  • User should be able to Tag story.
  • User should be able to view the original story by clicking the link and the count should be maintained.
  • User Score should be increased/decreased based upon the above actions.
  • User should be able to view the story list by published/upcoming/category/tag/user etc.
  • Moderator should be able to Edit Story.
  • Moderator should be able to Delete Story.
  • Moderator should be able to Confirm a Story/Comment as Spam.
  • Moderator should be able to Approve a Story as not spam which was previously marked as spam.
  • Admin should be able to Lock/Unlock a User.
  • Admin should be able to change role of a User.
  • Admin should be able to Publish stories at periodic interval.

With the above functionalities we can come up with an object model which consists classes like User, Story, Tag, Category, Vote, MarkAsSpam, Comment:

                                               Pre-DomainObjects

Now, lets refine this domain model with TDD (I will skip the red part of red-green-refactor to save space of this post, but you should always exercise the red part while developing in this way).

Promoting

When promoting the story we have to check two things, user has not previously promoted it and not marked it as spam. Lets write the tests:

[Fact]
public void HasPromoted_Should_Return_False_When_User_Has_Not_Previously_Promoted()
{
    var user = new User();

    Assert.False(_story.HasPromoted(user));
}

[Fact]
public void HasMarkedAsSpam_Should_Return_False_When_User_Has_Not_Previously_MarkedAsSpam()
{
    var user = new User();

    Assert.False(_story.HasMarkedAsSpam(user));
}

And in Story:

public bool HasPromoted(User byUser)
{
    return Votes.Any(v => v.ByUser.Id == byUser.Id);
}

public bool HasMarkedAsSpam(User byUser)
{
    return MarkAsSpams.Any(m => m.ByUser.Id == byUser.Id);
}

Now, create a new method that internally calls these two methods, so that client can only call this single method instead of calling those, test:

[Fact]
public void CanPromote_Should_Return_True_When_Story_Has_Not_Been_Promoted()
{
    var user = new User { Id = Guid.NewGuid() };

    Assert.True(_story.CanPromote(user));
}

[Fact]
public void CanPromote_Should_Return_True_When_Story_Has_Not_Been_MarkedAsSpam_By_The_User()
{
    var user = new User { Id = Guid.NewGuid() };

    Assert.True(_story.CanPromote(user));
}

And in Story:

public bool CanPromote(User byUser)
{
    return !HasPromoted(byUser) && !HasMarkedAsSpam(byUser);
}

Now the actual promote, note that we have introduced a new property  Timestamp in Vote, tests:

[Fact]
public void Promote_Should_Return_True_When_User_Can_Promote()
{
    var user = new User { Id = Guid.NewGuid() };

    Assert.True(_story.Promote(DateTime.UtcNow, user));
}

[Fact]
public void Promote_Should_Add_User_Vote_In_Votes()
{
    var user = new User { Id = Guid.NewGuid() };

    _story.Promote(DateTime.UtcNow, user);

    Assert.True(_story.Votes.Count(v => v.ByUser.Id == user.Id) > 0);
}

And in Story:

private readonly List<Vote> _votes = new List<Vote>();

public ICollection<Vote> Votes
{
    get
    {
        return _votes.AsReadOnly();
    }
}

public bool Promote(DateTime at, User byUser)
{
    if (CanPromote(byUser))
    {
        Vote vote = new Vote
                        {
                            ByUser = byUser,
                            ForStory = this,
                            Timestamp = at
                        };

        _votes.Add(vote);

        return true;
    }

    return false;
}

Notice that I have exposed the Votes as read only collection, though the ReadOnlyCollection<T> of ObjectModel namespace should be more appropriate, but I would like to stick with the interface instead of concrete class. So adding vote in Votes will raise exception, the client always have to use the Promote method.

Demoting

Demoting is similar to promoting, we have to check that the User has not submitted the story and the story has been previously promoted by the user(You can only demote if you previously promoted it), tests:

[Fact]
public void CanDemote_Should_Return_True_When_Story_Has_Been_Promoted_By_The_User()
{
    //Promote it first
    var user = new User { Id = Guid.NewGuid() };
    _story.Promote(DateTime.UtcNow, user);

    Assert.True(_story.CanDemote(user));
}

[Fact]
public void Demote_Should_Return_True_When_User_Can_Demote()
{
    //Promote it first
    var user = new User { Id = Guid.NewGuid() };
    _story.Promote(DateTime.UtcNow, user);

    Assert.True(_story.Demote(user));
}

[Fact]
public void Demote_Should_Remove_User_Vote_From_Votes()
{
    //Promote it first
    var user = new User { Id = Guid.NewGuid() };
    _story.Promote(DateTime.UtcNow, user);

    _story.Demote(user);

    Assert.True(_story.Votes.Count(v => v.ByUser.Id == user.Id) == 0);
}

And in Story:

public bool CanDemote(User byUser)
{
    return (PostedBy.Id != byUser.Id) && HasPromoted(byUser);
}

public bool Demote(User byUser)
{
    if (CanDemote(byUser))
    {
        Vote vote = _votes.Single(v => v.ByUser.Id == byUser.Id);

        _votes.Remove(vote);

        return true;
    }

    return false;
}

Marking as Spam

Marking as spam is also similar to story promoting, only a new checking that is if the story is published, so we will need a new property which will indicate whether the story is published, lets create a new property PublishedAt of nullable DateTime and create an extension method:

public static bool IsPublished(this Story story)
{
    return (story.PublishedAt.HasValue);
}

we also introduced a new property Timestamp for MarkAsSpam like vote, tests:

[Fact]
public void CanMarkAsSpam_Should_Return_True_When_Story_Has_Not_Been_Published()
{
    var user = new User { Id = Guid.NewGuid() };

    Assert.True(_story.CanMarkAsSpam(user));
}

[Fact]
public void CanMarkAsSpam_Should_Return_True_When_Story_Has_Not_Been_Promoted_By_The_User()
{
    var user = new User { Id = Guid.NewGuid() };

    Assert.True(_story.CanMarkAsSpam(user));
}

[Fact]
public void CanMarkAsSpam_Should_Return_True_When_Story_Has_Not_Been_MarkedAsSpam_By_The_User()
{
    var user = new User { Id = Guid.NewGuid() };

    Assert.True(_story.CanMarkAsSpam(user));
}

[Fact]
public void MarkAsSpam_Should_Return_True_When_User_Can_Mark_As_Spam()
{
    var user = new User { Id = Guid.NewGuid() };

    Assert.True(_story.MarkAsSpam(DateTime.UtcNow, user));
}

[Fact]
public void MarkAsSpam_Should_Add_User_Marking_In_MarkAsSpams()
{
    var user = new User { Id = Guid.NewGuid() };

    _story.MarkAsSpam(DateTime.UtcNow, user);

    Assert.True(_story.MarkAsSpams.Count(m => m.ByUser.Id == user.Id) > 0);
}

And in Story

private readonly List<MarkAsSpam> _markAsSpams = new List<MarkAsSpam>();

public ICollection<MarkAsSpam> MarkAsSpams
{
    get
    {
        return _markAsSpams.AsReadOnly();
    }
}

public bool CanMarkAsSpam(User byUser)
{
    return !this.IsPublished() && !HasPromoted(byUser) && !HasMarkedAsSpam(byUser);
}

public bool MarkAsSpam(DateTime at, User byUser)
{
    if (CanMarkAsSpam(byUser))
    {
        MarkAsSpam markAsSpam = new MarkAsSpam
                                    {
                                        ByUser = byUser,
                                        ForStory = this,
                                        Timestamp = at
                                    };

        _markAsSpams.Add(markAsSpam);

        return true;
    }

    return false;
}

Post Comment

For comment we have to introduce few new properties, Id, Content and CreatedAt. We will skip the spam checking part for the future post, tests:

[Fact]
public void PostComment_Should_Return_New_Comment()
{
    var user = new User{ Id = Guid.NewGuid() };

    var comment = _story.PostComment("This is a dummy content", DateTime.UtcNow, user);

    Assert.NotNull(comment);
}

[Fact]
public void PostComment_Should_Increase_Comments_Collection()
{
    var previousCount = _story.Comments.Count;

    var user = new User { Id = Guid.NewGuid() };

    _story.PostComment("This is a dummy content", DateTime.UtcNow, user);

    Assert.True(_story.Comments.Count > previousCount);
}

And in Story:

private readonly List<Comment> _comments = new List<Comment>();

public ICollection<Comment> Comments
{
    get
    {
        return _comments.AsReadOnly();
    }
}

public Comment PostComment(string content, DateTime at, User byUser)
{
    Comment comment = new Comment
                          {
                              Id = Guid.NewGuid(),
                              ByUser = byUser,
                              ForStory = this,
                              CreatedAt = at,
                              Content = content
                          };

    _comments.Add(comment);

    return comment;
}

Subscribing/Unsubscribing Comments

When subscribing comment we will check if the user has already subscribed, if not we will subscribe otherwise we will ignore it, tests:

[Fact]
public void ContainsCommentSubscriber_Should_Return_False_When_User_Does_Not_Exist_In_CommentSubscribers_Collection()
{
    var user = new User { Id = Guid.NewGuid() };

    Assert.False(_story.ContainsCommentSubscriber(user));
}

[Fact]
public void SubscribeComment_Should_Increase_CommentSubscribers_Collection()
{
    var user = new User { Id = Guid.NewGuid() };

    var previousCount = _story.CommentSubscribers.Count;

    _story.SubscribeComment(user);

    Assert.True(_story.CommentSubscribers.Count > previousCount);
}

[Fact]
public void SubscribeComment_Should_Not_Increase_CommentSubscribers_Collection_When_User_Already_Exists()
{
    var user = new User { Id = Guid.NewGuid() };

    _story.SubscribeComment(user);

    var previousCount = _story.CommentSubscribers.Count;

    _story.SubscribeComment(user);

    Assert.Equal(_story.CommentSubscribers.Count, previousCount);
}

[Fact]
public void UnsubscribeComment_Should_Decrease_CommentSubscribers_Collection()
{
    var user = new User { Id = Guid.NewGuid() };
    _story.SubscribeComment(user);

    var previousCount = _story.CommentSubscribers.Count;

    _story.UnSubscribeComment(user);

    Assert.True(_story.CommentSubscribers.Count < previousCount);
}

And in Story:

private readonly List<User> _commentSubscribers = new List<User>();

public ICollection<User> CommentSubscribers
{
    get
    {
        return _commentSubscribers.AsReadOnly();
    }
}

public bool ContainsCommentSubscriber(User byUser)
{
    return _commentSubscribers.Any(cs => cs.Id == byUser.Id);
}

public void SubscribeComment(User byUser)
{
    if (!ContainsCommentSubscriber(byUser))
    {
        _commentSubscribers.Add(byUser);
    }
}

public void UnSubscribeComment(User byUser)
{
    if (ContainsCommentSubscriber(byUser))
    {
        var s = _commentSubscribers.Single(cs => cs.Id == byUser.Id);
        _commentSubscribers.Remove(s);
    }
}

Viewing Story

We need the track how many times a story has been viewed, so let us introduce another property ViewCount, Test:

[Fact]
public void View_Should_Increase_ViewCount()
{
    var previousCount = _story.ViewCount;

    _story.View();

    Assert.True(_story.ViewCount > previousCount);
}

And in Story:

public int ViewCount
{
    get;
    private set;
}

public void View()
{
    ViewCount += 1;
}

Increasing/Decreasing User Score

User score should be increased/decreased based upon the action like submitting/promoting/demoting etc. So first let us create an Enum which will hold some predefined User Actions:

public enum UserAction
{
    None = 0,
    AccountActivated = 1,
    StorySubmitted = 2,
    StoryViewed = 3,
    StoryPromoted = 4,
    StoryCommented = 5,
    StoryMarkedAsSpam = 6,
    SpamStorySubmitted = 7
}

With increasing/decreasing user score we also need to have the support to know the current score and query score for a given time period, so lets add a new class which holds the date, score and user action when increasing/decreasing score, tests:

[Fact]
public void CurrentScore_Should_Be_Zero_When_User_Is_Created()
{
    Assert.Equal(0, _user.CurrentScore);
}

[Fact]
public void IncreaseScoreBy_Should_Increase_CurrentScore()
{
    decimal previousScore = _user.CurrentScore;

    _user.IncreaseScoreBy(5, UserAction.StorySubmitted);

    Assert.Equal(previousScore + 5, _user.CurrentScore);
}

[Fact]
public void DecreaseScoreBy_Should_Decrease_CurrentScore()
{
    decimal previousScore = _user.CurrentScore;

    _user.DecreaseScoreBy(50, UserAction.SpamStorySubmitted);

    Assert.Equal(previousScore - 50, _user.CurrentScore);
}

And in User:

public DateTime CreatedAt
{
    get;
    set;
}

public decimal CurrentScore
{
    get
    {
        return GetScoreBetween(CreatedAt, DateTime.UtcNow);
    }
}

private decimal GetScoreBetween(DateTime from, DateTime to)
{
    return _userScores.Where(us => us.Timestamp >= from && us.Timestamp <= to).Sum(us => us.Score);
}

public void IncreaseScoreBy(decimal score, UserAction reason)
{
    AddScore(score, reason);
}

public void DecreaseScoreBy(decimal score, UserAction reason)
{
    AddScore(-score, reason);
}

private void AddScore(decimal score, UserAction reason)
{
    _userScores.Add(
                        new UserScore
                        {
                            OfUser = this,
                            Reason = reason,
                            Score = score,
                            Timestamp = DateTime.UtcNow
                        });
}

Tag Story

When submitting story, user should be able to specify tags for story and later on can view the stories with that specified tags, so we need to associate tag with both User and Story. Since it is a common behavior, we can create an interface which both Story and User has to implement:

public interface ITagContainer
{
    ICollection<Tag> Tags
    {
        get;
    }

    void AddTag(Tag tag);

    void RemoveTag(Tag tag);

    void RemoveAllTags();

    bool ContainsTag(Tag tag);
}

Though composition is better than inheritance, but for the time being lets keep it this way. Since both is the same I am only showing the Story, tests:

[Fact]
public void Tags_Should_Be_Empty_When_Story_Is_Created()
{
    Assert.Empty(_story.Tags);
}

[Fact]
public void Contains_Should_Return_True_When_Tag_Exists_In_Tags()
{
    var tag = new Tag
                  {
                      Id = Guid.NewGuid(),
                      Name = "Dummy Tag"
                  };

    _story.AddTag(tag);

    Assert.True(_story.ContainsTag(new Tag { Name = "Dummy Tag" }));
}

[Fact]
public void AddTag_Should_Increase_Tags_Collection()
{
    int previousCount = _story.Tags.Count;

    var tag = new Tag
                  {
                      Id = Guid.NewGuid(),
                      Name = "Dummy Tag"
                  };

    _story.AddTag(tag);

    Assert.True(_story.Tags.Count > previousCount);
}

[Fact]
public void AddTag_Should_Not_Increase_Tags_Collection_For_Duplicate_Tag()
{
    var tag1 = new Tag
                   {
                       Id = Guid.NewGuid(),
                       Name = "Dummy Tag"
                   };

    _story.AddTag(tag1);

    int previousCount = _story.Tags.Count;

    var tag2 = new Tag
                   {
                       Id = Guid.NewGuid(),
                       Name = "Dummy Tag"
                   };

    _story.AddTag(tag2);

    Assert.Equal(previousCount, _story.Tags.Count);
}

[Fact]
public void RemoveTag_Should_Decrease_Tags_Collection()
{
    var tag = new Tag
                  {
                      Id = Guid.NewGuid(),
                      Name = "Dummy Tag"
                  };

    _story.AddTag(tag);

    int previousCount = _story.Tags.Count;

    _story.RemoveTag(new Tag { Name = "Dummy Tag" });

    Assert.True(_story.Tags.Count < previousCount);
}

[Fact]
public void RemoveAllTags_Should_Make_Tags_Collection_Empty()
{
    _story.AddTag(new Tag { Id = Guid.NewGuid(), Name = "Dummy Tag1" });
    _story.AddTag(new Tag { Id = Guid.NewGuid(), Name = "Dummy Tag2" });

    _story.RemoveAllTags();

    Assert.Empty(_story.Tags);
}

And in story:

private readonly List<Tag> _tags = new List<Tag>();

public ICollection<Tag> Tags
{
    get
    {
        return _tags.AsReadOnly();
    }
}

public void AddTag(Tag tag)
{
    if (!ContainsTag(tag))
    {
        _tags.Add(tag);
    }
}

public void RemoveTag(Tag tag)
{
    Tag sameNameTag = _tags.SingleOrDefault(t => t.Name == tag.Name);

    if (sameNameTag != null)
    {
        _tags.Remove(sameNameTag);
    }
}

public void RemoveAllTags()
{
    _tags.Clear();
}

public bool ContainsTag(Tag tag)
{
    return _tags.Any(t => t.Name == tag.Name);
}

Skipping few functionalities

We will skip story submit, edit, delete, confirm as spam, view story list for future post.

Changing Role of User

Admin should be able to change role of User. So we need to introduce another property in User. We will have some predefined role and the functionalities for each role should be static. First, let create an Enum

public enum Roles
{
    User = 0,
    Bot = 1,
    Moderator = 2,
    Administrator = 4
}

Next, the user will have a property Role and a method to change the role, we will skip the checking whether the caller is an admin for future when changing the role, test:

[Fact]
public void ChangeRole_Should_Update_Role()
{
    _user.ChangeRole(Roles.Administrator);

    Assert.Equal(Roles.Administrator, _user.Role);
}

and in User:

public Roles Role
{
    get;
    private set;
}

public void ChangeRole(Roles role)
{
    Role = role;
}

Lock/Unlock User

Admin should be able to lock/unlock user. It should be same as Role, we will again skip the admin calling checking for future, tests:

[Fact]
public void Lock_Should_Update_IsLocked()
{
    _user.Lock();

    Assert.True(_user.IsLocked);
}

[Fact]
public void Unlock_Should_Update_IsLocked()
{
    _user.Unlock();

    Assert.False(_user.IsLocked);
}

And in User:

public bool IsLocked
{
    get;
    private set;
}

public void Lock()
{
    IsLocked = true;
}

public void Unlock()
{
    IsLocked = false;
}

Approving Story

User can Mark story as spam if they think the story is not relevant, also our spam checkers can block a story from appearing if they detect it as spam. But the Moderator should be the final judge for confirming a story as spam, if the Moderator finds a story is not spam, s/he will approve the story which makes sure the story appearance.  For marking the Story as approved we will introduce a new Property ApprovedAt, same as Story published, tests:

[Fact]
public void Approve_Should_Update_ApprovedAt()
{
    var now = DateTime.UtcNow;

    _story.Approve(now);

    Assert.Equal(now, _story.ApprovedAt);
}

And in Story:

public DateTime? ApprovedAt
{
    get;
    private set;
}

public void Approve(DateTime at)
{
    ApprovedAt = at;
}

Note that we are again skipping the checking if the caller is Moderator in Approve method for future.

Publishing Story

Story publish is a process where popular stories appears in the front page, certainly there should be different strategies for calculating the popularity of story, which we will again it skip for future post, for the time being we will only focus once a story is qualified to publish how do we mark it. As mentioned in the Marking as Spam section that once a Story is published we will update its PublishedAt property, but updating only the PublishedAt does not serves our purpose as we also have to know the Rank to order it in the list. Tests:

[Fact]
public void Publish_Should_Update_PublishedAt()
{
    var now = DateTime.UtcNow;

    _story.Publish(now, 1);

    Assert.Equal(now, _story.PublishedAt);
}

[Fact]
public void Publish_Should_Update_Rank()
{
    _story.Publish(DateTime.UtcNow, 1);

    Assert.Equal(1, _story.Rank);
}

And in story:

public DateTime? PublishedAt
{
    get;
    private set;
}

public int? Rank
{
    get;
    private set;
}

public void Publish(DateTime at, int rank)
{
    PublishedAt = at;
    Rank = rank;
}

The following is final object model of this part that we have done so far:

DomainObjects

You can also download the complete code from the bottom of this post.

There are few points in the above which I like to highlight

 

  • Note that in above we are only focusing on the Domain Logic, we did not mentioned anything about database, UI or any other infrastructural stuffs.
  • Almost all of the code blocks are 3/4 lines long which is easy to understand and unit test.
  • There are certain functionalities in the above can be also achieved by using only properties instead of using method, for example in the User we can have IsLocked property with both getter and setter but instead we have created Lock, Unlock method and readonly property IsLocked which I think makes it more explicit and also expressive.

In the next post, we will check how to map this domain model into the database and create repository with LinqToSql with 100% code coverage.

Stay tuned!!!

Download: Source Code

Shout it
Filed under: , , , , , , ,

Comments

# re: Domain Model (Developing KiGG v2.0 Part 1)

Thursday, February 12, 2009 7:34 AM by Jesse Vlasveld

Great writeup, I sure can learn alot from this.

A quick question: What software do you use to create the object models?

Thanks,

Jesse Vlasveld

# re: Domain Model (Developing KiGG v2.0 Part 1)

Thursday, February 12, 2009 8:24 AM by Kazi Manzur Rashid

Hi Jesse,

I am using the VS Class diagram.

# re: Domain Model (Developing KiGG v2.0 Part 1)

Thursday, February 12, 2009 12:29 PM by Pradeep Kumar Mishra

As promised you finally posted the technical design. Thanks a lot for all your efforts.

# re: Domain Model (Developing KiGG v2.0 Part 1)

Sunday, February 15, 2009 4:16 AM by mosessaur

Wonderful Rashid, Although I already mastered kigg design :o) but still you are revealing many things by discussing your design and implementation of kigg.

Will stay tuned