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 { }
1 Answer 1
The design looks solid, a few thoughts though:
I would split
IUserVoiceStore
into more granularUserRepository
andSuggestionRepository
. 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
andUser
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.
-
\$\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– Phillippe Santana2018年10月19日 16:35:58 +00:00Commented Oct 19, 2018 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– Phillippe Santana2018年10月19日 16:37:48 +00:00Commented Oct 19, 2018 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– guillaume312018年10月22日 07:01:51 +00:00Commented Oct 22, 2018 at 7:01
CannotRemoveComment...Exception
anywhere? \$\endgroup\$