Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How do I write commands for an entity that's an aggregate root in one context but not in another?

I'm working on a project for a company that finds vendors to perform services for employee relocations. These services are things that movers don't have the expertise to do, like preparing a piano or for transit or constructing crates for valuables.

In this domain, an Order has 1:many Locations.

In the moving industry, orders are frequently in flux right up until the time the vendor is to perform the services requested of him. Therefore, in our model we have some statuses (e.g. Submitted, Cancelled, On Hold) that apply to Orders and Locations.

There's some pretty simple business rules that apply here. Here's a sampling:

  1. When an Order is placed On Hold, all Locations are placed On Hold.
  2. A Location cannot be taken Off Hold if its parent Order is On Hold.

And so on. From these rules, it seems obvious to me that this forms an aggregate root boundary. So therefore I have a MyClient.Statuses.Order aggregate where Statuses is the name of the context/service/whatever you want to call it:

public class Order {
    private Guid _id;
    private OrderStatus _status;

    public void PlaceOnHold() {
        if (_status == OrderStatus.Cancelled)
            // throw exception

        _status = OrderStatus.OnHold;
        Locations.ForEach(loc => loc.PlaceOnHold());
    } 

    public void PlaceLocationOnHold(Guid id) {
        if (_status == OrderStatus.Cancelled)
            // throw exception
        Locations.Single(loc => loc.Id == id).PlaceOnHold();
    }

    // etc...

        private Location[] Locations;
}

internal class Location { 
    public Guid Id;
    public LocationStatus Status;

    public void PlaceOnHold() {
        // It's ok for a cancelled location on a non-cancelled order,
        // but a Location cannot be placed On Hold if it's Cancelled so 
        // just ignore it
        if (Status == LocationStatus.Cancelled)
            return; 

        Status = LocationStatus.OnHold;
    }
}

Both of these objects (Order, Location) are have GUID ids in other contexts (e.g. for CRUD based attributes that don't have state transitions). So now we finally get to my question:

How do I write the command and handler to place a location on hold?

I want to keep this thing DRY and Service-Oriented in order to minimize coupling, but it's really difficult to keep a parent-child relationship between two entities in only one place.

Option 1 - A Single Location ID:

public class PlaceLocationOnHold_V1 {
    public readonly Guid Id;
}

public class PlaceLocationOnHold_V1Handler {
    public void Handle(PlaceLocationOnHold_V1 command) {
        // This is typically a no-no.  Should only fetch by OrderId:
        var aggregate = _repository.GetByLocationId(command.Id);  

        aggregate.PlaceLocationOnHold(command.Id);
        _repository.Save();
    }
}

Option 2 - Order ID and Location ID:

public class PlaceLocationOnHold_V2 {
    public readonly Guid OrderId; // This feels redundant
    public readonly Guid LocationId;
}

public class PlaceLocationOnHold_V2Handler {
    public void Handle(PlaceLocationOnHold_V2 command) {
        var aggregate = _repository.GetById(command.OrderId);
        aggregate.PlaceLocationOnHold(command.LocationId);
        _repository.Save();
    }
}

Option 3 - A single parameter with class that encapsulates "A Location, which belongs to an Order"

public class LocationIdentity {
    public Guid Id;
    public Guid OrderId;
}

public class PlaceLocationOnHold_V3 {
    public readonly LocationIdentity Location;
}

public class PlaceLocationOnHold_V3Handler {
    public void Handle(PlaceLocationOnHold_V3 command) {
        var aggregate = _repository.GetById(command.Location.OrderId);  
        aggregate.PlaceLocationOnHold(command.Location.Id);
        _repository.Save();
    }
}
like image 224
Josh Kodroff Avatar asked Feb 20 '23 14:02

Josh Kodroff


1 Answers

Check out the Vaughn Vernon articles on Effective Aggregate Design. Specifically, Part 2 - there is some good information on modeling aggregates that communicate with each other.

The main point that is missing in your design is that these are both ARs as you have already mentioned - they are globally identifiable. So they should be referencing each other by ID. Order should not hold a child collection of Locations.

So your Order class will have a collection of LocationIds and your Location will have an OrderId.

public class Order
{
    private Guid _id;
    private OrderStatus _status;
    private Guid[] _locationIds;
    //...
}

public class Location
{
    private Guid _id;
    private Guid _orderId;
    //...
}

Once you break that out correctly, Option #1 makes sense. Since Location is an AR unto itself, you can instantiate it and call PlaceOnHold directly on it without having to go through the Order AR.

As for the situation where a change in one AR trickles down into the others (i.e. placing Order on hold also makes all of the Locations go on hold as well), you could use domain events or eventual consistency.

public class Order
{
    //... private instance variables

    public void PlaceOnHold()
    {
        if (_status == OrderStatus.Cancelled)
          // throw exception

        _status == Orderstatus.OnHold;

        DomainEvents.Handle(new OrderPlacedOnHold(_id)); // handle this, look up the related locations and call PlaceOnHold on each of them)
    }
}

And for the situation where you may be trying to remove the hold on a Location, but the Order is on hold making the action illegal, you could instantiate the Order object in the command handler and pass it into the RemoveFromHold method of the Location. Vernon mentions this and reiterates the point that just because you can only ALTER one AR per transaction does not mean you cannot instantiate multiple ARs in the transaction.

public class RemoveHoldFromLocation : IHandler<RemoveHoldFromLocationCommand>
{
    public void Execute(RemoveHoldFromLocationCommand cmd)
    {
        var location = locationRepo.Get(cmd.LocationId);
        var order = orderRepo.Get(location.GetOrderId());

        location.RemoveHold(order.GetStatus());
    }
}

public class Location
{
    //... private instance variables, etc.

    public void RemoveHold(OrderStatus orderStatus)
    {
        if (orderStatus == OrderStatus.OnHold)
            // throw Exception

        _status == LocationStatus.OnHold;
    }
}

This is just pseudocode so forgive typos, etc. Similar code samples are in the Vernon PDF.

like image 191
Dan Avatar answered Apr 08 '23 02:04

Dan