Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to model aggregates that will be created in multiple steps, like wizard style

I will use Airbnb as an example.

When you sign up an Airbnb account, you can become a host by creating a listing. To create a listing, Airbnb UI guides you through the process of creating a new listing in multiple steps:

enter image description here

It will also remember your furthest step you've been, so next time when you want to resume the process, it will redirect to where you left.


I've been struggling to decide whether I should put the listing as the aggregate root, and define methods as available steps, or treat each step as their own aggregate roots so that they're small?

Listing as Aggregate Root

public sealed class Listing : AggregateRoot
{
    private List<Photo> _photos;

    public Host Host { get; private set; }
    public PropertyAddress PropertyAddress { get; private set; }
    public Geolocation Geolocation { get; private set; }
    public Pricing Pricing { get; private set; }
    public IReadonlyList Photos => _photos.AsReadOnly();
    public ListingStep LastStep { get; private set; }
    public ListingStatus Status { get; private set; }

    private Listing(Host host, PropertyAddress propertyAddress)
    {
        this.Host = host;
        this.PropertyAddress = propertyAddress;
        this.LastStep = ListingStep.GeolocationAdjustment;
        this.Status = ListingStatus.Draft;

        _photos = new List<Photo>();
    }

    public static Listing Create(Host host, PropertyAddress propertyAddress)
    {
        // validations
        // ...
        return new Listing(host, propertyAddress);
    }

    public void AdjustLocation(Geolocation newGeolocation)
    {
        // validations
        // ...
        if (this.Status != ListingStatus.Draft || this.LastStep < ListingStep.GeolocationAdjustment)
        {
            throw new InvalidOperationException();
        }
 
        this.Geolocation = newGeolocation;
    }

    ...
}

Most of the complex classes in the aggregate root are just value objects, and ListingStatus is just a simple enum:

public enum ListingStatus : int
{
    Draft = 1,
    Published = 2,
    Unlisted = 3,
    Deleted = 4
}

But ListingStep could be an enumeration class that stores the next step the current step can advance:

using Ardalis.SmartEnum;

public abstract class ListingStep : SmartEnum<ListingStep>
{
    public static readonly ListingStep GeolocationAdjustment = new GeolocationAdjustmentStep();
    public static readonly ListingStep Amenities = new AmenitiesStep();
    ...

    private ListingStep(string name, int value) : base(name, value) { }

    public abstract ListingStep Next();

    private sealed class GeolocationAdjustmentStep : ListingStep
    {
        public GeolocationAdjustmentStep() :base("Geolocation Adjustment", 1) { }

        public override ListingStep Next()
        {
            return ListingStep.Amenities;
        }
    }

    private sealed class AmenitiesStep : ListingStep
    {
        public AmenitiesStep () :base("Amenities", 2) { }

        public override ListingStep Next()
        {
            return ListingStep.Photos;
        }
    }

    ...
}

The benefits of having everything in the listing aggregate root is that everything would be ensured to have transaction consistency. And the steps are defined as one of the domain concerns.

The drawback is that the aggregate root is huge. On each step, in order to call the listing actions, you have to load up the listing aggregate root, which contains everything.

To me, it sounds like except the geolocation adjustment might depend on the property address, other steps don't depend on each other. For example, the title and the description of the listing doesn't care what photos you upload.

So I was thinking whether I can treat each step as their own aggregate roots?

Each step as own Aggregate Root

public sealed class Listing : AggregateRoot
{
    public Host Host { get; private set; }
    public PropertyAddress PropertyAddress { get; private set; }

    private Listing(Host host, PropertyAddress propertyAddress)
    {
        this.Host = host;
        this.PropertyAddress = propertyAddress;
    }

    public static Listing Create(Host host, PropertyAddress propertyAddress)
    {
        // Validations
        // ...
        return new Listing(host, propertyAddress);
    }
}

public sealed class ListingGeolocation : AggregateRoot
{
    public Guid ListingId { get; private set; }
    public Geolocation Geolocation { get; private set; }

    private ListingGeolocation(Guid listingId, Geolocation geolocation)
    {
        this.ListingId = listingId;
        this.Geolocation = geolocation;
    }

    public static ListingGeolocation Create(Guid listingId, Geolocation geolocation)
    {
        // Validations
        // ...
        return new ListingGeolocation(listingId, geolocation);
    }
}

...

The benefits of having each step as own aggregate root is that it makes aggregate roots small (To some extends I even feel like they're too small!) so when they're persisted back to data storage, the performance should be quicker.

The drawback is that I lost the transactional consistency of the listing aggregate. For example, the listing geolocation aggregate only references the listing by the Id. I don't know if I should put a listing value object there instead so that I can more information useful in the context, like the last step, listing status, etc.

Close as Opinion-based?

I can't find any example online where it shows how to model this wizard-like style in DDD. Also most examples I've found about splitting a huge aggregate roots into multiple smaller ones are about one-to-many relationships, but my example here is mostly about one-to-one relationship (except photos probably).

I think my question would not be opinion-based, because

  1. There are only finite ways to go about modeling aggregates in DDD
  2. I've introduced a concrete business model airbnb, as an example.
  3. I've listed 2 approaches I've been thinking.

You can suggest me which approach you would take and why, or other approaches different from the two I listed and the reasons.

like image 398
David Liang Avatar asked Feb 08 '21 09:02

David Liang


1 Answers

Let's discuss a couple of reasons to split up a large-cluster aggregate:

  • Transactional issues in multi-user environments. In our case, there's only one Host managing the Listing. Only reviews could be posted by other users. Modelling Review as a separate aggregate allows transactional consistency on the root Listing.
  • Performance and scalability. As always, it depends on your specific use case and needs. Although, once the Listing has been created, you would usually query the entire listing in order to present it to the user (apart from perhaps a collapsed reviews section).

Now let's have a look at the candidates for value objects (requiring no identity):

  • Location
  • Amenities
  • Description and title
  • Settings
  • Availability
  • Price

Remember there are advantages to limiting internal parts as value objects. For one, it greatly reduces overall complexity.

As for the wizard part, the key take away is that the current step needs to be remembered:

..., so next time when you want to resume the process, it will redirect to where you left.

As aggregates are conceptually a unit of persistence, resuming where you left off will require us to persist partially hydrated aggregates. You could indeed store a ListingStep on the aggregate, but does that really make sense from a domain perspective? Do the Amenities need to be specified before the Description and Title? Is this really a concern for the Listing aggregate or can this perhaps be moved to a Service? When all Listings are created through the use of the same Service, this Service could easily determine where it left off last time.

Pulling this wizard approach into the domain model feels like a violation of the Separation of Concerns principle. The B&B domain experts might very well be indifferent concerning the wizard flow.

Taking all of the above into account, the Listing as aggregate root seems like a good place to start.


UPDATE

I thought about the wizard being the concept of the UI, rather than of the domain, because in theory, since each step doesn't depend on others, you can finish any step in any order.

Indeed, the steps being independent is a clear indication that there's no real invariant, posed by the aggregate, on the order the data is entered. In this case, it's not even a domain concern.

I have no problem modeling those steps as their own aggregate roots, and have the UI determine where it left off last time.

The wizard steps (pages) shouldn't map to aggregates of their own. Following DDD, user actions will typically be forwarded to an Application API/Service, which in turn can delegate work to domain objects and services. The Application Service is only concerned with technical/infrastructure stuff (eg persistence), where as the domain objects and services hold the rich domain logic and knowledge. This often referred to as the Onion or Hexagonal architecture. Note that the dependencies point inward, so the domain model depends on nothing else, and knows about nothing else.

Onion arch

Another way to think about wizards is that these are basically data collectors. Often at the last step some sort of processing is done, but all steps before that usually just collect data. You could use this feature to wrap all data when the user closes the wizard (prematurely), send it to the Application API and then hydrate the aggregate and persist it until next time the user comes round. That way you only need to perform basic validation on the pages, but no real domain logic is involved.

My only concern of that approach is that, when all the steps are filled in and the listing is ready to be reviewed and published, who's responsible for it? I thought about the listing aggregate, but it doesn't have all the information.

This is where the Application Service, as a delegator of work, comes into play. By itself it holds no real domain knowledge, but it "knows" all the players involved and can delegate work to them. It's not an unbound context (no pun intended), as you want to keep the transactional scope limited to one aggregate at a time. If not, you'll have to resort to two stage commits, but that's another story.

To wrap it up, you could store the ListingStatus on Listing and make the invariant behind it a responsibility of the root aggregate. As such, it should have all the information, or be provided with it, to update the ListingStatus accordingly. In other words, it's not about the wizard steps, it's about the nouns and verbs that describe the processes behind the aggregate. In this case, the invariant that guards all data is entered and that it is currently in a correct state to be published. From then on, it's illegal to return to, and persist, the aggregate with only partial state or in an incoherent manner.

like image 138
Funk Avatar answered Oct 21 '22 08:10

Funk