DEV Community

Cover image for Domain Model Patterns: Bringing Your Domain to Life
Horse Patterns
Horse Patterns

Posted on • Edited on

Domain Model Patterns: Bringing Your Domain to Life

In complex software systems, how you structure your domain logic can make the difference between chaos and clarity. Domain Model Patterns provide a powerful way to organize business logic using objects that mirror the real-world rules and concepts of your application. These patterns help developers build expressive, maintainable, and adaptable systems that evolve gracefully with changing business needs.

Let's explore the key building blocks of the Domain Model and how they work together to bring your domain to life.

Value Objects: Small, Immutable and Value Equality

Value Objects are characterized solely by their attributes. They are immutable, meaning their internal state cannot be changed once created. Unlike entities, they have no identity. Two Value Objects with the same data are considered equal, regardless of where or how they are used. This makes them ideal for modeling concepts like measurements, addresses or contact information, where the focus is on the values themselves rather than on individual instances.

Example – Contact Value Object

Here's an implementation of a Contact Value Object. It includes validation for the email format and encapsulates contact details in an immutable, value-based structure:

public class Contact implements ValueObject {

    private String email;

    private String mobileNumber;

    private String address;

    private Contact(String email,
                    String mobileNumber,
                    String address) {
        setEmail(email);
        this.mobileNumber = mobileNumber;
        this.address = address;
    }

    public static Contact of(final String email,
                             final String mobileNumber,
                             final String address) {
        return new Contact(
                email,
                mobileNumber,
                address
        );
    }

    private void setEmail(String email) {
        requireNonNull(
                email,
                "the email cannot be null."
        );

        String regexPattern = "^(?=.{1,64}@)[A-Za-z0-9_-]+(\\.[A-Za-z0-9_-]+)*@"
                + "[^-][A-Za-z0-9-]+(\\.[A-Za-z0-9-]+)*(\\.[A-Za-z]{2,})$";

        if (!isMatches(email, regexPattern)) {
            throw new IllegalArgumentException("Invalid email address.");
        }
        this.email = email;
    }
    .....
}
Enter fullscreen mode Exit fullscreen mode

This approach ensures that each Contact object is always valid and consistent. Since it's immutable, any change (like updating the email) results in the creation of a new instance, preserving data integrity.

Entities: Unique Identity

Unlike Value Objects, Entities are defined by a unique identity that remains constant throughout their lifecycle. Their internal state may evolve, but their identity persists. Entities are used when it's important to track an object as a distinct individual over time, regardless of how its attributes change.

Example – PersonalData Entity

Here's an implementation of a PersonalData Entity. It uses a citizenId as a unique identifier and includes mutable fields such as name, age, and contact information:

public class PersonalData extends AbstractEntity<String> {

    private String name;
    private int age;
    private Contact contact;

    private PersonalData(String citizenId,
                         String name,
                         int age,
                         Contact contact) {
        super(citizenId);
        setContact(contact);
        this.age = age;
        setName(name);
    }

    public static PersonalData of(final String citizenId,
                                  final String name,
                                  final int age,
                                  final Contact contact) {
        return new PersonalData(
                citizenId,
                name,
                age,
                contact
        );
    }

    protected void updateEmail(final String newEmail) {
        this.contact = Contact.of(
                newEmail,
                contact.mobileNumber(),
                contact.address()
        );
    }
    .....
}
Enter fullscreen mode Exit fullscreen mode

This example illustrates how Entities encapsulate both data and behavior. The PersonalData class ensures that business rules, such as input validation or managing state changes, are enforced through well-defined methods.

Aggregates: Enforcing Consistency Boundaries

An Aggregate is a group of related Entities and Value Objects treated as a single unit for data changes. It establishes a boundary that encapsulates business rules and enforces consistency.

At the heart of every Aggregate is the Aggregate Root, the only entry point for making changes within the boundary. This Root controls access and ensures the integrity of all enclosed components.

Example – Athlete Aggregate Root or Root Entity

The Athlete class serves as the Aggregate Root. It encapsulates related information such as PersonalData and Category, and provides methods to enforce business rules like updating contact info or assigning a category based on age:

public class Athlete extends AbstractAggregateRoot<String> {

    private PersonalData personalData;
    private Category category;

    private Athlete(String athleteId,
                    PersonalData personalData) {
        super(athleteId);
        setPersonalData(personalData);
        setCategory(personalData.age());
    }

    public static Athlete create(final String athleteId,
                                 final PersonalData personalData) {
        return new Athlete(
                athleteId,
                personalData
        );
    }

    public void updateEmail(final String newMobileNumber) {
        this.personalData.updateEmail(newMobileNumber);
    }

    private void setPersonalData(PersonalData personalData) {
        this.personalData = requireNonNull(
                personalData,
                "The personalData cannot be null."
        );
    }

    private void setCategory(int age) {
        this.category = Arrays.stream(Category.values())
                .filter(c -> age >= c.minAge() && age <= c.maxAge())
                .findFirst()
                .orElseThrow(() -> new IllegalArgumentException("Category not found for the age: " + age));
    }
    .....
}
Enter fullscreen mode Exit fullscreen mode

This example highlights how the Athlete class acts as a gateway to the aggregate. All updates, such as changing an email address or determining the athlete's category based on age, are managed through this root. This ensures that the internal state of the aggregate remains valid and consistent.

Supporting Patterns: Repositories, Domain Services and Factories

Beyond the core domain model, supporting patterns help organize responsibilities, reduce complexity and maintain a clear separation of concerns.

Repositories

Repositories provide controlled access to Aggregates. They handle storing, retrieving and searching for domain objects without leaking persistence logic into the domain model itself.

Domain Services

A Domain Service handles business operations that don't naturally belong to any single Entity or Value Object. It is stateless and often coordinates activities across multiple Aggregates.

Factories

Factories encapsulate the creation logic of complex objects, ensuring that Entities and Aggregates are always instantiated in a valid, consistent state.

Conclusion

Applying Domain Model Patterns is more than an architectural choice - it's a mindset that brings clarity, robustness, and agility to your software. By distinguishing between Value Objects and Entities, organizing related data into Aggregates, and supporting them with Repositories, Domain Services, and Factories, you can design systems that reflect the true nature of your business domain.

These patterns not only help you write cleaner code but also ensure that your software remains flexible and adaptable as it grows. Embrace these principles, and you'll be well on your way to building software that stands the test of time.

Additional Resources

For a step-by-step video walkthrough of this example and further explanation of the pattern in action, watch the full tutorial:

🟥▶️https://www.youtube.com/watch?v=cb_h_LHMgjw&t

Remember, real speed doesn't come from rushing. It comes from doing things right. As Robert C. Martin said, “The only way to go fast, is to go well.

References

  • Evans, E. Domain-Driven Design: Tackling Complexity in the Heart of Software. Addison-Wesley, 2003.

  • Vernon, V. Implementing Domain-Driven Design. Addison-Wesley, 2013.

Top comments (0)