Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Product Configuration Relationships

I am creating a check out process, one step of which involves configuring a product. The use cases are as follows:

Product configuration

A product configuration is a set of configurable option groups.

Option Group

Each option group can consist of one selected option (or none), an the group consists of multiple options.

A user can add and remove options from a product group.

As an example, an option group may be called Databases.

Option

An option is a specific option of an option group.

As an example in the case of options belonging to the database option group, specific options may be MySQL, or MS-SQL.

Option Group Dependency Option groups can have a dependency on one other option group, so that specific items are filtered out should the requirement on the target option group not be met.

There is only one target dependency, we don’t need to worry about options in a product option group pointing to more than one target product option group.

For example, in order to allow the MS-SQL option to be selected in the database product group, the Windows option must be selected from the Operating System option group.

Similarly, in order to allow the MySQL option to be selected on the database product group, either Windows or Linux options must be selected from the Operating System option group.

Structure

enter image description here

In the above diagram, the MySQL (ID = 201) product option has a dependency on Windows (ID = 101) or Linux (ID = 102) product options of the OS product option group. If either of these operating system options are selected, then MySQL is shown.

The MS-SQL (ID = 202) product option has a dependency on Windows (ID = 101) product option of the OS product option group. Only when Windows operating system is selected will MS-SQL be shown.

Question - Where to store the dependency mapping data?

The question for now as the code evolves, is where to store the relationship dependency mapping between product options and their groups. The main issues I am questioning are:

Separate aggregate, manage transactions

Do we store the mapping in its own aggregate, if so how would we detect and stop deletions of Products and ProductOptionGroups that are referenced?

For example, if there is a dependency on the operating system Windows, we must protect it and not allow removal from the OS ProductOptionGroup if other OptionGroups have dependencies on it.

Would this be done by an application service? How would be build a transaction in our code?

Inside aggregate, easier transaction management, higher potential for concurrency issues

Do we store the mapping within the OptionGroup aggregate, however if we do then if someone updated the name and description of an OptionGroup, whilst another user was editing the mapping data, then there would be a concurrency exception on commit.

This doesn't really make sense as mapping data should not fail if someone updates a name, they are two unrelated concepts.

What would others do in this situation and how would I best structure the code for the above scenarios? Or am i missing some deeper insight staring at me from my aggregates that if redesigned will make things easier.

I think accessing ProductOptions inside the ProductOptionGroup from outside is forbidden by DDD design but i cant think of how to model it any other way at this time.

Edit for Giacomo Tesio's proposed answer

Thank you for the proposed answer and for taking the time to help. I really like the neat and concise coding style. Your answer does raise some further questions as below, I may very well be barking up the wrong tree but would appreciate clarification on:

  1. In OptionGroup, there is a _descriptions dictionary, this is used to contain the descriptions of the Options.

    Why is the option description property not part of the Option object?

  2. You mentioned an Option is a value object.

    In this case it has a member called _id of type OptionIdentity, are value objects allowed to have an identifying Id?

  3. In the code for Option, it takes a constructor of id, and list of dependencies.

    I understand an Option only exist as part of an OptionGroup (as the OptionIdentity type requires the member _group of type OptionGroupIdentity). Is one Option allowed to hold a reference to another Option that could be inside a different OptionGroup aggregate instance? Does this violate the DDD rule of holding references only to aggregate roots and not reference the things inside?

  4. Typically I have persisted aggregate roots and their child entities as the whole object and not separately, I do this by having the object/list/dictionary as a member within the aggregate root. For the Option code, it takes a set of dependencies (of type OptionIdentity[]).

    How would Options be rehydrated from the repository? If it is an entity contained within another entity, then should it not come as part of the aggregate root and be passed in to the constructor of OptionGroup?

like image 504
morleyc Avatar asked Nov 12 '13 15:11

morleyc


1 Answers

This is a well formulated question, even if a domain model should use the language that the domain that the experts talk, and I would guess that domain experts don't talk about ProductConfigurations, ProductOptionsGroups and Options. Thus, you should get a talk with an expert on the domain (tipically a target user of the application) to understand the terms he would use while doing such task "on paper".

However in the rest of the answer I will assume that the term used here are correct.
Moreover, note that my answer is modeled after your description of the domain, but a different description could lead to a deeply different model.

Bounded Context
You have 3 bounded context to model:

  • A shared kernel, that contains common concept that works like contracts. Both the other BC will depend on this.
  • Options' Management, related to the creation and management of the OptionsGroups and their dependencies (I would use a namespace named OptionsManagement for this BC)
  • Products' Management, related to the creation and management of the Products' Configurations (I would use a namespace named ProductsManagement for this BC)

Shared kernel
This step is easy, you just need a few identifiers here, that will work as shared identifiers:

namespace SharedKernel
{
    public struct OptionGroupIdentity : IEquatable<OptionGroupIdentity>
    {
        private readonly string _name;
        public OptionGroupIdentity(string name)
        {
            // validation here
            _name = name;
        }

        public bool Equals(OptionGroupIdentity other)
        {
            return _name == other._name;
        }

        public override bool Equals(object obj)
        {
            return obj is OptionGroupIdentity 
                && Equals((OptionGroupIdentity)obj);
        }

        public override int GetHashCode()
        {
            return _name.GetHashCode();
        }

        public override string ToString()
        {
            return _name;
        }
    }

    public struct OptionIdentity : IEquatable<OptionIdentity>
    {
        private readonly OptionGroupIdentity _group;
        private readonly int _id;
        public OptionIdentity(int id, OptionGroupIdentity group)
        {
            // validation here
            _group = group;
            _id = id;
        }

        public bool BelongTo(OptionGroupIdentity group)
        {
            return _group.Equals(group);
        }

        public bool Equals(OptionIdentity other)
        {
            return _group.Equals(other._group)
                && _id == other._id;
        }

        public override bool Equals(object obj)
        {
            return obj is OptionIdentity 
                && Equals((OptionIdentity)obj);
        }

        public override int GetHashCode()
        {
            return _id.GetHashCode();
        }

        public override string ToString()
        {
            return _group.ToString() + ":" + _id.ToString();
        }
    }
}

Options' Management
In OptionsManagement you have only one mutable entity named OptionGroup, something like this (code in C# with persistence, argument checks and all...), the exceptions (such as DuplicatedOptionException and MissingOptionException) and the events raised when the group change it's state.

A partial definition of OptionGroup could be something like

public sealed partial class OptionGroup : IEnumerable<OptionIdentity>
{
    private readonly Dictionary<OptionIdentity, HashSet<OptionIdentity>> _options;
    private readonly Dictionary<OptionIdentity, string> _descriptions;
    private readonly OptionGroupIdentity _name;

    public OptionGroupIdentity Name { get { return _name; } }

    public OptionGroup(string name)
    {
        // validation here
        _name = new OptionGroupIdentity(name);
        _options = new Dictionary<OptionIdentity, HashSet<OptionIdentity>>();
        _descriptions = new Dictionary<OptionIdentity, string>();
    }

    public void NewOption(int option, string name)
    {
        // validation here
        OptionIdentity id = new OptionIdentity(option, this._name);
        HashSet<OptionIdentity> requirements = new HashSet<OptionIdentity>();
        if (!_options.TryGetValue(id, out requirements))
        {
            requirements = new HashSet<OptionIdentity>();
            _options[id] = requirements;
            _descriptions[id] = name;
        }
        else
        {
            throw new DuplicatedOptionException("Already present.");
        }
    }

    public void Rename(int option, string name)
    {
        OptionIdentity id = new OptionIdentity(option, this._name);
        if (_descriptions.ContainsKey(id))
        {
            _descriptions[id] = name;
        }
        else
        {
            throw new MissingOptionException("OptionNotFound.");
        }
    }

    public void SetRequirementOf(int option, OptionIdentity requirement)
    {
        // validation here
        OptionIdentity id = new OptionIdentity(option, this._name);
        _options[id].Add(requirement);
    }

    public IEnumerable<OptionIdentity> GetRequirementOf(int option)
    {
        // validation here
        OptionIdentity id = new OptionIdentity(option, this._name);
        return _options[id];
    }

    public IEnumerator<OptionIdentity> GetEnumerator()
    {
        return _options.Keys.GetEnumerator();
    }

    System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
    {
        return GetEnumerator();
    }
}

Products' Management
In the ProductsManagement namespace you will have - an Option value object (thus immutable) that is able to check his own dependencies given a set of previously selected options - A ProductConfiguration entity, identified by a ProductIdentity that is able to decide which Options should be enabled given the options already enabled. - A few exceptions, persistence and so on...

What you can note in the following (really simplified) code sample is that obtaining the list of Options for each OptionGroupIdentity, and initializing the ProductConfiguration is out of the domain itself. Indeed simple SQL queries or custom application code can handle both.

namespace ProductsManagement 
{
    public sealed class Option
    {
        private readonly OptionIdentity _id;
        private readonly OptionIdentity[] _dependencies;

        public Option(OptionIdentity id, OptionIdentity[] dependencies)
        {
            // validation here
            _id = id;
            _dependencies = dependencies;
        }

        public OptionIdentity Identity
        {
            get
            {
                return _id;
            }
        }

        public bool IsEnabledBy(IEnumerable<OptionIdentity> selectedOptions)
        {
            // validation here
            foreach (OptionIdentity dependency in _dependencies)
            {
                bool dependencyMissing = true;
                foreach (OptionIdentity option in selectedOptions)
                {
                    if (dependency.Equals(option))
                    {
                        dependencyMissing = false;
                        break;
                    }
                }
                if (dependencyMissing)
                {
                    return false;
                }
            }

            return true;
        }
    }

    public sealed class ProductConfiguration
    {
        private readonly ProductIdentity _name;
        private readonly OptionGroupIdentity[] _optionsToSelect;
        private readonly HashSet<OptionIdentity> _selectedOptions;
        public ProductConfiguration(ProductIdentity name, OptionGroupIdentity[] optionsToSelect)
        {
            // validation here
            _name = name;
            _optionsToSelect = optionsToSelect;
        }

        public ProductIdentity Name
        {
            get
            {
                return _name;
            }
        }

        public IEnumerable<OptionGroupIdentity> OptionGroupsToSelect
        {
            get
            {
                return _optionsToSelect;
            }
        }

        public bool CanBeEnabled(Option option)
        {
            return option.IsEnabledBy(_selectedOptions);
        }

        public void Select(Option option)
        {
            if (null == option)
                throw new ArgumentNullException("option");
            bool belongToOptionsToSelect = false;
            foreach (OptionGroupIdentity group in _optionsToSelect)
            {
                if (option.Identity.BelongTo(group))
                {
                    belongToOptionsToSelect = true;
                    break;
                }
            }
            if (!belongToOptionsToSelect)
                throw new UnexpectedOptionException(option);
            if (!option.IsEnabledBy(_selectedOptions))
                throw new OptionDependenciesMissingException(option, _selectedOptions);
            _selectedOptions.Add(option.Identity);
        }


        public void Unselect(Option option)
        {
            if (null == option)
                throw new ArgumentNullException("option");
            bool belongToOptionsToSelect = false;
            foreach (OptionGroupIdentity group in _optionsToSelect)
            {
                if (option.Identity.BelongTo(group))
                {
                    belongToOptionsToSelect = true;
                    break;
                }
            }
            if (!belongToOptionsToSelect)
                throw new UnexpectedOptionException(option);
            if (!_selectedOptions.Remove(option.Identity))
            {
                throw new CannotUnselectAnOptionThatWasNotPreviouslySelectedException(option, _selectedOptions);
            }
        }
    }

    public struct ProductIdentity : IEquatable<ProductIdentity>
    {
        private readonly string _name;
        public ProductIdentity(string name)
        {
            // validation here
            _name = name;
        }

        public bool Equals(ProductIdentity other)
        {
            return _name == other._name;
        }

        public override bool Equals(object obj)
        {
            return obj is ProductIdentity
                && Equals((ProductIdentity)obj);
        }

        public override int GetHashCode()
        {
            return _name.GetHashCode();
        }

        public override string ToString()
        {
            return _name;
        }
    }

    // Exceptions, Events and so on...
}

The domain model should only contains business logic like this.

Indeed, you need a domain model if and only if the business logic is complex enough to worth isolation from the rest of applicative concerns (like persistence, for example). You know you need a domain model when you need to pay a domain expert to understand what the whole application is about.
I use events to obtains such isolation but you can use any other technique.

Thus, to answer your question:

Where to store the dependency mapping data?

Storage is not that relevant in DDD, but following the principle of least knowledge I would store them only in the schema dedicated to the persistence of the options' management BC. Domain's and application's services could simply query such tables when they need them.

Moreover

Do we store the mapping within the OptionGroup aggregate, however if we do then if someone updated the name and description of an OptionGroup, whilst another user was editing the mapping data, then there would be a concurrency exception on commit.

Don't be afraid of such issues until you actually meet them. They can simply be solved with an explicit exception that inform the user. Indeed I'm not so sure that the user adding a dependency would consider safe a successful commit when a dependency change names.

You should talk to the customer and to the domain expert to decide this.

And BTW, the solution is ALWAYS to make things explicit!

Edit to answer the new questions

  1. In OptionGroup, there is a _descriptions dictionary, this is used to contain the descriptions of the Options.

    Why is the option description property not part of the Option object?

In the OptionGroup (or Feature) bounded context, there's no Option object. This might look strange, even wrong at first, but an Option object in that context wouldn't provide any added value in that context. Holding a description is not enough to define a class.

To my money, however, OptionIdentity should contain the description, not an integer. Why? Because the integer won't say anything to the domain expert. "OS:102" has no meaning for anyone, while "OS:Debian GNU/Linux" will be explicit in logs, exceptions and brainstorms.

That's the same reason why I would replace the terms of your example with more business oriented ones (feature instead of optionGroup, solution instead of option and requirement instead of dependency): you nead a domain model only if you have a business rules so complex than forced the domain experts to design a new, often cryptic, conventional language to express them precisely and you need to understand it enough to build your application.

  1. You mentioned an Option is a value object.

    In this case it has a member called _id of type OptionIdentity, are value objects allowed to have an identifying Id?

Well, this is a good question.

An identity is what we use to communicate something when we care about its changes.
In the ProductsManagement context we don't care about Option's evolution, all we want to model there is ProductConfiguration evolution. Indeed in that context the Option (or Solution with a probably better wording) is a value that we want to be immutable.

That's why I said that Option is a value object: we don't care about the evolution of "OS:Debian GNU/Linux" in that context: we just want to ensure that its requirements are met by the ProductConfiguration at hand.

  1. In the code for Option, it takes a constructor of id, and list of dependencies.

    I understand an Option only exist as part of an OptionGroup (as the OptionIdentity type requires the member _group of type OptionGroupIdentity). Is one Option allowed to hold a reference to another Option that could be inside a different OptionGroup aggregate instance? Does this violate the DDD rule of holding references only to aggregate roots and not reference the things inside?

No. That's why I designed the shared identifiers modelling patterns.

  1. Typically I have persisted aggregate roots and their child entities as the whole object and not separately, I do this by having the object/list/dictionary as a member within the aggregate root. For the Option code, it takes a set of dependencies (of type OptionIdentity[]).

    How would Options be rehydrated from the repository? If it is an entity contained within another entity, then should it not come as part of the aggregate root and be passed in to the constructor of OptionGroup?

No Option is not an entity at all! It's a value!

You can cache them, if you have proper clean-up policy. But they wont be provided by a repository: your application will call an application service like the following to retrieve them when needed.

// documentation here
public interface IOptionProvider
{
    // documentation here with expected exception
    IEnumerable<KeyValuePair<OptionGroupIdentity, string>> ListAllOptionGroupWithDescription();

    // documentation here with expected exception
    IEnumerable<Option> ListOptionsOf(OptionGroupIdentity group);

    // documentation here with expected exception
    Option FindOption(OptionIdentity optionEntity)
}
like image 176
Giacomo Tesio Avatar answered Oct 04 '22 12:10

Giacomo Tesio