When mapping objects to Entity Framework Core, I like to use my domain entities directly. I always make sure my domain entities have no outside dependencies. They just represent the domain logic and are never influenced by the desires of the infrastructure layer.
For example, say you have a domain entity that represents an MLB batter:
public sealed class Batter : Entity
{
/// <summary>
/// The player's batting stats by game
/// </summary>
private readonly List<PlayerBattingStatsByGame> _battingStatsByGames;
public int Id { get; }
public string Name { get; }
}
public sealed class PlayerBattingStatsByGame
{
public int GameId { get; }
public int Hits { get; }
public DateOnly GameDate { get; }
}
In this case, I want the following of my entities:
- You cannot edit the player’s list of batting stats by game directly, you must go through the domain logic
_battingStatsByGames
is unchangeable from the outside- So that means having a method called
PlayedGame(PlayerBattingStatsByGame stats)
that adds the stats to the player’s collection_battingStatsByGames
- The related entity
PlayerBattingStatsByGame
does not need an ID to referenceBatter
. This just pollutes the class and the relationship should be inherent in the fact that it is loaded along withBatter
Defining the entity’s domain logic
Say you also want some domain logic that returns the player’s games chronologically. You then can define a property on your entity that returns the collection from _battingStatsByGames
.
public sealed class Batter : Entity
{
/// <summary>
/// The player's batting stats by game
/// </summary>
private readonly List<PlayerBattingStatsByGame> _battingStatsByGames;
public int Id { get; }
public string Name { get; }
/// <summary>
/// The player's batting stats by game in chronological order
/// </summary>
public IReadOnlyList<PlayerBattingStatsByGame> BattingStatsByGamesChronologically =>
_battingStatsByGames.OrderBy(x => x.GameDate).ToImmutableList(); // Or cache this on every LogBattingGame() call to reduce overhead
/// <summary>
/// Logs a game where the player participated in batting
/// </summary>
/// <param name="stats">Batting stats for the game participated in</param>
public void LogBattingGame(PlayerBattingStatsByGame stats)
{
_battingStatsByGames.Add(stats);
}
}
The problem with this is when you introduce Entity Framework Core into the picture, it will try to map your related entities PlayerBattingStatsByGame
to the property BattingStatsByGamesChronologically
and not the field _battingStatsByGames
.
Defining the relationships in the infrastructure layer
You can write your domain entity so that it is completely free of any dependencies aside from .NET itself. The only dependency that Batter
needs is using System.Collections.Immutable;
.
Entity Framework Core typically wants you to set up entity relationships automagically via conventions, meaning relationships between entities are created through code syntax:
public class Blog
{
public int Id { get; set; }
public ICollection<Post> Posts { get; } = new List<Post>();
}
public class Post
{
public int Id { get; set; }
public int BlogId { get; set; }
public Blog Blog { get; set; } = null!;
}
In this case, the usage of names like Blog
and Post
and inferring types, EF Core can build the relationships without further configuration.
But in my case, EF Core allows you to do just a little extra configuration to be able to keep the same relationships but without the coding conventions:
- You won’t have to add
BatterId
toPlayerBattingStatsByGame
- You won’t have to make
_battingStatsByGames
publicly mutable
Entity configurations
For each object that will be stored in a table, you will implement IEntityTypeConfiguration<T>
. For the Batter
example, you would have:
public sealed class BatterEntityTypeConfiguration : IEntityTypeConfiguration<Batter>
{
public void Configure(EntityTypeBuilder<Batter> builder)
{
builder.HasKey(e => e.Id);
var columnOrder = 0;
builder.Property(e => e.Id)
.IsRequired()
.HasColumnName("id")
.HasColumnOrder(columnOrder++);
builder.Property(e => e.Name)
.IsRequired()
.HasColumnName("name")
.HasColumnOrder(columnOrder++);
}
}
public sealed class PlayerBattingStatsByGameEntityTypeConfiguration : IEntityTypeConfiguration<PlayerBattingStatsByGame>
{
public void Configure(EntityTypeBuilder<PlayerBattingStatsByGame> builder)
{
builder.HasKey(["batter_id", "game_id"]);
var columnOrder = 0;
builder.Property("batter_id")
.HasColumnOrder(columnOrder++);
builder.Property(e => e.GameId)
.IsRequired()
.HasColumnName("game_id")
.HasColumnOrder(columnOrder++);
builder.Property(e => e.Hits)
.IsRequired()
.HasColumnName("hits")
.HasColumnOrder(columnOrder++);
builder.Property(e => e.GameDate)
.IsRequired()
.HasColumnType("date")
.HasColumnName("game_date")
.HasColumnOrder(columnOrder++);
}
}
Notice that defining both batter_id
as part of the key and as a property means that we don’t need a BatterId
property polluting our PlayerBattingStatsByGame
class. Instead, EF Core will automatically create this field on the table and only use it when it needs to load related entities.
To finish setting up the relationship between Batter
and PlayerBattingStatsByGame
, you also need to define the kind of relationship. This can be done in two ways:
On the parent entity
In BatterEntityTypeConfiguration.Configure
, you would add:
// Tell EF Core to ignore the domain logic property so it won't try to map a table column to it
builder.Ignore(x => x.BattingStatsByGamesChronologically);
// Tell EF Core to map the related entity to the private field "_battingStatsByGames"
builder.HasMany<PlayerBattingStatsByGame>("_battingStatsByGames")
.WithOne()
.HasForeignKey("batter_id")
.IsRequired();
This tells the parent entity to set up the relationship on batter_id
.
On the related object
You can instead define the mapping on PlayerBattingStatsByGameEntityTypeConfiguration
. Define it before calling HasKey()
.
builder.HasOne<Batter>()
.WithMany("_battingStatsByGames")
.HasForeignKey("batter_id");
builder.HasKey(["batter_id", "game_id"]);
Loading related entities while querying
In your DBContext, where you have the DbSet<Batter>
defined, you can add a method that loads Batter
s and their PlayerBattingStatsByGame
:
public sealed class BattersDbContext : DbContext
{
public BattersDbContext(DbContextOptions<BattersDbContext> options) : base(options)
{
}
public DbSet<Batter> Batters { get; private init; } = null!;
public IQueryable<Batter> BattersWithGames()
{
return Batters.Include("_battingStatsByGames");
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.HasDefaultSchema(Constants.Schema);
modelBuilder.ApplyConfiguration(new BatterEntityTypeConfiguration());
modelBuilder.ApplyConfiguration(new PlayerBattingStatsByGameEntityTypeConfiguration());
}
Now you can call DbContext.BattersWithGames()
to return your Batters with the _battingStatsByGames
private field fully populated.
Gotchas
Reconstructing objects when reading
EF Core requires that there be a constructor that either exposes the properties and fields that are mapped to columns, or has the constructor do the setting itself.
This means that Batter
would need the following constructor:
private Batter(int id, string name) {
Id = id;
Name = name;
_battingStatsByGames = new List<PlayerBattingStatsByGame>();
}
Id
and Name
are exposed via the constructor but _battingStatsByGames
is defined manually. EF Core can populate it (I believe through reflection).
Caching the domain logic
Note that when you invoke the BattingStatsByGamesChronologically
property, you are performing a sort on each call.
public IReadOnlyList<PlayerBattingStatsByGame> BattingStatsByGamesChronologically =>
_battingStatsByGames.OrderBy(x => x.GameDate).ToImmutableList(); // Or cache this on every LogBattingGame() call to reduce overhead
You can cache this by flagging if the collection has changed and only sorting then:
public sealed class Batter : Entity
{
private readonly bool _hasChanged = false;
private IReadOnlyList<PlayerBattingStatsByGame> _sorted;
...
private Batter(int id, string name) {
Id = id;
Name = name;
_battingStatsByGames = new List<PlayerBattingStatsByGame>();
_hasChanged = true; // Sort for the first time after loading
}
public void LogBattingGame(PlayerBattingStatsByGame stats)
{
_battingStatsByGames.Add(stats);
_hasChanged = true;
}
public IReadOnlyList<PlayerBattingStatsByGame> BattingStatsByGamesChronologically
{
get
{
if (_sorted == null || hasChanged)
{
_sorted = _battingStatsByGames.OrderBy(x => x.GameDate).ToImmutableList();
}
return _sorted;
}
}
}