How to define many-many using fluent api with EF Core

365 Views Asked by At

I have one entity named Team and another entity League. Now let's say that i want to create a many-many relationship between them but with an additional column/property that i need to define from code (NOT generated by Db like a DateAdded column for instance). The extra property i'd like to have in place is the Season. That is what i've tried so far:

// Base entity contains the Id and a `DomainEvents` list accesible via `RegisterDomainEvent` method
public class Team : BaseEntity<Guid> 
{
    private Team() 
    {
        _teamLeagues = new(); 
    }

    public Team(Guid id, string name) : this()
    {
        Id = Guard.Against.Default(id);
        Name = Guard.Against.NullOrWhiteSpace(name);
        // other properties omitted for brevity
    }
    
    public string Name { get; } = default!;   

    private readonly List<TeamLeague> _teamLeagues;

    public IEnumerable<League> Leagues => _teamLeagues
        .Select(x => x.League)
        .ToList()
        .AsReadOnly();
    
    internal void AddLeague(TeamLeague league)
    {
        Guard.Against.Null(league);
        _teamLeagues.Add(league);
    }
}
public class League : BaseEntity<Guid>, IAggregateRoot
{
    // Required by EF 
    private League()
    {
        _teamLeagues = new();
    }

    public League(Guid id, string name) : this()
    {
        Id = Guard.Against.Default(id);
        Name = Guard.Against.NullOrWhiteSpace(name);
    }
    
    public string Name { get; } = default!;
    
    private readonly List<TeamLeague> _teamLeagues;
    public IEnumerable<Team> Teams => _teamLeagues
        .Select(x => x.Team)
        .ToList()
        .AsReadOnly();

    public void AddTeamForSeason(Team team, string season)
    {
        Guard.Against.Null(team);
        Guard.Against.NullOrEmpty(season);

        var teamLeague = new TeamLeague(Guid.NewGuid(), team, this, season);
        _teamLeagues.Add(teamLeague);
        team.AddLeague(teamLeague);

        TeamAddedToLeagueForSeasonEvent teamAdded = new(teamLeague);
        RegisterDomainEvent(teamAdded);
    }
}
public class TeamLeague : BaseEntity<Guid>
{
    private TeamLeague() { }
 
    public TeamLeague(Guid id, Team team, League league, string? season) : this()
    {
        Id = Guard.Against.Default(id);
        Team = Guard.Against.Null(team);
        League = Guard.Against.Null(league);
        Season = season;
    }
   
    public Team Team { get; }
   
    public League League { get; }

    public string? Season { get; } = default!;
}

Configuration:

public void Configure(EntityTypeBuilder<Team> builder)
{
     builder.ToTable("Teams").HasKey(x => x.Id);
     builder.Ignore(x => x.DomainEvents);
     builder.Property(p => p.Id).ValueGeneratedNever();
     builder.Property(p => p.Name).HasMaxLength(ColumnConstants.DEFAULT_NAME_LENGTH);
}
public void Configure(EntityTypeBuilder<League> builder)
{
     builder.ToTable("Leagues").HasKey(x => x.Id);
     builder.Ignore(x => x.DomainEvents);
     builder.Property(p => p.Id).ValueGeneratedNever();
     builder.Property(p => p.Name).HasMaxLength(ColumnConstants.DEFAULT_NAME_LENGTH);

    builder
         .HasMany(x => x.Teams)
         .WithMany(x => x.Leagues)
         .UsingEntity<TeamLeague>(
            e => e.HasOne<Team>().WithMany(),
            e => e.HasOne<League>().WithMany())
         .Property(e => e.Season).HasColumnName("Season");
}
public void Configure(EntityTypeBuilder<TeamLeague> builder)
{
    builder.ToTable("TeamLeagues").HasKey(x => x.Id);
    builder.Ignore(x => x.DomainEvents);
    builder.Property(p => p.Id).ValueGeneratedNever();
    builder.Property(p => p.Season).HasMaxLength(ColumnConstants.DEFAULT_YEAR_LENGTH);
}

Now from an api controller i want to create a new team and to assign it to the league also must be added to the intermediate table as well.

 // naive api controller, simple for demo reasons
[HttpPost]
public async Task<IActionResult> Create([FromRoute] Guid leagueId, Team team)
{
    var league = await _leagueRepository.GetByIdAsync(leagueId);
    league?.AddTeamForSeason(team, "2023");
    await _leagueRepository.UpdateAsync(league!);        
    return Ok(league);
}
// League Repository
public override async Task UpdateAsync(T entity, CancellationToken cancellationToken = default)
{
    _dbContext.Set<T>().Update(entity);
    await SaveChangesAsync(cancellationToken);
}

But by doing this i can't update neither the Season nor the (TeamLeague) Id. How could i change the mapping (via fluent api again) in order to update that additional column on intermediate table.

2

There are 2 best solutions below

0
ggeorge On BEST ANSWER

After many hours of investigation i found the solution and i'd like to share it in any case someone else would like to use DDD with proper encapsulation.

So one way to solve this problem is to use this configuration for League

public class LeagueConfiguration : IEntityTypeConfiguration<League>
{
    public void Configure(EntityTypeBuilder<League> builder)
    {
        builder
           .HasMany(x => x.Teams)
           .WithMany(x => x.Leagues)
           .UsingEntity<TeamLeague>(
               e => e.HasOne<Team>().WithMany("_teamLeagues"),
               e => e.HasOne<League>().WithMany("_teamLeagues"))
           .Property(e => e.Season).HasColumnName("Season");        
    }
}

So we keep "clean" our domain model having the List<TeamLeague> _teamLeagues still private but this time with this configuration we give access to EFCore to our private field in order to map it to the intermediate table.

3
Adam Hey On

I found a similar solution but without the need to have an object that represents the intersect table (assuming its columns are TeamId and LeagueId):

public class LeagueConfiguration : IEntityTypeConfiguration<League>
{
    public void Configure(EntityTypeBuilder<League> builder)
    {
        builder
            .HasMany(x => x.Teams)
            .WithMany(x => x.Leagues)
            .UsingEntity<Dictionary<string, object>>(
            "TeamLeague", // The name of the intersect table
            j => j
                .HasOne<Team>()
                .WithMany()
                .HasForeignKey("TeamId")
                ,
            j => j
                .HasOne<League>()
                .WithMany()
                .HasForeignKey("LeagueId")
        );
    }
}

This way you don't need to have additional classes in your project.