Search code examples
c#.netentity-framework-coredomain-driven-designddd-repositories

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


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.


Solution

  • 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.