Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Is running a query from a command a violation of Command-Query Separation?

Given a real-world anonymous shopping cart, the "AddToCart" workflow must do the following steps:

  1. Lookup the current product from the database. Get the price from the product or use a service to calculate the price on user selections and other product properties. (query)
  2. Lookup the current shopping cart from the database. (query)
  3. If the current shopping cart doesn't exist in the database, create a new shopping cart entity (in memory).
  4. Add the new item (product) to the shopping cart entity (in memory) along with its price.
  5. Run any discount calculations on the entire shopping cart. (depends on query)
  6. Run any sales tax calculations on the shopping cart. (depends on query)
  7. Run any shipping calculations on the shopping cart. (depends on query)
  8. If this is a new shopping cart, add the entity to the database, otherwise update the shopping cart in the database. (command)

So, although "AddToCart" sounds like it should be a command (since it updates the system state), in practice it depends on many queries.

My Question

What is the generally accepted way to handle workflows like this?

  1. Make an AddToCartCommandHandler that depends on other services that may run queries.
  2. Make a facade CartService that orchestrates the workflow that runs the queries first followed by the commands.
  3. Make the controller action method first run the queries, then run any commands. Seems like some of the query steps could be missed if this needs to be reused.
  4. Other?

Is the reason I can't find an answer about this because it "depends on the design" and this is one of the exceptions where not to apply it?

If the commands and queries are separated, would I pass my real entity framework entity class to the command that adds/updates the cart (so EF can work out whether it is attached or not)? It seems like a DTO won't do in this case.

NOTE: I am implicitly assuming that systems that implement CQS do so with the aim that eventually they could become a full-on CQRS system. If so, this workflow apparently would not be able to make the transition - hence my question.

Background

I am taking my first stab at CQS.

It is clear from the documentation I have read about this pattern that a query must not change the system state.

However, it is unclear whether it is considered okay to run a query from within a command (I can't seem to find any info anywhere).

There are several real-world cases I can think of where this needs to happen. But, given the lack of real-world examples of this pattern online I am uncertain how to proceed. There is lots of theory online, but the only code I can find is here and here.

like image 867
NightOwl888 Avatar asked Apr 12 '16 16:04

NightOwl888


People also ask

Why Command query separation?

Command-query separation (CQS) is a principle of imperative computer programming. It was devised by Bertrand Meyer as part of his pioneering work on the Eiffel programming language. It states that every method should either be a command that performs an action, or a query that returns data to the caller, but not both.

What is complicated by command query responsibility segregation?

Command query responsibility segregation (CQRS) is a programming design pattern that treats retrieving data and changing data differently. CQRS uses command handlers to simplify the query process and hide complex, multi-system changes.

What is the difference between command and query?

Commands and Queries There's a basic difference between them - commands modify the data, and queries retrieve the data. They are put together in the context of the Responsibility Segregation because you should avoid mixing the two.

What is the difference between CQS and CQRS?

CQRS takes the defining principle of CQS and extends it to specific objects within a system, one retrieving data and one modifying data. CQRS is the broader architectural pattern, and CQS is the general principle of behaviour.


1 Answers

The answer to this problem came in the form of a comment by qujck.

The solution is to break the application into different query types and command types. The exact purpose of each type remain a mystery (since the blog post doesn't go into the reasons why he made this distinction), but it does make it clear how top-level and mid-level commands can depend on database queries.

Command Types

  1. Command (top-level)
  2. Command Strategy (mid-level)
  3. Data Command (direct data access)

Query Types

  1. Query (top-level)
  2. Query Strategy (mid-level)
  3. Data Query (direct data access)

Command-Query Implementation

// Commands

public interface ICommand
{
}

public interface IDataCommand
{
}

/// <summary>
/// A holistic abstraction, an abstraction that acts as the whole of each transaction 
/// </summary>
/// <typeparam name="TCommand"></typeparam>
public interface ICommandHandler<TCommand>
{
    void Handle(TCommand command);
}

public interface ICommandStrategyHandler<TCommand> where TCommand : ICommand
{
    void Handle(TCommand command);
}

/// <summary>
/// Direct database update
/// </summary>
/// <typeparam name="TCommand"></typeparam>
public interface IDataCommandHandler<TCommand> where TCommand : IDataCommand
{
    void Handle(TCommand command);
}



// Queries

public interface IQuery<TResult>
{
}

public interface IDataQuery<TResult>
{
}

/// <summary>
/// A holistic abstraction, an abstraction that acts as the whole of each transaction 
/// </summary>
/// <typeparam name="TQuery"></typeparam>
/// <typeparam name="TResult"></typeparam>
public interface IQueryHandler<TQuery, TResult> where TQuery : IQuery<TResult>
{
    TResult Handle(TQuery query);
}

public interface IQueryStrategyHandler<TQuery, TResult> where TQuery : IQuery<TResult>
{
    TResult Handle(TQuery query);
}

/// <summary>
/// Direct database query
/// </summary>
/// <typeparam name="TQuery"></typeparam>
/// <typeparam name="TResult"></typeparam>
public interface IDataQueryHandler<TQuery, TResult> where TQuery : IDataQuery<TResult>
{
    TResult Handle(TQuery query);
}


/// <summary>
/// Generic processor that can run any query
/// </summary>
public interface IQueryProcessor
{
    TResult Execute<TResult>(IQuery<TResult> query);

    // NOTE: Stephen recommends against using Async. He may be right that it is not
    // worth the aggrevation of bugs that may be introduced.
    //Task<TResult> Execute<TResult>(IQuery<TResult> query);

    TResult Execute<TResult>(IDataQuery<TResult> query);
}

AddToCart Dependency Graph

Using the above implementation, the structure of the AddToCart workflow dependency graph looks like this.

  • AddToCartCommandHandler : ICommandHandler<AddToCartCommand>
    • GetShoppingCartDetailsQueryHandler : IQueryHandler<GetShoppingCartDetailsQuery, ShoppingCartDetails>
      • GetShoppingCartQueryStrategyHandler : IQueryStrategyHandler<GetShoppingCartQueryStrategy, ShoppingCartDetails>
        • GetShoppingCartDataQueryHandler : IDataQueryHandler<GetShoppingCartDataQuery, ShoppingCartDetails>
          • ApplicationDbContext
        • CreateShoppingCartDataCommandHandler : IDataCommandHandler<CreateShoppingCartDataCommand>
          • ApplicationDbContext
    • UpdateShoppingCartDataCommandHandler : IDataCommandHandler<UpdateShoppingCartDataCommand>
    • SetItemPriceCommandStrategyHandler : ICommandStrategyHandler<SetItemPriceCommandStrategy>
      • GetProductDetailsDataQueryHandler : IDataQueryHandler<GetProductDetailsDataQuery, ProductDetails>
        • ApplicationDbContext
    • SetTotalsCommandStrategyHandler : ICommandStrategyHandler<SetTotalsCommandStrategy>
      • SetDiscountsCommandStrategyHandler : ICommandStrategyHandler<SetDiscountsCommandStrategy>
        • ?
      • SetSalesTaxCommandStrategyHandler : ICommandStrategyHandler<SetSalesTaxCommandStrategy>

Implementation

DTOs

public class ShoppingCartDetails : IOrder
{
    private IEnumerable<IOrderItem> items = new List<ShoppingCartItem>();

    public Guid Id { get; set; }
    public decimal SubtotalDiscounts { get; set; }
    public string ShippingPostalCode { get; set; }
    public decimal Shipping { get; set; }
    public decimal ShippingDiscounts { get; set; }
    public decimal SalesTax { get; set; }
    public decimal SalesTaxDiscounts { get; set; }

    // Declared twice - once for the IOrder interface
    // and once so we can get the realized concrete type.
    // See: https://stackoverflow.com/questions/15490633/why-cant-i-use-a-compatible-concrete-type-when-implementing-an-interface
    public IEnumerable<ShoppingCartItem> Items
    {
        get { return this.items as IEnumerable<ShoppingCartItem>; }
        set { this.items = value; }
    }
    IEnumerable<IOrderItem> IOrder.Items
    {
        get { return this.items; }
        set { this.items = value; }
    }

    //public IEnumerable<ShoppingCartNotification> Notifications { get; set; }
    //public IEnumerable<ShoppingCartCoupon> Coupons { get; set; } // TODO: Add this to IOrder
}

public class ShoppingCartItem : IOrderItem
{
    public ShoppingCartItem()
    {
        this.Id = Guid.NewGuid();
        this.Selections = new Dictionary<string, object>();
    }

    public Guid Id { get; set; }
    public Guid ShoppingCartId { get; set; }
    public Guid ProductId { get; set; }
    public int Quantity { get; set; }
    public decimal Price { get; set; }
    public decimal PriceDiscount { get; set; }
    public IDictionary<string, object> Selections { get; set; }
}

public class ProductDetails 
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
    public decimal Discount { get; set; }
}

Calculating Order Totals

Rather than relying on a string of services to do simple (and required) arithmetic, I opted to put this behavior into extension methods so it is done on the fly against the actual data. Since this logic will need to be shared between the shopping cart, order, and quote, the calculation is done against IOrder and IOrderItem rather than concrete model types.

// Contract to share simple cacluation and other business logic between shopping cart, order, and quote
public interface IOrder
{
    decimal SubtotalDiscounts { get; set; }
    decimal Shipping { get; set; }
    decimal ShippingDiscounts { get; set; }
    decimal SalesTax { get; set; }
    decimal SalesTaxDiscounts { get; set; }
    IEnumerable<IOrderItem> Items { get; set; }
}

public interface IOrderItem
{
    Guid ProductId { get; set; }
    int Quantity { get; set; }
    decimal Price { get; set; }
    decimal PriceDiscount { get; set; }
    IDictionary<string, object> Selections { get; set; }
}

public static class OrderExtensions
{
    public static decimal GetSubtotal(this IOrder order)
    {
        return order.Items.Sum(x => x.GetTotal());
    }

    public static decimal GetSubtotalBeforeDiscounts(this IOrder order)
    {
        return order.Items.Sum(x => x.GetTotalBeforeDiscounts());
    }

    public static decimal GetTotal(this IOrder order)
    {
        var subtotal = (order.GetSubtotal() - order.SubtotalDiscounts);
        var shipping = (order.Shipping - order.ShippingDiscounts);
        var salesTax = (order.SalesTax - order.SalesTaxDiscounts);
        return (subtotal + shipping + salesTax);
    }
}

public static class OrderItemExtensions
{
    public static decimal GetTotalBeforeDiscounts(this IOrderItem item)
    {
        return (item.Price * item.Quantity);
    }

    public static decimal GetTotal(this IOrderItem item)
    {
        return (GetTotalBeforeDiscounts(item) - item.PriceDiscount);
    }

    public static decimal GetDiscountedUnitPrice(this IOrderItem item)
    {
        return (item.Quantity > 0) ? (GetTotal(item) / item.Quantity) : 0;
    }
}

ShoppingCartController

For brevity, we only show the AddToCart action, but this is where other actions against the shopping cart (i.e. remove from cart) would go as well.

public class ShoppingCartController : Controller
{
    private readonly IQueryProcessor queryProcessor;
    private readonly IAnonymousIdAccessor anonymousIdAccessor;
    private readonly ICommandHandler<AddToCartCommand> addToCartHandler;

    public ShoppingCartController(
        IQueryProcessor queryProcessor, 
        IAnonymousIdAccessor anonymousIdAccessor, 
        ICommandHandler<AddToCartCommand> addToCartHandler)
    {
        if (queryProcessor == null)
            throw new ArgumentNullException("queryProcessor");
        if (anonymousIdAccessor == null)
            throw new ArgumentNullException("anonymousIdAccessor");
        if (addToCartHandler == null)
            throw new ArgumentNullException("addToCartHandler");

        this.queryProcessor = queryProcessor;
        this.anonymousIdAccessor = anonymousIdAccessor;
        this.addToCartHandler = addToCartHandler;
    }

    public ActionResult Index()
    {
        var command = new GetShoppingCartDetailsQuery
        {
            ShoppingCartId = this.anonymousIdAccessor.AnonymousID
        };

        ShoppingCartDetails cart = this.queryProcessor.Execute(command);

        return View(cart);
    }

    public ActionResult AddToCart(ItemViewModel model)
    {
        var command = new AddToCartCommand
        {
            ProductId = model.Id,
            Quantity = model.Qty,
            Selections = model.Selections,
            ShoppingCartId = this.anonymousIdAccessor.AnonymousID
        };

        this.addToCartHandler.Handle(command);

        // If we execute server side, it should go to the cart page
        return RedirectToAction("Index");
    }
}

AddToCartCommandHandler

Here is where the main part of the workflow is executed. This command will be called directly from the AddToCart controller action.

public class AddToCartCommandHandler : ICommandHandler<AddToCartCommand>
{
    private readonly IQueryStrategyHandler<GetShoppingCartQueryStrategy, ShoppingCartDetails> getShoppingCartQuery;
    private readonly IDataCommandHandler<UpdateShoppingCartDataCommand> updateShoppingCartCommand;
    private readonly ICommandStrategyHandler<SetItemPriceCommandStrategy> setItemPriceCommand;
    private readonly ICommandStrategyHandler<SetTotalsCommandStrategy> setTotalsCommand;

    public AddToCartCommandHandler(
        IQueryStrategyHandler<GetShoppingCartQueryStrategy, ShoppingCartDetails> getShoppingCartCommand,
        IDataCommandHandler<UpdateShoppingCartDataCommand> updateShoppingCartCommand,
        ICommandStrategyHandler<SetItemPriceCommandStrategy> setItemPriceCommand,
        ICommandStrategyHandler<SetTotalsCommandStrategy> setTotalsCommand
        )
    {
        if (getShoppingCartCommand == null)
            throw new ArgumentNullException("getShoppingCartCommand");
        if (setItemPriceCommand == null)
            throw new ArgumentNullException("setItemPriceCommand");
        if (updateShoppingCartCommand == null)
            throw new ArgumentNullException("updateShoppingCartCommand");
        if (setTotalsCommand == null)
            throw new ArgumentNullException("setTotalsCommand");

        this.getShoppingCartQuery = getShoppingCartCommand;
        this.updateShoppingCartCommand = updateShoppingCartCommand;
        this.setItemPriceCommand = setItemPriceCommand;
        this.setTotalsCommand = setTotalsCommand;
    }

    public void Handle(AddToCartCommand command)
    {
        // Get the shopping cart (aggregate root) from the database
        var shoppingCart = getShoppingCartQuery.Handle(new GetShoppingCartQueryStrategy { ShoppingCartId = command.ShoppingCartId });

        // Create a new shopping cart item
        var item = new Contract.DTOs.ShoppingCartItem
        {
            ShoppingCartId = command.ShoppingCartId,
            ProductId = command.ProductId,
            Quantity = command.Quantity,

            // Dictionary representing the option selections the user made on the UI
            Selections = command.Selections
        };

        // Set the item's price (calculated/retrieved from database query)
        setItemPriceCommand.Handle(new SetItemPriceCommandStrategy { ShoppingCartItem = item });

        // Add the item to the cart
        var items = new List<Contract.DTOs.ShoppingCartItem>(shoppingCart.Items);
        items.Add(item);
        shoppingCart.Items = items;

        // Set the shopping cart totals (sales tax, discounts)
        setTotalsCommand.Handle(new SetTotalsCommandStrategy { ShoppingCart = shoppingCart });

        // Update the shopping cart details in the database
        updateShoppingCartCommand.Handle(new UpdateShoppingCartDataCommand { ShoppingCart = shoppingCart });
    }
}

GetShoppingCartQueryStrategyHandler

public class GetShoppingCartQueryStrategyHandler : IQueryStrategyHandler<GetShoppingCartQueryStrategy, ShoppingCartDetails>
{
    private readonly IDataQueryHandler<GetShoppingCartDataQuery, ShoppingCartDetails> getShoppingCartDataQuery;
    private readonly IDataCommandHandler<CreateShoppingCartDataCommand> createShoppingCartDataCommand;

    public GetShoppingCartQueryStrategyHandler(
        IDataQueryHandler<GetShoppingCartDataQuery, ShoppingCartDetails> getShoppingCartDataQuery,
        IDataCommandHandler<CreateShoppingCartDataCommand> createShoppingCartDataCommand)
    {
        if (getShoppingCartDataQuery == null)
            throw new ArgumentNullException("getShoppingCartDataQuery");
        if (createShoppingCartDataCommand == null)
            throw new ArgumentNullException("createShoppingCartDataCommand");

        this.getShoppingCartDataQuery = getShoppingCartDataQuery;
        this.createShoppingCartDataCommand = createShoppingCartDataCommand;
    }

    public ShoppingCartDetails Handle(GetShoppingCartQueryStrategy query)
    {
        var result = this.getShoppingCartDataQuery.Handle(new GetShoppingCartDataQuery { ShoppingCartId = query.ShoppingCartId });

        // If there is no shopping cart, create one.
        if (result == null)
        {
            this.createShoppingCartDataCommand.Handle(new CreateShoppingCartDataCommand { ShoppingCartId = query.ShoppingCartId });

            result = new ShoppingCartDetails
            {
                Id = query.ShoppingCartId
            };
        }

        return result;
    }
}

GetShoppingCartDataQueryHandler

/// <summary>
/// Data handler to get the shopping cart data (if it exists)
/// </summary>
public class GetShoppingCartDataQueryHandler : IDataQueryHandler<GetShoppingCartDataQuery, ShoppingCartDetails>
{
    private readonly IAppContext context;

    public GetShoppingCartDataQueryHandler(IAppContext context)
    {
        if (context == null)
            throw new ArgumentNullException("context");
        this.context = context;
    }

    public ShoppingCartDetails Handle(GetShoppingCartDataQuery query)
    {
        return (from shoppingCart in context.ShoppingCarts
                where shoppingCart.Id == query.ShoppingCartId
                select new ShoppingCartDetails
                {
                    Id = shoppingCart.Id,
                    SubtotalDiscounts = shoppingCart.SubtotalDiscounts,
                    ShippingPostalCode = shoppingCart.ShippingPostalCode,
                    Shipping = shoppingCart.Shipping,
                    ShippingDiscounts = shoppingCart.ShippingDiscounts,
                    SalesTax = shoppingCart.SalesTax,
                    SalesTaxDiscounts = shoppingCart.SalesTaxDiscounts,

                    Items = shoppingCart.Items.Select(i =>
                        new Contract.DTOs.ShoppingCartItem
                        {
                            Id = i.Id,
                            ShoppingCartId = i.ShoppingCartId,
                            ProductId = i.ProductId,
                            Quantity = i.Quantity,
                            Price = i.Price,
                            PriceDiscount = i.PriceDiscount
                            // TODO: Selections...
                        })
                }).FirstOrDefault();
    }
}

CreateShoppingCartDataCommandHandler

public class CreateShoppingCartDataCommandHandler : IDataCommandHandler<CreateShoppingCartDataCommand>
{
    private readonly IAppContext context;

    public CreateShoppingCartDataCommandHandler(IAppContext context)
    {
        if (context == null)
            throw new ArgumentNullException("context");
        this.context = context;
    }

    public void Handle(CreateShoppingCartDataCommand command)
    {
        var cart = new ShoppingCart
        {
            Id = command.ShoppingCartId
        };

        this.context.ShoppingCarts.Add(cart);
        this.context.SaveChanges();
    }
}

UpdateShoppingCartDataCommandHandler

This updates the shopping cart with all of the changes that the business layer applied.

For the time being, this "command" does a query so it can reconcile the differences between the database and in memory copy. However, it is obviously a violation of the CQS pattern. I plan to make a follow-up question to determine what the best course of action is for change tracking since change tracking and CQS appear to be intimately linked.

public class UpdateShoppingCartDataCommandHandler : IDataCommandHandler<UpdateShoppingCartDataCommand>
{
    private readonly IAppContext context;

    public UpdateShoppingCartDataCommandHandler(IAppContext context)
    {
        if (context == null)
            throw new ArgumentNullException("context");
        this.context = context;
    }

    public void Handle(UpdateShoppingCartDataCommand command)
    {
        var cart = context.ShoppingCarts
            .Include(x => x.Items)
            .Single(x => x.Id == command.ShoppingCart.Id);


        cart.Id = command.ShoppingCart.Id;
        cart.SubtotalDiscounts = command.ShoppingCart.SubtotalDiscounts;
        cart.ShippingPostalCode = command.ShoppingCart.ShippingPostalCode;
        cart.Shipping = command.ShoppingCart.Shipping;
        cart.ShippingDiscounts = command.ShoppingCart.ShippingDiscounts;
        cart.SalesTax = command.ShoppingCart.SalesTax;
        cart.SalesTaxDiscounts = command.ShoppingCart.SalesTaxDiscounts;

        ReconcileShoppingCartItems(cart.Items, command.ShoppingCart.Items, command.ShoppingCart.Id);

        // Update the cart with new data

        context.SaveChanges();
    }

    private void ReconcileShoppingCartItems(ICollection<ShoppingCartItem> items, IEnumerable<Contract.DTOs.ShoppingCartItem> itemDtos, Guid shoppingCartId)
    {
        // remove deleted items
        var items2 = new List<ShoppingCartItem>(items);
        foreach (var item in items2)
        {
            if (!itemDtos.Any(x => x.Id == item.Id))
            {
                context.Entry(item).State = EntityState.Deleted;
            }
        }


        // Add/update items
        foreach (var dto in itemDtos)
        {
            var item = items.FirstOrDefault(x => x.Id == dto.Id);
            if (item == null)
            {
                items.Add(new ShoppingCartItem
                {
                    Id = Guid.NewGuid(),
                    ShoppingCartId = shoppingCartId,
                    ProductId = dto.ProductId,
                    Quantity = dto.Quantity,
                    Price = dto.Price,
                    PriceDiscount = dto.PriceDiscount
                });
            }
            else
            {
                item.ProductId = dto.ProductId;
                item.Quantity = dto.Quantity;
                item.Price = dto.Price;
                item.PriceDiscount = dto.PriceDiscount;
            }
        }
    }
}

SetItemPriceCommandStrategyHandler

public class SetItemPriceCommandStrategyHandler : ICommandStrategyHandler<SetItemPriceCommandStrategy>
{
    private readonly IDataQueryHandler<GetProductDetailsDataQuery, ProductDetails> getProductDetailsQuery;

    public SetItemPriceCommandStrategyHandler(
        IDataQueryHandler<GetProductDetailsDataQuery, ProductDetails> getProductDetailsQuery)
    {
        if (getProductDetailsQuery == null)
            throw new ArgumentNullException("getProductDetailsQuery");

        this.getProductDetailsQuery = getProductDetailsQuery;
    }

    public void Handle(SetItemPriceCommandStrategy command)
    {
        var shoppingCartItem = command.ShoppingCartItem;

        var product = getProductDetailsQuery.Handle(new GetProductDetailsDataQuery { ProductId = shoppingCartItem.ProductId });

        // TODO: For products with custom calculations, need to use selections on shopping cart item
        // as well as custom formula and pricing points from product to calculate the item price.

        shoppingCartItem.Price = product.Price;
    }
}

GetProductDetailsDataQueryHandler

public class GetProductDetailsDataQueryHandler : IDataQueryHandler<GetProductDetailsDataQuery, ProductDetails>
{
    private readonly IAppContext context;

    public GetProductDetailsDataQueryHandler(IAppContext context)
    {
        if (context == null)
            throw new ArgumentNullException("context");
        this.context = context;
    }

    public ProductDetails Handle(GetProductDetailsDataQuery query)
    {
        return (from product in context.Products
                where product.Id == query.ProductId
                select new ProductDetails
                {
                    Id = product.Id,
                    Name = product.Name,
                    Price = product.Price
                }).FirstOrDefault(); 
    }
}

SetTotalsCommandStrategyHandler

public class SetTotalsCommandStrategyHandler : ICommandStrategyHandler<SetTotalsCommandStrategy>
{
    private readonly ICommandStrategyHandler<SetDiscountsCommandStrategy> setDiscountsCommand;
    private readonly ICommandStrategyHandler<SetSalesTaxCommandStrategy> setSalesTaxCommand;

    public SetTotalsCommandStrategyHandler(
        ICommandStrategyHandler<SetDiscountsCommandStrategy> setDiscountsCommand,
        ICommandStrategyHandler<SetSalesTaxCommandStrategy> setSalesTaxCommand
        )
    {
        if (setDiscountsCommand == null)
            throw new ArgumentNullException("setDiscountsCommand");
        if (setSalesTaxCommand == null)
            throw new ArgumentNullException("setSalesTaxCommand");

        this.setDiscountsCommand = setDiscountsCommand;
        this.setSalesTaxCommand = setSalesTaxCommand;
    }

    public void Handle(SetTotalsCommandStrategy command)
    {
        var shoppingCart = command.ShoppingCart;

        // Important: Discounts must be calculated before sales tax to ensure the discount is applied
        // to the subtotal before tax is calculated.
        setDiscountsCommand.Handle(new SetDiscountsCommandStrategy { ShoppingCart = shoppingCart });
        setSalesTaxCommand.Handle(new SetSalesTaxCommandStrategy { ShoppingCart = shoppingCart });
    }
}

SetDiscountsCommandStrategyHandler

public class SetDiscountsCommandStrategyHandler : ICommandStrategyHandler<SetDiscountsCommandStrategy>
{
    public void Handle(SetDiscountsCommandStrategy command)
    {
        var shoppingCart = command.ShoppingCart;

        // TODO: Set discounts according to business rules

        foreach (var item in shoppingCart.Items)
        {
            item.PriceDiscount = 0;
        }

        shoppingCart.SubtotalDiscounts = 0;
        shoppingCart.SalesTaxDiscounts = 0;
        shoppingCart.ShippingDiscounts = 0;
    }
}

SetSalesTaxCommandStrategyHandler

public class SetSalesTaxCommandStrategyHandler : ICommandStrategyHandler<SetSalesTaxCommandStrategy>
{
    public void Handle(SetSalesTaxCommandStrategy command)
    {
        var shoppingCart = command.ShoppingCart;
        var postalCode = command.ShoppingCart.ShippingPostalCode;

        bool isInCalifornia = !string.IsNullOrEmpty(postalCode) ?
            // Matches 90000 to 96200
            Regex.IsMatch(postalCode, @"^9(?:[0-5]\d{3}|6[0-1]\d{2}|6200)(?:-?(?:\d{4}))?$") :
            false;

        if (isInCalifornia)
        {
            var subtotal = shoppingCart.GetSubtotal();

            // Rule for California - charge a flat 7.75% if the zip code is in California
            var salesTax = subtotal * 0.0775M;

            shoppingCart.SalesTax = salesTax;
        }
    }
}

Do note that there is no shipping calculation in this workflow. This is primarily because the shipping calculation may depend on external APIs and it may take some time to return. Therefore, I am planning to make the AddToCart workflow a step that runs instantaneously when an item is added and make a CalculateShippingAndTax workflow that happens after the fact that updates the UI again after the totals have been retrieved from their (possibly external) sources, which might take time.

Does this solve the problem? Yes, it does fix the real-world problems I was having when commands need to depend on queries.

However, it feels like this really only separates queries from commands conceptually. Physically, they still depend on one another unless you only look at the IDataCommand and IDataQuery abstractions that only depend on ApplicationDbContext. I am not sure if this is the intent of qujck or not. I am also uncertain if this solves the bigger issue of the design being transferable to CQRS or not, but since it is not something I am planning for I am not that concerned about it.

like image 200
NightOwl888 Avatar answered Nov 13 '22 03:11

NightOwl888