2008-09-06

How to create fully encapsulated and simple Domain Models

Background

Udi wrote about this topic, but I never really liked the solution. To me it seemed complex and over architected, even the solutions from the commenter's where surprisingly complex (No disrespect for Udi or any of the commenter's intended).

Business Context

The core domain revolves around renting video games. I am working on a new feature to allow customers to trade in old video games. Customers can trade in multiple games at a time so we have a TradeInCart entity that works similar to most shopping carts that everybody is familiar with. However there are several rules that limit the items that can be placed into the TradeInCart. The core rules are:

1. Only 3 games of the same title can be added to the cart.
2. The total number of items in the cart cannot exceed 10.
3. No games can be added to the cart that the customer had previously reported lost with regards to their rental membership.
    a. If an attempt is made to add a previously reported lost game, then we need to log a BadQueueStatusAddAttempt to the persistence store.

A solution

The service layer service looks like this.

   1: using System.Transactions;
   2:  
   3: namespace VideoGameRenting
   4: {
   5:     public class TradeInService
   6:     {
   7:         private readonly Repository _repository;
   8:  
   9:         private GameDTO _gameDTO;
  10:         private TradeInCartDTO _tradeInCartDTO;
  11:         private LineItemDTO[] _lineItemDTOS;
  12:         private GameReportedLostDTO _gameReportedLostDTO;
  13:         private TradeInCart _tradeInCart;
  14:  
  15:         public TradeInService(Repository repository)
  16:         {
  17:             _repository = repository;
  18:         }
  19:  
  20:         //This is an outer boundry of the system.
  21:         //The operation below is a complete operation aganst the system.
  22:         public void AddGameToCart(int gameId, int tradeInCartId)
  23:         {
  24:             using(TransactionScope scope = new TransactionScope(TransactionScopeOption.RequiresNew))
  25:             {
  26:                 LoadAllDataNeeded(gameId, tradeInCartId);
  27:  
  28:                 ApplyBusinessRulesToData();
  29:  
  30:                 SaveDataIfNeeded();
  31:    
  32:                 scope.Complete();
  33:             }
  34:  
  35:             //If it is a GUI application, "redirect" to a method that renders a view with data
  36:         }
  37:  
  38:         private void LoadAllDataNeeded(int gameId, int tradeInCartId)
  39:         {
  40:             _gameDTO = _repository.Find<GameDTO>(gameId);
  41:             _tradeInCartDTO = _repository.Find<TradeInCartDTO>(tradeInCartId);
  42:             _lineItemDTOS = _repository.Query<LineItemDTO>(x => x.TradeInCartId == _tradeInCartDTO.Id);
  43:             _gameReportedLostDTO = _repository.FindBy<GameReportedLostDTO>(
  44:                 x => x.Customer == _tradeInCartDTO.CustomerId && x.GameId == _gameDTO.Id);
  45:         }
  46:  
  47:         private void ApplyBusinessRulesToData()
  48:         {
  49:             _tradeInCart = new TradeInCart(_tradeInCartDTO, _lineItemDTOS);
  50:             _tradeInCart.AttemptToAddGameAndLogAnyAbuse(_gameDTO, _gameReportedLostDTO);
  51:         }
  52:  
  53:         private void SaveDataIfNeeded()
  54:         {
  55:             _repository.Save(_tradeInCartDTO);
  56:             foreach (LineItemDTO line in _tradeInCart.GetItems())
  57:             {
  58:                 _repository.Save(line);
  59:             }
  60:             foreach (AbuseLogEntryDTO abuseLogEntry in _tradeInCart.GetAbuseLogEntries())
  61:             {
  62:                 _repository.Save(abuseLogEntry);
  63:             }
  64:         }
  65:     }
  66: }

Basically I treat data as pure data structures(DTO) and I have objects that operate on the data.

The TradeInCart DDD Entity looks like this, it is the agregate root for TradeInCartDTO, LinetemDTO and AbuseLogEntryDTO.

   1: using System;
   2: using System.Collections.Generic;
   3:  
   4: namespace VideoGameRenting
   5: {
   6:     public class TradeInCart
   7:     {
   8:         private readonly TradeInCartDTO _tradeInCartDTO;
   9:         private readonly List<LineItemDTO> _items;
  10:         private readonly List<AbuseLogEntryDTO> _abuseLogEntries;
  11:  
  12:         public TradeInCart(TradeInCartDTO tradeInCartDTO, IEnumerable<LineItemDTO> items)
  13:         {
  14:             _tradeInCartDTO = tradeInCartDTO;
  15:             _items = new List<LineItemDTO>(items);
  16:             _abuseLogEntries = new List<AbuseLogEntryDTO>();
  17:         }
  18:  
  19:         public IEnumerable<LineItemDTO> GetItems()
  20:         {
  21:             return _items.AsReadOnly();
  22:         }
  23:  
  24:         public IEnumerable<AbuseLogEntryDTO> GetAbuseLogEntries()
  25:         {
  26:             return _abuseLogEntries.AsReadOnly();
  27:         }
  28:  
  29:         public void AttemptToAddGameAndLogAnyAbuse(GameDTO game, GameReportedLostDTO gameReportedLost)
  30:         {
  31:             if (GameHasBeenReportedLost(gameReportedLost))
  32:             {
  33:                 LogAbuse();
  34:                 return;
  35:             }
  36:             if (GameCannotBeAddedToCart(game)) return;
  37:  
  38:             AddGameToNewLineItem(game);
  39:         }
  40:  
  41:         private bool GameHasBeenReportedLost(GameReportedLostDTO gameReportedLost)
  42:         {
  43:             return gameReportedLost != null;
  44:         }
  45:  
  46:         private void LogAbuse()
  47:         {
  48:             AbuseLogEntryDTO abuseLogEntry = new AbuseLogEntryDTO();
  49:             abuseLogEntry.Message = "BadQueueStatusAddAttempt";
  50:             abuseLogEntry.TimeStamp = DateTime.Now;
  51:             _abuseLogEntries.Add(abuseLogEntry);
  52:         }
  53:  
  54:         private bool GameCannotBeAddedToCart(GameDTO game)
  55:         {
  56:             return _items.Count > 10 || CountGamesWithSameTitle(game.Id) > 3;
  57:         }
  58:  
  59:         private void AddGameToNewLineItem(GameDTO game)
  60:         {
  61:             LineItemDTO lineItemDTO = new LineItemDTO();
  62:             lineItemDTO.GameId = game.Id;
  63:             lineItemDTO.TradeInCartId = _tradeInCartDTO.Id;
  64:             _items.Add(lineItemDTO);
  65:         }
  66:  
  67:         private int CountGamesWithSameTitle(int gameId)
  68:         {
  69:             int gameCount = 0;
  70:             foreach (LineItemDTO line in _items)
  71:             {
  72:                 if(line.GameId == gameId)
  73:                 {
  74:                     gameCount++;
  75:                 }
  76:             }
  77:             return gameCount;
  78:         }
  79:  
  80:  
  81:     }
  82: }

The full source code is in my trunk.

Conclusion

I think my solution is simpler and easier to understand. I think it communicates very well.

I am actually in doubt... Do I need a AddToCartAttempt class, with GameDTO and GameReportedLostDTO as fields and the appropriate logic? Then I would not need to pass around parameters in TradeInCart's GameHasBeenReportedLost, GameCannotBeAddedToCart and AddGameToNewLineItem

No comments: