Photo by Eric Krull
Introduction
Whenever I code I prefer to have immutable domain models.
Example:
public class Game
{
public Game(Team homeTeam, Team awayTeam, GameResult result)
{
HomeTeam = homeTeam ?? throw new ArgumentNullException(nameof(homeTeam));
AwayTeam = awayTeam ?? throw new ArgumentNullException(nameof(awayTeam));
Result = result ?? throw new ArgumentNullException(nameof(result));
}
public Team HomeTeam { get; }
public Team AwayTeam { get; }
public GameResult Result { get; }
}
Here you can see that it's only possible to set the properties when creating the object (get only properties).
By doing this you can make sure that you don't accidentally mutate the state of the object. This would not compile for example:
...
var game = new Game(homeTeam, awayTeam, result);
game.AwayTeam = new Team(Guid.NewGuid(), "Arsenal"); // Compilation error
Immutability comes with a lot of benefits, but sometimes it can be a bit cumbersome to deal with when you only want to update some properties. Since the object is immutable, you need to create a copy with all the existing values and the new updated one.
I will show you how Records in C# 9 will greatly simplify this
Let's set the scene
Let's say that we are building an application related to betting. A user can place bets on different games.
Our current domain model (before records) looks something like this (greatly simplified):
public class Bet
{
public Bet(string id, string userId, IReadOnlyCollection<BetRow> rows)
{
Id = id ?? throw new ArgumentNullException(nameof(id));
UserId = userId ?? throw new ArgumentNullException(nameof(userId));
Rows = rows ?? throw new ArgumentNullException(nameof(rows));
}
public string Id { get; }
public string UserId {get; }
public IReadOnlyCollection<BetRow> Rows { get; }
}
public class BetRow
{
public BetRow(string gameId, int homeScore, int awayScore) : this(gameId, homeScore, awayScore, BetRowOutcome.Unsettled)
{
}
public BetRow(string gameId, int homeScore, int awayScore, BetRowOutcome outcome)
{
GameId = gameId ?? throw new ArgumentNullException(nameof(gameId));
HomeScore = homeScore;
AwayScore = awayScore;
Outcome = outcome;
}
public string GameId { get; }
public int HomeScore { get; }
public int AwayScore { get; }
public BetRowOutcome Outcome { get; }
}
A Bet
contains a collection of BetRows
and also some other data like Id
and UserId
. A BetRow
also holds a BetRowOutcome
property. This property will be updated when a game is finished.
public enum BetRowOutcome
{
Unsettled,
Win,
Loss
}
Now we want to check if the user has won or not. We need to check and update the Outcome
property for each BetRow
.
Without records
I will now show you one of the "pain points" when it comes to immutability and c#. If we weren't using immutability, the following code would work. We just loop through the bet rows and update (mutate) the Outcome
property.
BetService
public Bet SettleBet(Dictionary<string, Game> finishedGames, Bet bet)
{
foreach (var betRow in bet.Rows)
{
var result = finishedGames[betRow.GameId].Result;
if (betRow.HomeScore == result.HomeScore && betRow.AwayScore == result.AwayScore)
{
betRow.Outcome = BetRowOutcome.Win;
}
else
{
betRow.Outcome = BetRowOutcome.Loss;
}
}
return bet;
}
Since the above code doesn't compile, we need to add a bit more code to make it work.
public class BetService
{
public Bet SettleBet(Dictionary<string, Game> finishedGames, Bet bet)
{
var updatedBetRows = new List<BetRow>();
foreach (var betRow in bet.Rows)
{
var result = finishedGames[betRow.GameId].Result;
BetRowOutcome betRowOutcome;
if (betRow.HomeScore == result.HomeScore && betRow.AwayScore == result.AwayScore)
{
betRowOutcome = BetRowOutcome.Win;
}
else
{
betRowOutcome = BetRowOutcome.Loss;
}
var updatedBetRow = new BetRow(betRow.GameId, betRow.HomeScore, betRow.AwayScore, betRowOutcome);
updatedBetRows.Add(updatedBetRow);
}
return new Bet(bet.Id, bet.UserId, updatedBetRows);
}
}
- We added a list that we use to keep track of the updated
BetRows
. - Foreach row we create a new
BetRow
object and add it to the list. - We return a new
Bet
with the updated rows.
This works great but imagine that your object has a bunch of different properties, you could easily end up with something like this:
return new Bet(
bet.Id,
bet.UserId,
updatedBetRows,
more,
properties,
here,
updateThisAsWell,
easy,
to,
forget,
and,
also,
make,
errors)
Hopefully you get my point. :)
Basically we want to avoid passing all the existing parameters to the constructor.
With records
I have now converted the following classes to records.
public record Bet
{
public Bet(string id, string userId, IReadOnlyCollection<Record.BetRow> rows)
{
Id = id ?? throw new ArgumentNullException(nameof(id));
UserId = userId ?? throw new ArgumentNullException(nameof(userId));
Rows = rows ?? throw new ArgumentNullException(nameof(rows));
}
public string Id { get; }
public string UserId { get; }
public IReadOnlyCollection<Record.BetRow> Rows { get; init; }
}
public record BetRow
{
public BetRow(string gameId, int homeScore, int awayScore) : this(gameId, homeScore, awayScore, BetRowOutcome.Unsettled)
{
}
public BetRow(string gameId, int homeScore, int awayScore, BetRowOutcome outcome)
{
GameId = gameId ?? throw new ArgumentNullException(nameof(gameId));
HomeScore = homeScore;
AwayScore = awayScore;
Outcome = outcome;
}
public string GameId { get; }
public int HomeScore { get; }
public int AwayScore { get; }
public BetRowOutcome Outcome { get; init; }
}
The key thing here is that I changed class
to record
and added the init
keyword to the Rows
and Outcome
properties. This allows me to do the following:
BetService
public Bet SettleBet(Dictionary<string, Game> finishedGames, Bet bet)
{
var updatedBetRows = new List<Record.BetRow>();
foreach (var betRow in bet.Rows)
{
var result = finishedGames[betRow.GameId].Result;
BetRowOutcome betRowOutcome;
if (betRow.HomeScore == result.HomeScore && betRow.AwayScore == result.AwayScore)
{
betRowOutcome = BetRowOutcome.Win;
}
else
{
betRowOutcome = BetRowOutcome.Loss;
}
var updatedBetRow = betRow with { Outcome = betRowOutcome }; // No more new
updatedBetRows.Add(updatedBetRow);
}
return bet with { Rows = updatedBetRows }; // No more new
}
Now I can use the with
keyword and only specify the properties that I would like to update. I will not mutate the existing Bet
object, I will get a completely new object with updated Rows
, all other properties will keep their old values.
This was just a quick example of how to use records, if you want to read more about records have a look here