jeudi 29 octobre 2020

DDD - clean architecture vs clear workflow

We are writing in a DDD manner, meaning we do not "follow DDD as a bible" but rather make use of the benefits it offers, and bend it when it forces us to do stuff thats not "common sense" for us - after all one of DDD's main point is that the code stays close to the real life use cases.

And here is one place where the techniquetook us a little off track:

Adding an item to a basket user story: (I'm keeping it short here...)

"when a customer adds an item to the basket, if sale is over limit (1000$), user will get a "Maximum sale is 1000$" popup If this item appears in the basket more times than allowed user will get a "Product exceeded max amount allowed in one order" popup And the item does not exist in the stock user will get a "Product XXX is out of stock" popup If all terms are ok, add the item to the basket"

Application code may look like this:

func basketapp.AddItem(basketId, productId, quantity){

   myBasket := BasketRepo.LoadByID(basketId)      //load the basket
   Product := ProductService.GetByID(productId)   //get the requested product 
   myBasket.AddItem(Product, quantity)            //runs internal domain business logic to add the item
   BasketRepo.Save(basketId, myBasket)            //Persist/add event source
}

Where domain business logic would look like this:

func model.basket.AddItem(product, quantity){
   if basket.total() > maxTotalPerBasket {
       return maxItemsPerBasketExceededError
   }

   if basket.CountItem(product.id) > product.MaxRepeatsPerSale {
       return productCountLimitExceededError
   }
   .....
   item := newBasketItem(product, quantity ....)
    basket.Items.Append(item)
}

The problem starts when I wish to validate the product is in stock after the simple validations To the human (customer, product manager developer) mind, its another validation, so writing this as the user story suggests:

func model.basket.AddItem(product, quantity){
   if basket.total() > maxTotalPerBasket {
       return maxItemsPerBasketExceededError
   }
 
   if basket.CountItem(product.id) > product.MaxRepeatsPerSale {
       return productCountLimitExceededError
   }
        
   if !inventoryService.exists(product.Id,storeId ){
       return productNotInInventoryError
   }
        
   item := newBasketItem(product, quantity ....)

   basket.Items.Append(item)
}

But according the DDD concepts, model does not call another service (even not an injected scheme),

So the extra code would be a part of the app layer, and I would need to split model.AddItem in this manner:

func basketapp.AddItem(basketId, productId, quantity){

   myBasket := BasketRepo.LoadByID(basketId)        
   Product := ProductService.GetByID(productId)     
   //myBasket.AddItem(Product, quantity)            
        
        
   if !myBasket.ValidateItem(Product, quantity){    //Where ValidateItem is the model basket.total()+basket.CountItem code
      return ...
   } else if !StockLevelService.exists(productId, storeId ){
      return ...
   }
   myBasket.AddItem(Product, quantity)  //leaving lines from model.Additem after validation
        
          
   BasketRepo.Save(basketId, myBasket)         
        
}

Its cleaner code wise, but now the user story needs to change (and for that - people writing this user story should understand that architecture wise stockLevel is something done in a different service/domain".

We had some other mutations of this idea, yet we cant find a way that will "play by the technical rules" on the one hand, and "will read as the user story" on the other (and of course need to consider everyone on the team will easily understand where is the right place for everything, so we will not find mixed approaches and need to fix upon code review... as well as quickly know where to go when debugging each part etc)

Ideas are welcome :)

Aucun commentaire:

Enregistrer un commentaire