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

4

7

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.

Chalkboard answered 8/2, 2021 at 9:12 Comment(3)
Short answer as comment. Read about Builder pattern and State Machine.Doall
@AdamJachocki: can you post your answer with examples? I've read about those patterns.Chalkboard
this question should not be opinion-basedFrustule
H
3

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.

Heartbreak answered 10/2, 2021 at 21:1 Comment(5)
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. I have no problem modeling those steps as their own aggregate roots, and have the UI determine where it left off last time. 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.Chalkboard
@DavidLiang I updated my post to answer your questions.Heartbreak
thanks for the updates. So if each step doesn't depend on others, then I can model them as separate aggregates, and have the UI and application service figure out the last step. I'm using CQRS so when building the UI, I can look for the persistence storage and figure out which data is missing hence I can figure out what furthest step was. I am pretty OK with that. My last concern is, when everything on each step is filled, and the listing is ready to be published, which domain model should the application service delegate the work to?Chalkboard
I am still hesitating whether I should have a concept of draft listing and published listing. So when everything on each step is filled, the application service can delegate the work to published listing. With this approach, I don't need to keep a status in the listing aggregate, hence each aggregate, modeled in each step, doesn't need to deal with the status, because they know they're in the draft listing context. My problem with this approach is that when transferring from draft listing to published listing, I need to keep the identity (i.e, use draft listing Id as the published listing Id).Chalkboard
If I don't have a concept of draft and published listing, then I would imagine the listing would carry a status, and at the final step, the application service will delegate the work to the listing aggregate, which would set its status to published. My problem is that, on each child aggregate I model each step, it not only needs to have the listing id, but also need the listing status. Even I can model them as value object, who's responsible to build that value object (id + status)? The listing repository? It doesn't make sense to me if it has a method to only return a value object.Chalkboard
Q
0

Like any other aggregate. It shouldn't care if you collect the needed data in a multistep wizard or in just one screen. It's a UI issue, gathering the data and passing it to the domain at the end of the wizard.

Quiff answered 11/2, 2021 at 6:37 Comment(1)
Like what I said in the other comment, I thought about the multi-step is the UI issue so those steps could be modeled as their own aggregates. My question is when all steps are filled in and the listing is ready to be reviewed and published, which aggregate root is responsible for it? The listing aggregate root, which has the initial create() method, has no information about other step. I also thought about there might be 2 bounded contexts: draft listing and listing. Only draft listing has the wizard style.Chalkboard
I
0

You're trying to design your system based on the UI (the wizard step)!
In Domain-Driven Design you shouldn't really care about the UI (which is a technical detail),
you should look for the bounded contexts, invariants, etc.

For Example: Listing bounded-context: property and guests, location, amenities, description and title Booking bounded-context: booking settings, calendar and availability, pricing Review bounded-context:

the listing doesn't have to be a global one,
you can display the listings for which you have all required information from the 'Listing context' and are availability for the search period, etc.

Inspan answered 15/2, 2021 at 22:52 Comment(1)
That AirBnb wizard was just an example. Whether I design my system based on the UI or not was not the point of my question. Let's say I do have a domain requirement that the listing, or in fact just any huge aggregate, has to be filled out in steps. How would you do that in DDD?Chalkboard
B
0

In my experience, DDD was a design methodology that came from a culture of what we'd now call Java backend data modeling. Modern web development has matured and evolved quite a bit since then with Angular/React/Vue frameworks that have their own paradigms about data modeling. Coming from a UX background, I'll elaborate on how to structure UI components that integrate with DDD models.

Separate data from presentation

MVC design works here. Naively, the end result of this workflow is the construction of a Listing domain model. But, I'm sure AirBnB's domain model for a listing is much more complex. Let's approximate that by considering each "step" as a form that constructs independent models. To simplify, let's only consider models for Photo and Location.

Class Photo:           Class Location:
  id                    guid
  src                   geolocation

Provide a view for each model

Think of these UI components as "form" models that should work outside the context of a wizard. All of their fields are nullable, which represent incomplete steps. As an invariant, a view is valid iff it can construct a valid instance of the associated model.

Class PhotoView:       Class LocationView:
  id                       guid
  src                      geolocation
  valid { get }            valid { get }

Define the Controller

Now, consider a View-Model WizardView to help orchestrate the independent views into "Wizard" behavior. We already have the independent views taking care of "valid/invalid" state. Now we just need an idea of "current" step. In the AirBnb UX, it seems like the "current" step is more of a "selected" state where the list item is expanded and all others are collapsed. Either way, a full page transition or "selected" represents the same state of "this step is active <-> all others are inactive." If _selected is null, traverse steps[] for the first invalid step, otherwise, null <--> all valid.

A StepView could display a whole page or, in the case of AirBnb, a single list item, where status == view.valid.

Class WizardView:          Class StepView:
   steps[]                    title
   _selected                  view
   selected { get set }       status { get }
   addStep(StepView)
   submit()

The submit() represents whatever handling you want to trigger when all steps are valid and the domain models can be constructed. Notice how I've deferred the actual creation of any real domain model and only maintained "form" or "draft" data structures in the views. Only at the time of submit(), either on button press or as a callback to when the "all valid" event occurs, do these views bubble up data, most likely to make server request. You can construct a higher level Listing model here and make that your request payload. However, it is not the Wizard's job to communicate with the backend. It simply pools all the data together for a proper handler to construct a valid request.

Why? Ideally, the frontend should speak the same domain model that the backend does. At the very least your UX models should match one-to-one to high level aggregates. The idea for the frontend is to interface with a high-level layer of abstraction that the backend is not likely to change, while giving it the freedom to decompose and restructure that data in whatever internal domain it needs to. In practice, the frontend and backend domains get out of sync, so it's better leave a layer for data-munging at the request level so that the UX is internally consistent and coherent.

Beetle answered 17/2, 2021 at 15:43 Comment(2)
Thanks for your time to look into this. Maybe my question was not clear enough. I don't think I've mentioned in my OP but I am using CQRS approach, so my front-end has no problem to query directly to the database and figure out which steps are missing in the draft listing creation. I also have application services as defined use cases so when the user clicks the submit button in the form on each step, the UI (mine is ASP.NET Core MVC app) will call the corresponding use case. Within each use case, the application service then manages the workflow and decides which domain model to use.Chalkboard
My question lays on the domain modeling. I see pros and cons on both sides: model each step as separate aggregate, or just have a big single aggregate called Listing that stores the status and the last step.Chalkboard

© 2022 - 2024 — McMap. All rights reserved.