2
\$\begingroup\$

I've taken on the challenge of modeling a simple User Voice-like system. High-level description:

  • It's a portal for some SaaS users;
  • They come and leave feature requests, suggestions, etc.;
  • They should be able to vote/unvote for any suggestions;
  • They can leave as many comments as they like on suggestions;
  • Comments may be removed by the owner, but may not be edited.

I've modeled the domain as follows, using a DDD approach. Please advise about mistakes, warnings, improvements, etc.

I've also applied the advices from these posts:

public abstract class Entity
{
    public Guid Id { get; protected set; } = Guid.NewGuid();
}

public class User : Entity // Aggregate Root
{
    public string Key => $"{Email}:{MarketplaceUrl}";

    public string Name { get; }

    public string Email { get; }

    public string MarketplaceName { get; }

    public Uri MarketplaceUrl { get; }

    internal User(string name, string email, string marketplaceName, Uri marketplaceUrl)
    {
        Name = name;
        Email = email;
        MarketplaceName = marketplaceName;
        MarketplaceUrl = marketplaceUrl;
    }

    public Suggestion MakeSuggestion(string text)
    {
        return new Suggestion(this, text);
    }
}

public class Suggestion : Entity // Aggregate Root
{
    public string Text { get; /* a suggestion cannot be altered */ }

    public User ByUser { get; }

    public DateTime SuggestedAt { get; }

    public ICollection<Comment> Comments { get; } = new List<Comment>();

    public ICollection<Vote> Votes { get; } = new List<Vote>();

    internal Suggestion(User byUser, string text)
    {
        ByUser = byUser;
        Text = text;
        SuggestedAt = DateTime.UtcNow;
    }

    public Comment AddComment(User byUser, string text)
    {
        var comment = new Comment(byUser, text);
        Comments.Add(comment);
        return comment;
    }

    public void RemoveComment(Comment comment, User userRemovingComment)
    {
        Comments.Remove(comment);
    }

    public void Unvote(User byUser)
    {
        var vote = Votes.SingleOrDefault(v => v.ByUser == byUser));

        if (vote != null)
            Votes.Remove(vote);
    }

    public Vote Vote(User byUser)
    {
        if (Votes.Any(v => v.ByUser == byUser))
            throw new CannotVoteTwiceOnSameSuggestionException();

        var vote = new Vote(byUser);
        Votes.Add(vote);
        return vote;
    }
}

public class Comment : Entity
{
    public string Text { get; /* a comment cannot be changed */ }

    public User ByUser { get; }

    public DateTime CommentedAt { get; }

    internal Comment(User byUser, string text)
    {
        ByUser = byUser;
        Text = text;
        CommentedAt = DateTime.UtcNow;
    }
}

public class Vote : Entity
{
    public User ByUser { get; }

    public DateTime VotedAt { get; }

    internal Vote(User byUser)
    {
        ByUser = byUser;
        VotedAt = DateTime.UtcNow;
    }
}

public interface IUserVoiceStore
{
    Task AddUserAsync(User user);

    Task AddSuggestionAsync(Suggestion suggestion);

    Task<Suggestion> GetSuggestionAsync(Guid id);

    Task<User> GetUserAsync(Guid id);

    // For when comments and votes are added/removed to/from a suggestion.
    Task UpdateSuggestionAsync(Suggestion suggestion);
}

public class UserVoiceService
{
    private readonly IUserVoiceStore store;

    public UserVoiceService(IUserVoiceStore store)
    {
        this.store = store;
    }

    public async Task<User> RegisterUserAsync(string name, string email, string marketplaceName, Uri marketplaceUrl)
    {
        var user = new User(name, email, marketplaceName, marketplaceUrl);
        await store.AddUserAsync(user);
        return user;
    }
}

public class CannotVoteTwiceOnSameSuggestionException : Exception { }

public class CannotRemoveCommentFromAnotherUserExcetion : Exception { }
\$\endgroup\$
  • \$\begingroup\$ You are not using the CannotRemoveComment...Exception anywhere? \$\endgroup\$ – t3chb0t Oct 19 '18 at 14:04
  • \$\begingroup\$ @t3chb0t my mistake; I edited the code at Suggestion.RemoveComment to validate the user and throw if needed. Thanks. \$\endgroup\$ – Phillippe Santana Oct 19 '18 at 16:18
  • 5
    \$\begingroup\$ Please do not update the code in your question to incorporate feedback from answers, doing so goes against the Question + Answer style of Code Review. This is not a forum where you should keep the most updated version in your question. Please see what you may and may not do after receiving answers. \$\endgroup\$ – Mast Oct 19 '18 at 16:32
  • \$\begingroup\$ @Mast sorry about that. I'll revert the code to its original form and then post a link to a Gist with the updated code. \$\endgroup\$ – Phillippe Santana Oct 19 '18 at 17:14
1
\$\begingroup\$

The design looks solid, a few thoughts though:

  • I would split IUserVoiceStore into more granular UserRepository and SuggestionRepository. Also, UpdateSuggestionAsync() seems to indicate you can only update a suggestion and nothing else at a time, which can be limiting. It also IMO goes out of a repository's jurisdiction to flush a specific object to persistent storage. Using some kind of separate unit of work class where you can put multiple objects to be updated as part of a business transaction might be a better idea.

  • Not always feasible, but maybe change the type of link between Suggestion and User from a full reference to just an ID to avoid the temptation of manipulating 2 aggregate roots at the same time. (I don't agree with the article you linked to in that regard)

  • Keep an eye on suggestions with a large number of comments - depending on concurrent access, they can clog up your system and cause locking problems, especially if comments become more sophisticated, with images and so on.

\$\endgroup\$
  • \$\begingroup\$ So split the repo by Aggregate Root; nice, code updated. About your 3rd suggestion, do you think I should promote Comment to an AR and have a dedicated Repo? It does make some sense. \$\endgroup\$ – Phillippe Santana Oct 19 '18 at 16:35
  • \$\begingroup\$ Also, about your 2nd suggestion, it is according to Vernon's advice, see informit.com/articles/article.aspx?p=2020371&seqNum=4 - Rule: Reference Other Aggregates by Identity. I'll update the code accordingly \$\endgroup\$ – Phillippe Santana Oct 19 '18 at 16:37
  • \$\begingroup\$ About promoting Comment, it would be premature optimization to do it without some transactional analysis based on real or expected numbers. \$\endgroup\$ – guillaume31 Oct 22 '18 at 7:01

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.