I believe I identified the problem. The application I'm developing uses a clean architecture with DDD to enrich the models. For the User entity, which contains several properties, I defined all of them as ObjectValues. For example, for the Id, I created a class called UserId; for the name property, I created a class called UserName, and so on. I applied the same approach to the Role entity, creating a RoleId class for the Id and UserRole for the role name.
// Object Values / User
public class UserId
{
public Guid Value { get; }
private UserId(Guid value) => Value = value;
public static UserId Of(Guid value)
{
ArgumentNullException.ThrowIfNull(value);
if (value == Guid.Empty)
{
throw new Exception("UserId cannot be empty");
}
return new UserId(value);
}
}
public class UserName
{
public string Value { get; }
private UserName(string value) => Value = value;
public static UserName Of(string value)
{
ArgumentNullException.ThrowIfNull(value);
if (string.IsNullOrWhiteSpace(value))
{
throw new Exception("UserName cannot be empty");
}
return new UserName(value);
}
}
// Object Values / Role
public class RoleId
{
public Guid Value { get; }
private RoleId(Guid value) => Value = value;
public static RoleId Of(Guid value)
{
ArgumentNullException.ThrowIfNull(value);
return new RoleId(value);
}
}
public class RoleName
{
public string Value { get; }
private RoleName(string value) => Value = value;
public static RoleName Of(string value)
{
ArgumentNullException.ThrowIfNull(value);
if (string.IsNullOrWhiteSpace(value))
{
throw new Exception("RoleName cannot be empty");
}
return new RoleName(value);
}
}
Next, within each User and Role model, I created the respective navigation properties. In the User entity, I created the Roles property like this:
public List<Role> {get;set;}.
And in the Role entity, I created the Users navigation property:
public List<User> Users {get;set;}.
public class User : Entity<UserId>
{
public UserName? UserName { get; set; }
public List<Role> Roles { get; set; } = [];
public User() { }
public User(Guid id, string userName, string userEmail)
{
Id = UserId.Of(id);
UserName = UserName.Of(userName);
}
public static User Create(Guid id, string userName)
{
return new User(id, userName);
}
}
public class Role : Entity<RoleId>
{
public RoleName RoleName { get; set; } = default!;
public List<User> Users { get; } = [];
public Role() { }
public Role(Guid id, string roleName)
{
Id = RoleId.Of(id);
RoleName = RoleName.Of(roleName);
}
public static Role Create(Guid id, string roleName)
{
return new Role(id, roleName);
}
}
In the database context (SQL Server), within the OnModelCreate method, I defined table names, primary keys, and converted the ObjectValue properties to primitive values as follows: For example, for the User entity Id, I did this: builder.Property(u => u.Id).HasConversion(id => id.Value, value => new UserId(value));
. I applied the same logic to the rest of the User properties as well as for the Role entity. Finally, I defined the relationship between User and Role as many-to-many, as shown below:
// Users
builder.ToTable("Users");
builder.HasKey(u => u.Id);
builder.Property(u => u.Id).HasConversion(id => id.Value, value => UserId.Of(value));
builder.Property(u => u.UserName).HasConversion(prop => prop!.Value, value => UserName.Of(value));
// Roles
builder.ToTable("Roles");
builder.HasKey(r => r.Id);
builder.Property(r => r.Id).HasConversion(id => id.Value, value => RoleId.Of(value));
builder.Property(r => r.RoleName).HasConversion(prop => prop!.Value, value => RoleName.Of(value));
// Many-to-Many Relationship
modelBuilder.Entity<User>()
.HasMany(u => u.Roles)
.WithMany(ur => ur.Users);
Then, through the context injected in a controller method, I attempted to perform a simple query to retrieve the user identified by Id = "58c49479-ec65-4de2-86e7-033c546291aa" along with their assigned roles as follows:
var user = await _context.Users
.Include(user => user.Roles)
.Where(user => user.Id == UserId.Of("58c49479-ec65-4de2-86e7-033c546291aa"))
.SingleOrDefaultAsync();
When executing this query, it didn’t return the user's associated roles (which do exist), and it generated an exception indicating that the query returned more than one record that matches the filter, which isn't true since there's only one user with a single role in the database. After much trial and error, I decided to replace the ObjectValues used as identifiers for the User and Role entities with primitive values, in this case Guid. I removed the ObjectValue-to-Primitive transformation line in OnModelCreate for both user and role, resulting in the following setup:
// Users
builder.ToTable("Users");
builder.HasKey(u => u.Id);
builder.Property(u => u.UserName).HasConversion(prop => prop!.Value, value => new UserName(value));
// Roles
builder.ToTable("Roles");
builder.HasKey(r => r.Id);
builder.Property(r => r.RoleName).HasConversion(prop => prop!.Value, value => new RoleName(value));
// Many-to-Many Relationship
modelBuilder.Entity<User>()
.HasMany(u => u.Roles)
.WithMany(ur => ur.Users);
I also modified the User and Role entities:
public class User : Entity<Guid>
{
public UserName? UserName { get; set; }
public List<Role> Roles { get; set; } = [];
public User() { }
public User(Guid id, string userName)
{
Id = id;
UserName = UserName.Of(userName);
}
public static User Create(Guid id, string userName)
{
return new User(id, userName);
}
}
public class Role : Entity<Guid>
{
public RoleName RoleName { get; set; } = default!;
public List<User> Users { get; } = [];
public Role() { }
public Role(Guid id, string roleName)
{
Id = id;
RoleName = RoleName.Of(roleName);
}
public static Role Create(Guid id, string roleName)
{
return new Role(id, roleName);
}
}
After re-configuring the entities, I ran the query again, and voila! The user now returns the associated roles as expected. I'm not sure what happens with EFC 8 regarding entity identifiers of type ObjectValue, but it doesn't seem to handle them well. For now, I prefer to work with primitive data types for identifiers to avoid these issues. If this can help someone, great. Or, if anyone knows how to solve or address this, I'd love to hear about it. Cheers!