We cover ways to organize commands and queries in subsequent chapters.
Let’s start with the JoinChatRoom feature:
public class JoinChatRoom
{
public record class Command(IChatRoom ChatRoom, IParticipant Requester) : ICommand;
public class Handler : ICommandHandler<Command>
{
public void Handle(Command command)
{
command.ChatRoom.Add(command.Requester);
}
}
}
The Command class represents the command itself, a data structure that carries the command data. The Handler class handles that type of command. When executed, it adds the specified IParticipant to the specified IChatRoom, using the ChatRoom and Requester properties (highlighted line). Next feature:
public class LeaveChatRoom
{
public record class Command(IChatRoom ChatRoom, IParticipant Requester) : ICommand;
public class Handler : ICommandHandler<Command>
{
public void Handle(Command command)
{
command.ChatRoom.Remove(command.Requester);
}
}
}
That code represents the exact opposite of the JoinChatRoom command, the LeaveChatRoom handler removes an IParticipant from the specified IChatRoom (highlighted line).
Nesting the classes like this allows reusing the class name Command and Handler for each feature.
To the next feature:
public class SendChatMessage
{
public record class Command(IChatRoom ChatRoom, ChatMessage Message) : ICommand;
public class Handler : ICommandHandler<Command>
{
public void Handle(Command command)
{
command.ChatRoom.Add(command.Message);
var participants = command.ChatRoom.ListParticipants();
foreach (var participant in participants)
{
participant.NewMessageReceivedFrom(
command.ChatRoom,
command.Message
);
}
}
}
}
The SendChatMessage feature, on the other hand, handles two things (highlighted lines):
- It adds the specified Message to IChatRoom (now only a data structure that keeps track of users and past messages).
- It also sends the specified Message to all IParticipant instances that joined that IChatRoom.
We are starting to see many smaller pieces interacting with each other to create a more developed system. But we are not done; let’s look at the two queries, then the chat implementation:
public class ListParticipants
{
public record class Query(IChatRoom ChatRoom, IParticipant Requester) : IQuery<IEnumerable<IParticipant>>;
public class Handler : IQueryHandler<Query, IEnumerable<IParticipant>>
{
public IEnumerable<IParticipant> Handle(Query query)
{
return query.ChatRoom.ListParticipants();
}
}
}
The ListParticipants handler uses the specified IChatRoom and returns its participants (highlighted line). Now, to the last query:
public class ListMessages
{
public record class Query(IChatRoom ChatRoom, IParticipant Requester) : IQuery<IEnumerable<ChatMessage>>;
public class Handler : IQueryHandler<Query, IEnumerable<ChatMessage>>
{
public IEnumerable<ChatMessage> Handle(Query query)
{
return query.ChatRoom.ListMessages();
}
}
}
The ListMessages handler uses the specified IChatRoom instance to return its messages.
Because all commands and queries reference IParticipant, we could enforce rules such as “IParticipant must join a channel before sending messages,” for example. I decided to omit these details to keep the code simple, but feel free to add those features if you want to.
Next, let’s take a look at the ChatRoom class, which is a simple data structure that holds the state of a chatroom:
public class ChatRoom : IChatRoom
{
private readonly List<IParticipant> _participants = new List<IParticipant>();
private readonly List<ChatMessage> _chatMessages = new List<ChatMessage>();
public ChatRoom(string name)
{
Name = name ??
throw new ArgumentNullException(nameof(name));
}
public string Name { get; }
public void Add(IParticipant participant)
{
_participants.Add(participant);
}
public void Add(ChatMessage message)
{
_chatMessages.Add(message);
}
public IEnumerable<ChatMessage> ListMessages()
{
return _chatMessages.AsReadOnly();
}
public IEnumerable<IParticipant> ListParticipants()
{
return _participants.AsReadOnly();
}
public void Remove(IParticipant participant)
{
_participants.Remove(participant);
}
}
If we take a second look at the ChatRoom class, it has a Name property. It contains a list of IParticipant instances and a list of ChatMessage instances. Both ListMessages() and ListParticipants() return the list AsReadOnly(), so a clever programmer cannot mutate the state of ChatRoom from the outside. That’s it; the new ChatRoom class is a façade over its underlying dependencies.Finally, the Participant class is probably the most exciting part of this system because it is the one that makes heavy use of our Mediator and CQS:
public class Participant : IParticipant
{
private readonly IMediator _mediator;
private readonly IMessageWriter _messageWriter;
public Participant(IMediator mediator, string name, IMessageWriter messageWriter)
{
_mediator = mediator ??
throw new ArgumentNullException(nameof(mediator));
Name = name ??
throw new ArgumentNullException(nameof(name));
_messageWriter = messageWriter ??
throw new ArgumentNullException(nameof(messageWriter));
}
public string Name { get; }
public void Join(IChatRoom chatRoom)
{
_mediator.Send(new JoinChatRoom.Command(chatRoom, this));
}
public void Leave(IChatRoom chatRoom)
{
_mediator.Send(new LeaveChatRoom.Command(chatRoom, this));
}
public IEnumerable<ChatMessage> ListMessagesOf(IChatRoom chatRoom)
{
return _mediator.Send<ListMessages.Query, IEnumerable<ChatMessage>>(new ListMessages.Query(chatRoom, this));
}
public IEnumerable<IParticipant> ListParticipantsOf(IChatRoom chatRoom)
{
return _mediator.Send<ListParticipants.Query, IEnumerable<IParticipant>>(new ListParticipants.Query(chatRoom, this));
}
public void NewMessageReceivedFrom(IChatRoom chatRoom, ChatMessage message)
{
_messageWriter.Write(chatRoom, message);
}
public void SendMessageTo(IChatRoom chatRoom, string message)
{
_mediator.Send(new SendChatMessage.Command (chatRoom, new ChatMessage(this, message)));
}
}
Every method of the Participant class, apart from NewMessageReceivedFrom, sends a command or a query through the IMediator interface, breaking the tight coupling between the participants and the system’s operations (that is, the commands and queries). The Participant class is also a simple façade over its underlying dependencies, delegating most of the work to the mediator.Now that we have covered the numerous tiny pieces let’s look at how everything works together. I grouped several test cases that share the following setup code:
public class ChatRoomTest
{
private readonly IMediator _mediator = new Mediator();
private readonly TestMessageWriter _reagenMessageWriter = new();
private readonly TestMessageWriter _garnerMessageWriter = new();
private readonly TestMessageWriter _corneliaMessageWriter = new();
private readonly IChatRoom _room1 = new ChatRoom(“Room 1”);
private readonly IChatRoom _room2 = new ChatRoom(“Room 2”);
private readonly IParticipant _reagen;
private readonly IParticipant _garner;
private readonly IParticipant _cornelia;
public ChatRoomTest()
{
_mediator.Register(new JoinChatRoom.Handler());
_mediator.Register(new LeaveChatRoom.Handler());
_mediator.Register(new SendChatMessage.Handler());
_mediator.Register(new ListParticipants.Handler());
_mediator.Register(new ListMessages.Handler());
_reagen = new Participant(_mediator, “Reagen”, _reagenMessageWriter);
_garner = new Participant(_mediator, “Garner”, _garnerMessageWriter);
_cornelia = new Participant(_mediator, “Cornelia”, _corneliaMessageWriter);
}
// Omited test cases and helpers
}
Leave a Reply