SSW Foursquare

Rules to Better Domain Driven Design - 4 Rules

Domain-Driven Design (DDD) is a software development approach that focuses on the domain of the problem, rather than the technology. It is a way of thinking and a set of priorities, aimed at accelerating software projects that have to deal with complex domains.

  1. Do you encapsulate domain models in Domain-Driven Design?

    Encapsulating domain models is a critical aspect of domain-driven design (DDD) that helps maintain the integrity and consistency of your application's core logic. Without proper encapsulation, the domain logic can become scattered and difficult to manage, leading to increased complexity and maintenance challenges.

    Why encapsulate domain models?

    When the domain model is not properly encapsulated, business rules and logic might be spread across various parts of the application, making it difficult to understand, maintain, and extend. Encapsulation ensures that the domain model is self-contained, with a clear and coherent structure.

    Key benefits of encapsulation

    • Maintains Integrity: By keeping domain logic within the domain model, you ensure that all business rules (i.e. invariants) are enforced consistently
    • Improves Maintainability: Encapsulated models are easier to understand and modify because all relevant logic is contained within the model itself
    • Enhances Testability: Encapsulated domain models can be tested in isolation, improving the reliability of your tests
    • Promotes Clear Boundaries: Encapsulation helps define clear boundaries between different parts of the system, adhering to the principles of bounded contexts

    Best practices for encapsulating domain models

    1. Use Aggregates: Aggregates are clusters of domain objects that are treated as a single unit. Ensure that all changes to the domain model are done through aggregates. This means that Entities are not directly modified, but rather through the Aggregate Root
    2. Hide Internal State: Keep the internal state of the domain model private and provide methods to interact with the state safely
    3. Encapsulate Collections: Collections should be exposed as read-only interfaces to prevent external code from modifying the collection directly (e.g. using IEnumerable<T> or IReadOnlyList<T> instead of List<T>)
    4. Use Factory Methods: Use factory methods to create instances of domain objects, ensuring that the object is always created in a valid state
    5. Use Value Objects: Value objects are immutable objects that represent a concept in the domain. Use value objects to encapsulate domain concepts that are not entities

    Examples

    public class Order
    {
        public required Guid CustomerId { get; set; }
        public OrderStatus OrderStatus { get; set; }
        public decimal PaidTotal { get; set; }
    
        public Customer? Customer { get; set; }
        public ICollection<OrderItem> Items { get; set; } = [];
    }

    Figure: Bad example - Public setters, exposed collections, no constructor

        public class Order
        {
            public Guid Id { get; private set; }
    
            public Guid CustomerId { get; private set; }
    
            public OrderStatus OrderStatus { get; private set; }
    
            public Customer Customer { get; private set; } = null!;
    
            public decimal PaidTotal { get; private set; }
    
            private readonly List<OrderItem> _items = [];
    
            public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();
    
            public static Order Create(CustomerId customerId)
            {
                Guard.Against.Null(customerId);
    
                var order = new Order()
                {
                    Id = new OrderId(Guid.NewGuid()),
                    CustomerId = customerId,
                    OrderStatus = OrderStatus.New,
                    PaidTotal = Money.Default
                };
    
                return order;
            }
        }

    Figure: Good example - Private setters, read-only collection, factory method

  2. Do you use the Specification pattern in your software design?

    When developing software, ensuring that your code is maintainable, flexible, and readable is crucial. One effective way to achieve this is by implementing the Specification pattern. This pattern allows for clear and modular encapsulation of business rules and query criteria, promoting the separation of concerns and enhancing the overall quality of your code.

    What Problem are we Solving here?

    Let's take the example below of adding/removing items to/from a customer's order. When looking up the customer we need to include the current OrderItems and ensure the order is not complete. This logic is duplicated in both the AddItem and RemoveItem methods, which violates the DRY principle.

    Even worse, if the logic changes, we need to update it in multiple places, increasing the risk of bugs and inconsistencies. Below we correctly check the orders status when adding items, but not when removing them which is a bug.

    public class OrderService(ApplicationDbContext dbContext)
    {
        public async Task AddItem(Guid orderId, OrderItem item)
        {
            var order = dbContext.Orders
                .Include(o => o.OrderItems)
                .FirstOrDefault(o => o.Id == orderId && o.Status != OrderStatus.Complete);
    
            order.AddItem(item);
    
            await dbContext.SaveChangesAsync();
        }
    
        public void RemoveItem(int age)
        {
            // Duplicated logic and bug introduced by not checking OrderStatus
            var order = dbContext.Orders
                .Include(o => o.OrderItems)
                .FirstOrDefault(o => o.Id == orderId);
    
            order.RemoveItem(item);
    
            await dbContext.SaveChangesAsync();
        }
    }

    Figure: Bad example - Duplicated query logic to fetch the customer

    What is the Specification Pattern?

    The Specification pattern is a design pattern used to define business rules in a reusable and composable way. It encapsulates the logic of a business rule into a single unit, making it easy to test, reuse, and combine with other specifications.

    Use Cases for the Specification Pattern

    1. Reusable Queries: Specifications can be used to define reusable query criteria for data access layers, making it easier to build complex queries.
    2. Validation Rules: Specifications can be used to define validation rules for input data, ensuring that it meets the required criteria.
    3. Encapsulating Business Rules: Specifications can encapsulate complex business rules in the Domain where most business logic should go.
    4. Repository Alternative: Specifications can be used as an alternative to repositories for querying data. Instead of encapsulating queries in repositories, you can encapsulate them in specifications.

    Using the Specification Pattern

    Steve Smith (aka ["Ardalis"])(https://github.com/ardalis) has created an excellent library called Ardalis.Specifications that integrates well with EF Core.

    To use the Specification pattern, follow these steps:

    1. Define the Specification:

      public sealed class TeamByIdSpec : SingleResultSpecification<Team>
      {
        public TeamByIdSpec(TeamId teamId)
        {
           Query.Where(t => t.Id == teamId)
              .Include(t => t.Missions)
              .Include(t => t.Heroes);
        }
      }
    2. Use Specification:

          var teamId = new TeamId(request.TeamId);
          var team = dbContext.Teams
              .WithSpecification(new TeamByIdSpec(teamId))
              .FirstOrDefault();

    For an end-to-end example of the specification pattern see the SSW.CleanArchitecture Template.

    Good Example

    Re-visiting the example above, we can apply the Specification pattern as follows:

    public sealed class OrderByIdSpec : SingleResultSpecification<Order>
    {
       public IncompleteOrderByIdSpec(Guid orderId)
       {
            Query
                .Include(o => o.OrderItems)
                .Where(o => o.Id == orderId && o.Status != OrderStatus.Complete);
       }
    } 
    
    public class OrderService(ApplicationDbContext dbContext)
    {
        public async Task AddItem(Guid orderId, OrderItem item)
        {
            var order = dbContext.Orders
                .WithSpecification(new OrderByIdSpec(orderIdorderId))
                .FirstOrDefaultAsync();
    
            order.AddItem(item);
    
            await dbContext.SaveChangesAsync();
        }
    
        public void RemoveItem(int age)
        {
            var order = dbContext.Orders
                .WithSpecification(new IncompleteOrderByIdSpec(orderIdorderId))
                .FirstOrDefaultAsync();
    
            order.RemoveItem(item);
    
            await dbContext.SaveChangesAsync();
        }
    }

    Figure: Good example - Specification used to keep Order query logic DRY

  3. Do you understand the difference between anemic and rich domain models?

    When using Domain-Centric architectures such as Clean Architecture, we need to decide where the business logic will go. There are two main approaches to this: Anemic Domain Model and Rich Domain Model. Understanding the differences between these two models is crucial for making informed decisions about your software architecture.

    Anemic Domain Model

    An Anemic Domain Model is characterized by:

    • Property Bags: Entities are simple data containers with public getters and setters
    • No Behavior: No logic or validation within the entities

    Pros of Anemic Domain Model

    • Good for simple or CRUD (Create, Read, Update and Delete) projects
    • Easier to understand for developers new to the project

    Cons of Anemic Domain Model

    • Doesn’t scale well with complexity - complex logic can be duplicated across many places in the client code
    • Difficult to maintain as the project grows - changes to logic need to be updated in multiple places
    • Less readable code - Code related to an entity is scattered across multiple places

    class Order { public int Id { get; set; } public DateTime OrderDate { get; set; } public decimal TotalAmount { get; set; } }

    Figure: Example - Anemic model where the Order class is just a data container with no behavior.:::

    Rich Domain Model

    A Rich Domain Model, on the other hand, embeds business logic in the model (within Aggegates/Entities/Value Objects/Services). This approach makes the domain the heart of your system, as opposed to being database or UI-centric.

    A Rich Domain Model is characterized by:

    • Data and Behavior: Combines data and behavior (business logic and validation) in the same entity
    • Encapsulation: Entities are responsible for maintaining their own state and enforcing invariants

    Pros of Rich Domain Model

    • Scales well with complexity - encapsulation (fundamental OOP principle) of the Domain model makes the calling client code simpler
    • Easier to maintain - cohesion (fundamental OOP principle) of the Domain model means logic is closer to the data it applies to
    • Encourages better testability - Domain model is easy to test in isolation

    Cons of Rich Domain Model

    • Steeper learning curve
    • May require more initial setup and design

    class Order { public int Id { get; private set; } public DateTime OrderDate { get; private set; } public decimal TotalAmount { get; private set; }

    public Order(DateTime orderDate) {
        OrderDate = orderDate;
        TotalAmount = 0;
    }
    
    public void AddItem(decimal itemPrice) {
        if (itemPrice <= 0)
        {
            throw new ArgumentException("Item price must be greater than zero.");
        }
    
        TotalAmount += itemPrice;
    }

    }

    Figure: Example - Rich model where the Order class encapsulates data and business logic.:::

    In both cases the Application is still responsible for communicating with external systems via abstractions implemented in the Infrastructure Layer.

    Choosing the Right Model

    Projects with complex domains are much better suited to a Rich Domain model and Domain Driven Design (DDD) approach. DDD is not an all-or-nothing commitment; you can introduce the concepts gradually where they make sense in your application.

    One side-effect of pushing logic into our Domain layer is that we can now start to write unit tests against our domain models. This is easy to do as our Domain is independent of our infrastructure or persistence layer.

    Tips for Transitioning to a Rich Domain Model

    • Start Small: Introduce DDD concepts gradually
    • Focus on Key Areas: Identify the most complex parts of your domain and refactor them first
    • Emphasize Testability: Take advantage of the isolated domain model to write comprehensive unit tests
    • Iterate and Improve: Continuously refine your domain model as the project evolves

    By understanding the differences between anemic and rich domain models, you can make informed decisions about your software architecture and ensure that your project scales effectively with complexity.

    How does the Application Layer interact with the Model?

    When using Clean Architecture we consider the Application Layer is part of the 'Core' of our system. It needs to interact with the Domain Layer for the system to function. This will happen in two slightly different ways depending on the underlying model.

    1. Anemic Domain Model: Application Layer follows the 'Transaction Script' pattern. The Application will contain all logic in the system. It will use the Domain Layer to update state, but will be in full control of the changes. There is no logic in the Domain and the entities become 'Data Access Objects'.
    2. Rich Domain Model: Application Layer becomes the 'Orchestrator' of the Domain. It is responsible for fetching the entities from the Persistence Layer, but will delegate to the Domain for any updates. The Domain Layer will be responsible for maintaining the state of the system and enforcing invariants.
  4. Do you use ubiquitous language?

    Ubiquitous language is a core principle in domain-driven design (DDD) that encourages developers and stakeholders to use the same vocabulary when discussing business logic and domain concepts. By using a shared, domain-specific language across code, documentation, and conversations, you ensure that everyone has a common understanding of core concepts. This approach reduces misunderstandings and makes the codebase more accessible to those familiar with the business domain.

    Why Ubiquitous Language Matters

    Ubiquitous language helps bridge the gap between technical and non-technical stakeholders, creating a consistent and clear understanding of the domain. When everyone uses the same terms — whether in code, documentation, or discussions — it’s easier to align on requirements, troubleshoot issues, and onboard new team members. Without it, terms can become muddled, causing confusion and misinterpretation.

    Let’s say you’re working on an insurance system, and the domain term “policyholder” is used consistently among stakeholders. However, in the codebase, you see different terms used interchangeably: AccountOwner, Customer, and InsuredParty. Each of these terms could technically represent the policyholder, but the inconsistency can lead to confusion and misunderstandings about the exact role of each entity.

    Terms in the code do not reflect domain language used by stakeholders

    To follow ubiquitous language, you would use PolicyHolder consistently across the codebase, aligning the code’s vocabulary with the language used by domain experts. This approach eliminates ambiguity, making it clear that PolicyHolder refers to the specific entity recognized by all stakeholders.

    Ubiquitous language is used, and developers and stakeholders are on the same page

    Benefits

    • Improved Communication: By using the same terms as domain experts, developers and stakeholders communicate more effectively, reducing the risk of misinterpretation.
    • Increased Readability: Consistent terminology makes it easier for anyone familiar with the domain to understand the codebase.
    • Enhanced Maintenance: When domain terms are used uniformly, developers spend less time deciphering concepts and more time building functionality.

    💡 Tip: You can use the Contextive extension for IntelliJ and VS Code (other IDEs coming soon) to assist with this. The linked repo also has a discussion between Chris Simon (the author) and SSW's Gert Marx about both the extension and ubiquitous language in general

We open source.Loving SSW Rules? Star us on GitHub. Star
Stand by... we're migrating this site to TinaCMS