DDD, Clean Architecture, and Dotnet

Where I explore some common design concepts
Published on Thursday, 4 April 2024

First, a little context

As evident from earlier posts, I come from a background of Django development, where - for better or worse - everything is wrapped closely around the ORM, which employs the Active Record pattern.

I am getting more and more into Dotnet, Domain-Driven Design (DDD), and Clean Architecture principles. The latter two dictate that the domain models are at the core of the application, and these should not depend on outside concerns like the database or external services.

In Django, this is immediately problematic, because your "models" are intimately connected to the database and shaped in a way that fits into database tables. As soon as you want to model something more complex, that doesn't fit inside a single table, the chain fall off the gears, so to speak. If you want more detailed complaints and lamentation on that; read my earlier posts: Django - The Bad Parts, Breaking out of Vendor Lock-in, and Breaking out of Vendor Lock-in (part 2).

In Dotnet, there are multiple ORMs to choose from, and the one that is generally considered the default choice is called Entity Framework (EF). EF uses a Unit-of-Work pattern rather than the Active Record pattern, and while you are free to pollute your domain models with EF specific configurations through annotations and bespoke collection types like this:

public class TaskList
{
    [Key]
    public int Id { get; set; }
    [Required]
    public string Title { get; set; }
    public ICollection<Task> Tasks { get; set; }
}

You can also keep the configuration completely separate.

Where are we going with this?

Awesome! This leads me to the main subject of this post, which can be briefly summarized to: Do I need separate Data Models and Domain Models and a mapping between them?.

In Django, if I want strict DDD, then I would have to consider the Django models to be Data Models, since they are closely related to the database and modelled to fit into relational database tables. Further, I would have separate Domain Models and some mapping between them. I would also need to simply not use a lot of the shortcuts of the framework, since those would skip over my domain logic, and work directly on data models.

So what about Dotnet and EF? The cool part of EF here is that I can model my domain objects any way I want, and then add configuration to my DB context to let EF know how to map it. That is such an incredible win over Django in terms of having low coupling, clear separation of concerns, and Clean Architecture!

I would of course generally keep my domain models as simple and "vanilla" as possibly, so they are easier to map to the DB, but the point is that I don't have to! This means if I need models that don't sit neatly in a single table, I can easily have that.

Discussing these concepts with the friendly neighbourhood AI

I had a brief exchange with ChatGPT to see if it would validate my understanding, and it happily did so. Mind you, I don't put much stock in these biased, hallucinating lie machines (Large Lie Models), but still; it is interesting to bounce ideas around with it and get instant feedback. Anyway, it resulted in a nice little detail around how to handle what DDD calls "Value Objects", which are data instances, that are not entities with an identity and lifecycle, but merely a bunch of anonymous data points. I gave the AI the classic Todo App example and added some details about value objects to see what it would churn out, and I thought it was interesting enough to share.

Rather than paraphrasing; here is the full conversation:

Me:

In the context of C#, Dotnet, Entity Framework, and Domain driven design, I need some input.

DDD is about treating domain models as core, and the database is an infrastructure concern. We typically use a repository pattern to abstract away storage and loading of domain models.

So far so good. We could choose to have EF data models as a separate thing and have domain models as a separate thing and then map between the two inside of repositories. That would be a lot of code and complexity, and EF is smart enough to do mapping of domain models directly to the db using configuration classes.

Awesome. My question is then; if my domain models don’t fit 1-to-1 with database tables and need a lot of configuration OR when value objects are involved, which are NOT entities, but still need to be stored in the DB as part of the aggregate; how will That look?


ChatGPT:

When your domain models don't align directly with your database tables or when you have value objects that need to be stored in the database, you can still leverage Entity Framework within your Domain-Driven Design (DDD) approach. Here's how it could look:

  1. Custom Configuration: You can use EF's fluent API to configure complex mappings between your domain models and the corresponding database tables. This allows you to handle cases where there's no direct 1-to-1 mapping.
  2. Value Objects: EF Core supports storing value objects as part of entities by utilizing owned entity types. These are essentially value objects that are stored as part of the owning entity's table. You can define these owned types within your entity classes and EF will handle storing them appropriately.
  3. Separation of Concerns: Even though EF can handle mapping directly from domain models to the database, it's still a good practice to keep a clear separation between your domain models and the database-related concerns. You can achieve this by encapsulating EF-specific code within repository implementations, keeping your domain models focused on domain logic.
  4. Repository Interface: Define repository interfaces in your domain layer, abstracting away the persistence details. Implement these interfaces in your infrastructure layer using EF, where you can handle the mapping and database interactions.

By following these principles, you can maintain the integrity of your domain models while still leveraging the power and convenience of Entity Framework for data persistence.


Me:

Say we were to explore a simple example domain of the classical TODO application. How would you write the domain models, repository interfaces and EF configuration?


ChatGPT:

Certainly! Let's break down how you might structure the domain models, repository interfaces, and EF configuration for a simple TODO application using C#, .NET, and Entity Framework.

Domain Models:

// Task entity
public class Task
{
    public int Id { get; set; }
    public string Title { get; set; }
    public string Description { get; set; }
    public DateTime DueDate { get; set; }
    public bool IsCompleted { get; set; }

    // Navigation property for related entities if needed
}

// TaskList entity
public class TaskList
{
    public int Id { get; set; }
    public string Title { get; set; }
    public ICollection<Task> Tasks { get; set; }

    // Navigation property for related entities if needed
}

Repository Interfaces:

// Repository interface for Task entity
public interface ITaskRepository
{
    Task GetById(int id);
    IEnumerable<Task> GetAll();
    void Add(Task task);
    void Update(Task task);
    void Remove(Task task);
}

// Repository interface for TaskList entity
public interface ITaskListRepository
{
    TaskList GetById(int id);
    IEnumerable<TaskList> GetAll();
    void Add(TaskList taskList);
    void Update(TaskList taskList);
    void Remove(TaskList taskList);
}

EF Configuration:

// EF Configuration for Task entity
public class TaskConfiguration : IEntityTypeConfiguration<Task>
{
    public void Configure(EntityTypeBuilder<Task> builder)
    {
        builder.ToTable("Tasks");
        builder.HasKey(t => t.Id);
        builder.Property(t => t.Title).IsRequired().HasMaxLength(100);
        builder.Property(t => t.Description).IsRequired().HasMaxLength(500);
        builder.Property(t => t.DueDate).IsRequired();
        builder.Property(t => t.IsCompleted).IsRequired();
    }
}

// EF Configuration for TaskList entity
public class TaskListConfiguration : IEntityTypeConfiguration<TaskList>
{
    public void Configure(EntityTypeBuilder<TaskList> builder)
    {
        builder.ToTable("TaskLists");
        builder.HasKey(tl => tl.Id);
        builder.Property(tl => tl.Title).IsRequired().HasMaxLength(100);

        // Define relationship between TaskList and Task entities if needed
        builder.HasMany(tl => tl.Tasks)
            .WithOne()
            .HasForeignKey(task => task.TaskListId);
    }
}

DbContext:

// EF Configuration for Task entity
public class TaskConfiguration : IEntityTypeConfiguration<Task>
{
    public void Configure(EntityTypeBuilder<Task> builder)
    {
        builder.ToTable("Tasks");
        builder.HasKey(t => t.Id);
        builder.Property(t => t.Title).IsRequired().HasMaxLength(100);
        builder.Property(t => t.Description).IsRequired().HasMaxLength(500);
        builder.Property(t => t.DueDate).IsRequired();
        builder.Property(t => t.IsCompleted).IsRequired();
    }
}

// EF Configuration for TaskList entity
public class TaskListConfiguration : IEntityTypeConfiguration<TaskList>
{
    public void Configure(EntityTypeBuilder<TaskList> builder)
    {
        builder.ToTable("TaskLists");
        builder.HasKey(tl => tl.Id);
        builder.Property(tl => tl.Title).IsRequired().HasMaxLength(100);

        // Define relationship between TaskList and Task entities if needed
        builder.HasMany(tl => tl.Tasks)
            .WithOne()
            .HasForeignKey(task => task.TaskListId);
    }
}

Me:

Almost all of this completely tracks with my understanding. One thing that stands out: your comments say to add navigation properties on the domain models? Aren’t navigation properties and EF construct which serves as DB infrastructure? These do not seem to belong to domain objects. Or have I misunderstood?

Leaking ICollection and IQueryable outside of DB specific repository implementations seems to go against the pattern?


ChatGPT:

You're correct in pointing out that navigation properties are typically associated with EF and database relationships rather than pure domain logic. I apologize for any confusion caused by the comments in the code.

Regarding the use of ICollection and IQueryable in repository interfaces, you raise a valid concern. These types are indeed more closely associated with database queries and EF-specific operations, and leaking them outside of DB-specific repository implementations can indeed break the abstraction provided by the repository pattern.

A more domain-centric approach would be to use more domain-friendly types in your repository interfaces. For example, you might consider returning IEnumerable<T> instead of IQueryable<T>, as IEnumerable<T> is more general and doesn't expose IQueryable's query capabilities, which are more implementation-specific.

Here's a revised version of the repository interfaces:

// Repository interface for Task entity
public interface ITaskRepository
{
    Task GetById(int id);
    IEnumerable<Task> GetAll();
    void Add(Task task);
    void Update(Task task);
    void Remove(Task task);
}

// Repository interface for TaskList entity
public interface ITaskListRepository
{
    TaskList GetById(int id);
    IEnumerable<TaskList> GetAll();
    void Add(TaskList taskList);
    void Update(TaskList taskList);
    void Remove(TaskList taskList);
}

By using IEnumerable<T> instead of IQueryable<T>, you keep the repository interfaces more agnostic of the underlying data access mechanism, allowing for easier swapping of implementations without leaking database-specific details into the domain layer. Thank you for bringing this up!


Me:

Great. Now, let’s imagine that the task object might have a bunch of value objects attached. That is, objects that are not entities themselves, but make up part of a task.

Let’s say that a task has a list of post-it objects attached. These post-its should not have their own repository, since they are only ever part of the task aggregate. They so need to be stored in the DB though, and should probably have their own tables and maintain IDs for joins.

How would you configure this with EF and “owned entities”, as you mentioned earlier, while making sure to respect the DDD pattern?


ChatGPT:

To model the scenario where a Task entity has a collection of value objects (e.g., Post-it objects) that need to be stored in the database but are not entities themselves, you can use EF Core's owned entity types feature. Owned entities are a perfect fit for value objects that are part of an aggregate but do not have a separate identity outside of the owning entity.

Here's how you can configure this with EF Core while respecting the principles of Domain-Driven Design (DDD):

Domain Models:

// Task entity
public class Task
{
    public int Id { get; set; }
    public string Title { get; set; }
    public ICollection<PostIt> PostIts { get; set; }
    // Other properties...

    // Navigation property for related entities if needed
}

// Post-it value object
public class PostIt
{
    public string Content { get; set; }
    // Other properties...
}

EF Configuration:

// EF Configuration for Task entity
public class TaskConfiguration : IEntityTypeConfiguration<Task>
{
    public void Configure(EntityTypeBuilder<Task> builder)
    {
        builder.ToTable("Tasks");
        builder.HasKey(t => t.Id);
        builder.Property(t => t.Title).IsRequired().HasMaxLength(100);

        // Configure owned entity Post-it
        builder.OwnsMany(t => t.PostIts, p =>
        {
            p.ToTable("PostIts");
            p.Property<int>("TaskId"); // Foreign key
            p.HasKey("TaskId", "Content"); // Composite key
            p.Property(po => po.Content).IsRequired().HasMaxLength(100);
            // Configure other properties of Post-it...
        });
    }
}

In this setup:

  • The Task entity has a collection of Post-it objects.
  • The Post-it class is a value object without its own identity.
  • In the EF configuration for the Task entity, the Post-it collection is configured as owned entity using the OwnsMany method.
  • The Post-it objects will be stored in their own table in the database (PostIts), but they will be automatically deleted when the owning Task entity is deleted.
  • The TaskId property serves as a foreign key linking the Post-it objects to their owning Task entity.

This configuration respects the principles of DDD by keeping the value objects within the aggregate boundary of the Task entity while still allowing them to be stored in the database as separate entities.


Me:

And I suppose the decision of not letting post-its have an ID in the database helps drive home the point that they are value objects. They get a composite key that states that their perceived identity are only valid in reference to the owning task.

They could have an ID for db concerns, but it would still make sense to use it with the foreign key to tasks as a composite key.

Correct?


ChatGPT:

Yes, you're absolutely correct. By not giving Post-it objects their own separate identity (i.e., not assigning them individual IDs), it reinforces the concept that they are indeed value objects rather than entities with their own lifecycle.

Using a composite key consisting of the Task's ID and some unique identifier (such as the content of the Post-it) effectively ties the Post-it's identity to its owning Task. This approach aligns well with DDD principles, emphasizing that the identity and existence of the Post-it are dependent on the Task to which it belongs.

While technically you could assign separate IDs to Post-it objects for database concerns, doing so would blur the distinction between entities and value objects within the domain model. By using a composite key with the Task's ID, you maintain clarity and consistency in your domain model, reinforcing the notion that Post-it objects are part of the Task aggregate and do not have independent identities outside of it.


Me:

What if the post-its also had a status field, owner field, and up-votes field? How would you define the composite key? Would it still just be the content and foreign key? And why content specifically?


ChatGPT:

If the Post-it objects have additional fields such as status, owner, and up-votes, you might need to reconsider the composition of the composite key. The choice of which fields to include in the composite key depends on the unique characteristics that distinguish one Post-it from another within the context of a Task.

In the context of a TODO application, where the primary identifier of a Post-it might be its content (the actual text written on the Post-it), including the content in the composite key could still be appropriate. However, considering the additional fields you mentioned, you may want to reconsider the composition of the key to ensure uniqueness and meaningfulness.

Here's a revised approach:

// Task entity
public class Task
{
    public int Id { get; set; }
    public string Title { get; set; }
    public ICollection<PostIt> PostIts { get; set; }
    // Other properties...

    // Navigation property for related entities if needed
}

// Post-it value object
public class PostIt
{
    public string Content { get; set; }
    public string Status { get; set; }
    public string Owner { get; set; }
    public int Upvotes { get; set; }
    // Other properties...
}

In the EF configuration, you can still use a composite key, but you might consider including additional fields in addition to the content:

builder.OwnsMany(t => t.PostIts, p =>
{
    p.ToTable("PostIts");
    p.Property<int>("TaskId"); // Foreign key
    p.HasKey("TaskId", "Content", "Status", "Owner"); // Composite key
    p.Property(po => po.Content).IsRequired().HasMaxLength(100);
    p.Property(po => po.Status).IsRequired().HasMaxLength(50);
    p.Property(po => po.Owner).IsRequired().HasMaxLength(50);
    // Configure other properties of Post-it...
});

In this configuration, the composite key consists of the Task's ID, the content of the Post-it, its status, and its owner. This composite key ensures that each Post-it within a Task is uniquely identified based on these combined attributes. Including additional fields in the composite key enhances the uniqueness and integrity of the data, ensuring that each Post-it is distinct within the context of its owning Task.



Blog Logo

Hi! thanks for dropping in to pick my brain

I write on this blog for my own benefit. I write because I like to, and because it helps me process topics. It is also my own little home on the web and a place for me to experiment.

Since you're here though, I'd love to hear your thoughts on what you've read here, so please leave a comment below. Also, if you like what you read and want to give a small tip, fell free to:

Buy Me A Coffee