I recently came across the CQRS Bookings kata, and thought it might be a good chance to experiment. The actual kata description isn’t very detailed, but there is a good example repo (in C#).
You first need some data structure to define what rooms are available & when (in a more realistic example, this would come from some external source, e.g. a third party API). I decided to start with something relatively simple: a map of hotel id to date, then to a list of available rooms (another map).
3 => #{
{2023, 12, 1} => [
#{
id => <<"101">>,
prices => #{
"EUR" => #{
1 => 109,
2 => 140
}
}
},
#{
id => <<"102">>,
prices => #{
"EUR" => #{
1 => 109,
2 => 140
}
}
},
...
The rooms have two prices, depending on the number of occupants.
We can then have a first stab at making a booking:
gen_server:call(cqrs_booking, {book_room, {1, 2, <<"101">>, {2023, 12, 1}, {2023, 12, 2}}}).
handle_call({book_room, Cmd}, _From, #{available_rooms:=AvailableRooms, bookings:=Bookings} = State) ->
{Client, Hotel, Room, CheckIn, _CheckOut} = Cmd,
AvailableRoomsForHotel = maps:get(Hotel, AvailableRooms),
AvailableRoomsForDay = maps:get(CheckIn, AvailableRoomsForHotel),
[_RoomInfo] = lists:filter(fun(R) -> maps:get(id, R) == Room end, AvailableRoomsForDay),
NewBookings = case maps:is_key(Client, Bookings) of
true ->
BookingsForClient = maps:get(Client, Bookings),
maps:update(Client, [Cmd | BookingsForClient], Bookings);
false ->
maps:put(Client, [Cmd], Bookings)
end,
{reply, ok, State#{bookings:=NewBookings}};
First, we look up the hotel by id, then the day (using the check-in date). This would crash the process, if the key did not exist. If the room is available, then the booking is added to another map (part of the gen_server state). I’m not removing the room from the available list, which would obviously be a problem if you were actually running a hotel; but that’s not really the part I’m interested in.
To make things a bit more CQRS, we can now split the validation of the command, from the actual processing. This obviously introduces a whole new assortment of trade-offs, but deciding whether that is worthwhile for a particular use case is out of the scope of this blog post.
self() ! {new_booking, Cmd},
This is simply sending a new message to the same process, to be handled later:
handle_info({new_booking, {Client, _Hotel, _Room, _CheckIn, _CheckOut} = Cmd}, #{bookings:=Bookings} = State) ->
NewBookings = case maps:is_key(Client, Bookings) of
true ->
BookingsForClient = maps:get(Client, Bookings),
maps:update(Client, [Cmd | BookingsForClient], Bookings);
false ->
maps:put(Client, [Cmd], Bookings)
end,
{noreply, State#{bookings:=NewBookings}};
This works; but as of v21, there is a more elegant way to handle it:
{reply, ok, State, {continue, {new_booking, Cmd}}};
...
handle_continue({new_booking, {Client, _Hotel, _Room, _CheckIn, _CheckOut} = Cmd}, #{bookings:=Bookings} = State) ->
...
All well and good, so far; but if the server crashes, we lose everything. Next time, we’ll look at persisting some data.