dotnet / efcore

EF Core is a modern object-database mapper for .NET. It supports LINQ queries, change tracking, updates, and schema migrations.
https://docs.microsoft.com/ef/
MIT License
13.66k stars 3.15k forks source link

How to map a value object to a row in the database? #29653

Closed ComptonAlvaro closed 1 year ago

ComptonAlvaro commented 1 year ago

Supose that I have in my domain a root entity that is Order and many values objects for the state, one for each possible state (CreatedState, AcceptedState...). So OrderStatus has not an ID.

However, in my database, I have a table for the status, OrderStatus, that have and ID and a varchar for the description (Created, Accepted...).

But I don't know how to map or how to map this case with fluent API and how to work in the domain.

Supose that I want to pass an order from Created to acepted. I would do this in my model:

public class Order
{

    //Properties an other code

    public void ToAccepted()
    {
        this.State = new AcceptedState();
    }
}

public class OrderStateAccepted
{
    OrderStateAccepted()
    {
         State  = "Accepted";
    }

    public string State;
}

In my application service I call the repository and the domain logic to do the action:

public class OrderToAcceptedService
{
    public satic ToAccepted()
    {
        Order myOrder = _orderRepository(1); //I get the order by ID
        myOrder.ToAccepted();
       _orderRepository.Commint();
    }
}

But how should to map the Order and OrderStatus domain entities in the repository with fluent API? Because in some why entity core should to now that the Accepted state has for example ID = 1 in the database to can update the Order.StateId field in the database to this value.

In sumary, I would like to know how could I update the foreign key of the status in the database when in the domain the state is a value object without an ID. Is it possible?

Thanks.

ajcvickers commented 1 year ago

@ComptonAlvaro Your description is a bit vague, but here's one approach:

public static class Your
{
    public static string ConnectionString = @"Data Source=(LocalDb)\MSSQLLocalDB;Database=AllTogetherNow";
}

public class Order
{
    public int Id { get; set; }
    public OrderState State { get; set; } = null!;

    public void ToAccepted() => State = new AcceptedState();
    public void ToCreated() => State = new CreatedState();
}

public class OrderStateDescription
{
    public OrderState Id { get; set; } = null!;
    public string Description { get; set; } = null!;
}

public abstract class OrderState
{
    protected abstract int Id { get; }

    protected bool Equals(OrderState other)
        => Id == other.Id;

    public override bool Equals(object? obj)
        => !ReferenceEquals(null, obj)
           && (ReferenceEquals(this, obj)
               || obj.GetType() == GetType()
               && Equals((OrderState)obj));

    public override int GetHashCode() => Id;

    public class OrderStateConverter : ValueConverter<OrderState, int>
    {
        public OrderStateConverter()
            : base(v => v.Id, v => Factory(v))
        {
        }

        private static OrderState Factory(int id)
            => id == CreatedState.StateId
                ? new CreatedState()
                : id == AcceptedState.StateId
                    ? new AcceptedState()
                    : throw new ArgumentOutOfRangeException();
    }
}

public class CreatedState : OrderState
{
    public static int StateId => 1;
    protected override int Id => StateId;
}

public class AcceptedState : OrderState
{
    public static int StateId => 2;
    protected override int Id => StateId;
}

public class SomeDbContext : DbContext
{
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder
            .UseSqlServer(Your.ConnectionString)
            .LogTo(Console.WriteLine, LogLevel.Information)
            .EnableSensitiveDataLogging();

    public DbSet<Order> Orders => Set<Order>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Order>(b =>
        {
            b.Property(e => e.State).HasConversion<OrderState.OrderStateConverter>();
            b.HasOne<OrderStateDescription>()
                .WithMany()
                .HasForeignKey(e => e.State);
        });

        modelBuilder.Entity<OrderStateDescription>(b =>
        {
            b.Property(e => e.Id).HasConversion<OrderState.OrderStateConverter>();

            b.HasData(
                new OrderStateDescription {Id = new AcceptedState(), Description = "Accepted"},
                new OrderStateDescription {Id = new CreatedState(), Description = "Created"});
        });
    }
}

public class Program
{
    public static void Main()
    {
        using (var context = new SomeDbContext())
        {
            context.Database.EnsureDeleted();
            context.Database.EnsureCreated();

            var order = new Order();
            order.ToCreated();
            context.Add(order);
            context.SaveChanges();
        }

        using (var context = new SomeDbContext())
        {
            var order = context.Orders.First();
            order.ToAccepted();
            context.SaveChanges();
        }
    }
}
ComptonAlvaro commented 1 year ago

Thanks for the solution.

It is more or less what I need, but in my case OrderState wouldn't have an Id, because it is an object model, so I guess in the configuration of the mdel in EF Core, I would need to set a shadow property for the Id, so I will have this field in the model but I will not have in the class of the domain.

Also, the converter it is defined as subclass in the OrderState class, that is a domain class, so I would prefer to don't have in this class, because it is not a concern of the domain, but it is a concern of EF Core. But I guess that perhaps I could define this converter outside this class and create it in the repository.

So in sumary, it is good point to start but I would like two things:

1.- Don't have an Id for the order state. It is an object model. 2.- Don't define the converter in the domain class, so I would prefer to define it in some where in the repository.

ajcvickers commented 1 year ago

@ComptonAlvaro

For 1, since you want to have an ID in the database there will need to be a mapping somewhere. You can separate it out from the domain type, but how to get from the domain object to an ID must exist somewhere unless you also remove it from the database.

For 2, It can go anywhere you want, but it will need access to the object to ID mapping.

Personally, I'd do it like this if I had that database schema:

public class Order
{
    public int Id { get; set; }
    public OrderState State { get; private set; }

    public void ToAccepted() => State = OrderState.Accepted;
    public void ToCreated() => State = OrderState.Created;
}

public enum OrderState
{
    Accepted = 1,
    Created = 2
}

public class OrderStateDescription
{
    public OrderState Id { get; set; }
    public string Description { get; set; } = null!;
}

public class SomeDbContext : DbContext
{
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder
            .UseSqlServer(Your.ConnectionString)
            .LogTo(Console.WriteLine, LogLevel.Information)
            .EnableSensitiveDataLogging();

    public DbSet<Order> Orders => Set<Order>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Order>(b =>
        {
            b.HasOne<OrderStateDescription>()
                .WithMany()
                .HasForeignKey(e => e.State);
        });

        modelBuilder.Entity<OrderStateDescription>(b =>
        {
            b.HasData(
                new OrderStateDescription {Id = OrderState.Accepted, Description = nameof(OrderState.Accepted)},
                new OrderStateDescription {Id = OrderState.Created, Description = nameof(OrderState.Created)});
        });
    }
}

public class Program
{
    public static void Main()
    {
        using (var context = new SomeDbContext())
        {
            context.Database.EnsureDeleted();
            context.Database.EnsureCreated();

            var order = new Order();
            order.ToCreated();
            context.Add(order);
            context.SaveChanges();
        }

        using (var context = new SomeDbContext())
        {
            var order = context.Orders.First();
            order.ToAccepted();
            context.SaveChanges();
        }
    }
}

It's simpler, faster, less code, less to go wrong, and easier to maintain.

ComptonAlvaro commented 1 year ago

@ajcvickers Thanks.

Yes it is the way in which i was thinking, and for me the code is more clear in this way.

Thank you so much.