DDD: Different aggregates of the same domain model per usecase. Is there a way?

167 views Asked by At

I'm pretty new to DDD. Please, correct me if I'm wrong at any point. Thanks.

Long story short - performance optimization (at least)

Consider the following example:

Let's say we have an order domain model (or concept may be). And we have different usecases applicable for that model. Then, instead of creating a single aggregate and use it in each corresponding usecase we may define several usecase-scoped aggregate "views". Each view contains all the business data as well as business rules and logic required by that usecase.

I assume this approach may be allowed, because we still:

  • Has bounded context;
  • Use ubiquitous language;
  • Stay transactional consistent;
  • Don't violate invariant ;

We just take our business logic grouping (unit of work) to the next level.

Here's a repository working with different aggregates "views". See comments.

type Repository interface {
    /*
        Store - stores new order into repository.

        (aggregate) OrderNew here - is an order representation having minimum dataset for existence. No more, no less.
    */
    Store(ctx context.Context, order OrderNew) error

    /*
        GetState - returns orders' state distinguished by its `uid`.

        (value object) OrderState here - is an orders' state. Based on its value a use case makes decision about what to
        do next.

        Example for "UseCaseOrderConfirmation":
            - The usecase retrieves OrderState;
            - If state is "declined" (let's say because of a timeout) - action forbidden, return error;
            - If state is "awaiting_confirmation" - proceed execution;
    */
    GetState(ctx context.Context, uid string) (OrderState, error)

    /*
        SyncUpdateOnConfirmation - begins transaction under the hood and passes `order` to the given callback `update`
        function. Callback returns either updated OrderOnConfirmation and ok, or an empty struct and false if current
        transaction has to be rolled back.

        (aggregate) OrderOnConfirmation here - is another "view" of the same domain model, that is different aggregate.
        It contains all the required data needed to perform confirmation usecase only. No more, no less.
    */
    SyncUpdateOnConfirmation(
        ctx context.Context, uid string, update func(order OrderOnConfirmation) (OrderOnConfirmation, bool),
    ) error
}

And here is example usecases:

type UseCaseOrderCreation struct {
    repository orders.Repository
}

func (c UseCaseOrderCreation) Create(ctx context.Context, data orders.OrderNewData) error {
    // ... (omitted) order duplication check

    order, err := orders.New(data)
    // ... (omitted) error handling

    err = c.repository.Store(ctx, order)
    // ... (omitted) error handling and possible following application logic
}

type UseCaseOrderConfirmation struct {
    repository orders.Repository
}

func (c UseCaseOrderConfirmation) Confirm(ctx context.Context, uid string) error {
    state, err := c.repository.GetState(ctx, uid)
    // ... (omitted) error handling

    if !state.AwaitingConfirmation() {
        return ErrOrderInvalidState
    }

    update := func(order orders.OrderOnConfirmation) (orders.OrderOnConfirmation, bool) {
        if !order.State().AwaitingConfirmation() {
            return orders.OrderOnConfirmation{}, false
        }

        // ... (omitted) domain or application services usage

        return order.Confirm( /* ... data */ ), true
    }

    err = c.repository.SyncUpdateOnConfirmation(ctx, uid, update)
    // ... (omitted) error handling and other stuff
}

I think this approach may work. Since we're not violating any of the aggregate goal. Anyway, if we don't need order description while confirming it, why should we get it from repository - it makes no sense in the context of confirmation.

Or may be here's another, a better approach?

By the way, currently I use Golang. Lazy loading seems to be not an option. Because it results into two approaches:

  • Aggregates initialized partially (then fill missing data with repository) - seems to be no good. We have to be aware of missing data;
  • Repository logic leaking to the aggregate. Even if repository is an interface, it also seems to be no good;

I've tried to google this question, find in the corresponding books and articles for several days. But, I feel that is may be not the DDD's option. May be here's another design pattern serving this purpose. Or, may be I got something wrong with it.

2

There are 2 answers

6
ArwynFr On

Having different representations of the same concepts is legitimate, it is called a polysemic domain model, but it is done per bounded context rather than per use case.

Let's say you have a shipment context separate from the order context. You probably don't need all the details of the order when you want to change the shipment status, although you may need the order status to prevent from sending the shipment as long as the order is not confirmed.

Having different representations per use case means you may have inconsistent business rules applying on the same context. The purpose of the domain layer is to ensure business invariants, and these should not vary depending on whether you are creating or confirming an order.

For instance (in C#, sorry I don't speak go):

public enum OrderStatus { Pending, Declined, Confirmed };

public class Order
{
  public OrderStatus Status { get; init; }
  public string Value { get; init; }

  public void Decline() => SetStatus(OrderStatus.Declined);
  public void Confirm() => SetStatus(OrderStatus.Confirmed);

  private void SetStatus(OrderStatus value)
  {
    // Business rule is valid for all use cases of the context
    if (Status != OrderStatus.Pending)
    {
      throw new InvalidOperationException();
    }
    Status = value;
  }
}
public class CreateOrderUseCase
{
  private readonly OrderRepository repository;

  public void Execute(CreateOrderDto payload)
  {
    var order = new Order { Status = OrderStatus.Pending, Value = payload.data };
    repository.Insert(order);
  }
}
public class ConfirmOrderUseCase
{
  private readonly OrderRepository repository;

  public void Execute(CreateOrderDto payload)
  {
    var order = repository.Find(payload.id);
    order.Confirm();
    repository.Update(order);
  }
}
0
R.Abbasi On

I think you should look into this article to understand the trade-off between performance, domain completeness, and domain purity described by Vladimir Khorikov. For performance issues, in addition to the options you mentioned, sometimes you can take out the entity from its aggregate (taking out the order detail entity from the order aggregate in your case) and put it in a separate aggregate. But it makes your domain model more complex to handle the consistency of the transactions between Order aggregate and Order Detail aggregate.