Sessions and Shopping Carts

Managing shopping carts using Sessions is the source of much confusion for beginners to ASP.NET. This article looks at how to do this so that items can be added and removed by customers easily before they commit to their purchase.

For this exercise, I am using ASP.NET Web Forms and the Northwind sample database. I'm going to present the user with a selection of products filtered by category. When the user changes the category, a postback will occur, which means that the products selected so far will be lost unless some kind of state management mechanism is employed. Session is a simple mechanism to use, but needs to be used judiciously to ensure that not too much server memory is used per user. All I'm going to store, therefore, is the ID of the selected products. This two-page sample will feature a ShoppingCart.aspx page, which includes a DropDownList of Categories to filter the porducts by, and a GridView listing the products associated with the selected Category. The user will be able to select products using CheckBoxes in the GridView, and then move to Checkout.aspx, where all their currently selected products will be displayed. At this point, they will also see the total cost of their order, and have the option to remove products from the basket if they so wish. I have used Visual Studio 2010, and the sample project is available for download at the end of the article.

The markup for ShoppingCart.aspx follows. There's nothing too fancy about this. You should notice that the GridView has its DataKeyNames property set to ProductID and that the CheckBoxes have been applied in a TemplateField. There are two buttons: one to add items to the shopping cart, and the other to move to the Checkout.aspx page.


<%@ Page Title="" Language="C#" MasterPageFile="~/Site.Master" AutoEventWireup="true" 
			CodeBehind="ShoppingCart.aspx.cs" Inherits="SessionShoppingCart.ShoppingCart" %>
<asp:Content ID="Content1" ContentPlaceHolderID="HeadContent" runat="server">
</asp:Content>
<asp:Content ID="Content2" ContentPlaceHolderID="MainContent" runat="server">
   <p>
      Select Category:
      <asp:DropDownList ID="Categories" runat="server" DataSourceID="CategoriesData" 
        DataTextField="CategoryName" DataValueField="CategoryID" AutoPostBack="true" />
      <asp:SqlDataSource ID="CategoriesData" runat="server" 
        ConnectionString="<%$ ConnectionStrings:NorthwindConnectionString %>"
        SelectCommand="SELECT [CategoryID], [CategoryName] FROM [Categories]"></asp:SqlDataSource>
    </p>
    <asp:GridView ID="Products" runat="server" AutoGenerateColumns="False" DataKeyNames="ProductID"
      DataSourceID="ProductsData" GridLines="None" EnableViewState="False">
      <Columns>
        <asp:TemplateField HeaderText="Add To Cart">
          <ItemTemplate>
            <asp:CheckBox ID="SelectedProducts" runat="server" />
          </ItemTemplate>
        </asp:TemplateField>
        <asp:BoundField DataField="ProductName" HeaderText="Product" SortExpression="ProductName" />
        <asp:BoundField DataField="CategoryName" HeaderText="Category" SortExpression="CategoryName" />
        <asp:BoundField DataField="UnitPrice" HeaderText="Price" SortExpression="UnitPrice"
          DataFormatString="{0:c}" />
      </Columns>
    </asp:GridView>
    <asp:Button ID="AddToCart" runat="server" Text="Select Products" OnClick="AddToCart_Click" /> 
    &nbsp;&nbsp;&nbsp;&nbsp;
    <asp:Button ID="Checkout" runat="server" Text="Check Out" OnClick="Checkout_Click" />
    <asp:SqlDataSource ID="ProductsData" runat="server" 
      ConnectionString="<%$ ConnectionStrings:NorthwindConnectionString %>"
      SelectCommand="SELECT Products.ProductID, Products.ProductName, Categories.CategoryName, 
        Products.UnitPrice FROM Products INNER JOIN Categories ON Products.CategoryID = Categories.CategoryID 
        WHERE Products.CategoryID = @CategoryID">
      <SelectParameters>
        <asp:ControlParameter ControlID="Categories" Name="CategoryID" PropertyName="SelectedValue"
          DefaultValue="1" />
      </SelectParameters>
    </asp:SqlDataSource>
</asp:Content>


When rendered, the page looks like this:

Both of the buttons have been wired up to Click event handlers in the code-behind. The first, AddToCart_Click, takes care of creating the shopping cart in session, and adding selected items to it:


protected void AddToCart_Click(object sender, EventArgs e)
{
  var selectedProducts = Products.Rows.Cast<GridViewRow>()
    .Where(row => ((CheckBox)row.FindControl("SelectedProducts")).Checked)
    .Select(row => Products.DataKeys[row.RowIndex].Value.ToString()).ToList();
  if (Session["Cart"] == null)
  {
    Session["Cart"] = selectedProducts;
  }
  else
  {
    var cart = (List<string>)Session["Cart"];
    foreach (var product in selectedProducts)
      cart.Add(product);
    Session["Cart"] = cart;
  }
  foreach (GridViewRow row in Products.Rows)
  {
    CheckBox cb = (CheckBox)row.FindControl("SelectedProducts");
    if (cb.Checked)
      cb.Checked = false;
  }
}

The first line is a Linq query (broken over 3 lines for clarity), which examines the GridViewRows collection of the GridView (Products) and obtains the DataKey value for each row that contains a ticked CheckBox. These are converted to a List<string>. If no products have been selected previously, Session["Cart"] will not have been created yet, and will be null. In that case, the List<string> (selectedProducts) will be assigned to the session variable Session["Cart"], which gets created automatically at that point. If products have already been selected and added to the cart (ie it alredy exists), it is retrieved form the session variable, and the new products are added to it, before the amended List<string> is re-assigned to the session variable. Finally, all the selected Checkboxes are cleared to prevent them being checked when the ShoppingCart page reappears.

The second Click event, tied to the Checkout button is much less eventful (excuse the pun):


protected void Checkout_Click(object sender, EventArgs e)
{
  if (Session["Cart"] != null)
    Response.Redirect("Checkout.aspx");
}

If the cart is not null, the user is redirected to the Checkout page.

Before going through the code for the Checkout page, here's how it looks if a few products have been selected:

You can see that the selected items appear as described earlier, with a CheckBox that allows removal of individual items. The total cost also appears. Here's the mark-up for Checkout.aspx:


<%@ Page Title="" Language="C#" MasterPageFile="~/Site.Master" AutoEventWireup="true"
  CodeBehind="Checkout.aspx.cs" Inherits="SessionShoppingCart.Checkout" %>

<asp:Content ID="Content1" ContentPlaceHolderID="HeadContent" runat="server">
</asp:Content>
<asp:Content ID="Content2" ContentPlaceHolderID="MainContent" runat="server">
  <asp:GridView ID="Basket" runat="server" AutoGenerateColumns="False" 
    GridLines="None" EnableViewState="False" ShowFooter="True" 
    DataKeyNames="ProductID" OnRowCreated="Basket_RowCreated">
    <Columns>
      <asp:TemplateField HeaderText="Remove">
        <ItemTemplate>
          <asp:CheckBox ID="RemovedProducts" runat="server" />
        </ItemTemplate>
      </asp:TemplateField>
      <asp:TemplateField HeaderText="Product" SortExpression="ProductName">
        <ItemTemplate>
          <asp:Label ID="ProductName" runat="server" Text='<%# Eval("ProductName") %>' />
        </ItemTemplate>
        <FooterTemplate>
          <strong>
            Total Price:
          </strong>
        </FooterTemplate>
      </asp:TemplateField>
      <asp:TemplateField HeaderText="Price" SortExpression="UnitPrice">
        <ItemTemplate>
          <asp:Label ID="UnitPrice" runat="server" Text='<%# Eval("UnitPrice", "{0:c}") %>' />
        </ItemTemplate>
        <FooterTemplate>
          <strong>
            <asp:Literal ID="TotalPrice" runat="server" />
          </strong>
        </FooterTemplate>
      </asp:TemplateField>
    </Columns>
  </asp:GridView>
  <asp:Button ID="RemoveProduct" runat="server" 
    Text="Remove From Basket" OnClick="RemoveProduct_Click" />
  &nbsp;&nbsp;&nbsp;&nbsp;
  <asp:Button ID="ConfirmPurchase" runat="server" Text="Confirm Purchase" />
  <asp:SqlDataSource ID="BasketData" runat="server" 
    ConnectionString="<%$ ConnectionStrings:NorthwindConnectionString %>">
  </asp:SqlDataSource>
</asp:Content>



All the colums have been converted to TemplateFields in this case. This is so that I can add a footer row to the ProductName column and the UnitPrice column where I display the Total Price of the order. You should also notice that an event hanlder has been created for the OnRowCreated event, which is where the footer data is displayed. We'll see that in a minute. There are again, 2 buttons. One allows the user to remove items, and the second, well, it doesn't do anything in this example. But if you were to implement a full blown cart, it could take the user to the payment page of your site.

I'm going to post the entire code-behind for this form, and explain it afterwards:


public partial class Checkout : Page
{
  Decimal cost;
  protected void Page_Load(object sender, EventArgs e)
  {
    if (Session["Cart"] == null)
      Response.Redirect("ShoppingCart.aspx");
    BindBasket();
  }

  protected void RemoveProduct_Click(object sender, EventArgs e)
  {
    var cart = (List<string>)Session["Cart"];
    var removedProducts = Basket.Rows.Cast<GridViewRow>()
      .Where(row => ((CheckBox)row.FindControl("RemovedProducts")).Checked)
      .Select(row => Basket.DataKeys[row.RowIndex].Value.ToString()).ToList();
    cart.RemoveAll(removedProducts.Contains);
    BindBasket();
  }

  protected void BindBasket()
  {
    var sql = "SELECT ProductID, ProductName, UnitPrice FROM Products WHERE ProductID IN ({0})";
    var values = (List<string>)Session["Cart"];
    if (values.Count > 0)
    {
      var parms = values.Select((s, i) => "@p" + i.ToString()).ToArray();
      var inclause = string.Join(",", parms);
      BasketData.SelectCommand = string.Format(sql, inclause);
      BasketData.SelectParameters.Clear();
      for (var i = 0; i < parms.Length; i++)
      {
        BasketData.SelectParameters.Add(parms[i].Replace("@", ""), values[i]);
      }

      DataView view = (DataView)BasketData.Select(DataSourceSelectArguments.Empty);

      var costQuery = view.Cast<DataRowView>().Select(drv => drv.Row.Field<decimal>("UnitPrice"));
      cost = costQuery.Sum();
      Basket.DataSource = view;
      Basket.DataBind();
    }
  }

  protected void Basket_RowCreated(object sender, GridViewRowEventArgs e)
  {
    if (e.Row.RowType == DataControlRowType.Footer)
    {
      Literal total = (Literal)e.Row.FindControl("TotalPrice");
      total.Text = cost.ToString("c");
    }
  }
}

At the top of the code, a variable, Decimal cost is created. This will be used ot hold the total cost of the selected items. Within the Page_Load method, a check is made to see if Session["Cart"] exists yet. If not, the user is redirected to the ShoppingCart page. Well, they haven't chosen anything yet! Finally, a call to a local method, BindBasket() is made, which may look at first glance to be quite complicated. It's not, actually.

If you look at the SQL which appears in the first line of BindBasket, you will notice that it makes use of an IN clause: WHERE ProductID IN ({0}). If you are unfamiliar with this approach, have a look at my article on parameterised IN clauses, but essentially, an IN clause will accept a comma-separated string of values, and then find all items in the database table that meet any of those values in the WHERE clause. The next several lines of code extract the ProductIDs from the session-based shopping cart, and construct a parameterised SelectCommand for the SqlDataSource and set up a collection of SelectParameters. The article on parameterised IN clauses will help you understand this in depth.

Then I fire the SqlDataSource's SelectCommand and obtain a DataView. You can read more about that in this article on obtaining values from SqlDataSources. Once I have the DataView, I again use LINQ to query it for for the UnitPrice values and total them up using Sum(). As you may have noticed by now, LINQ is not all about querying SQL Server databases. A good introduction to LINQ To DataSet can be found here. The total of the UnitPrices is stored in the variable that was declared at the top of the page. Finally, within the BindBasket() method, the DataView is bound to the GridView.

The Basket_RowCreated event handler identifies the footer, and applies the total cost value to the Literal control within it.

The RemoveProduct_Click event handler is responsible for deleting products that are no longer wanted. Again, it may look more complicated at first glance than it really is. However, it's just another LINQ query to obtain the rows that were checked, and then the these are removed from the List<string> that comes from the cart. Once this has been accomplished, the databinding method is called again so that the user can see the refreshed basket.

The code is available for download, but you probably appreciate that this is not a finished application. Apart from the fact that there is no payment page, there is no way for users to purchase more than one of each item. You may also have a distinct dislike for SqlDataSources, which I used in this example purely for speed and convenience. You are free to modify the code as you see fit, or just try to draw some modicum of inspiration from it.

Shout it

Date Posted: Monday, May 3, 2010 11:36 PM
Last Updated: Tuesday, May 4, 2010 12:34 PM
Posted by: Mikesdotnetting
Total Views to date: 58948

12 Comments

Monday, May 10, 2010 1:44 PM - BigJim STL

Mike
Thanks for the detailed, very informative article. I have a question on this topic, which has prevented our teams from ever using session variables. We have been repeatedly told by our admins that since we run web apps on large server farms, sessions are not available - since the response might come back to a different server, and the use of "sticky sessions" is verboten here (for whatever reason).
If we can't use session variables, what other options do we have - just writing things back to a semi-temporary table in the database? Is there a way around the server farm issue aside from sticky sessions?

Again, many thanks, and great article!

Tuesday, May 11, 2010 7:23 AM - Mike

@BigJim

You have a couple of options if load balancer persistence is out of the picture: one is to simply use cookies, and the other is to use out of process state management (Sql Server, XML etc).

Tuesday, May 11, 2010 4:56 PM - BigJimSTL

Mike

Thanks, we'll look in to that! Again, great article

Tuesday, May 11, 2010 8:27 PM - Richard

The big problem with storing this type of information in session state is expiry. By default, sessions expiry after 20 minutes of inactivity. If the user adds several items to their basket, gets distracted for 20 minutes, and then tries to check out, their cart will be empty. At this point, most users will get frustrated with your site and go somewhere else.

Using cookies to store the entire basket won't work either - there's a tight limit on the amount of data a site can store in a cookie.

Unless you're sure that all of your visitors will be using browsers which support local storage, the only reliable solution is to store the cart in a database and a unique key for the cart in a cookie. Of course, you then need to set up a maintenance job to purge baskets that haven't been updated recently, but that gives you much better control than trying to extend the session timeout.

Tuesday, May 11, 2010 9:27 PM - Mike

@Richard

Thanks for your comments. In regard to session expiry, a lot of sites nowadays use Ajax to ping the server periodically which effectively keeps the session alive until the user closes the browser.

Pretty much all browsers have a 4kb limit on the amount of data within a cookie. However, most of them allow multiple (50+) cookies to be set. You could in theory spread the basket across multiple cookies, but I wouldn't recommend that. However, 4kb is quite a bit if all you are saving is a unique identifier and a quantity for each order line, for instance.

Using sessions isn't the only way to do this. It's one way. The purpose of the article was to show how to do it, not necessarily to recommend it.

Of course, a really simple way to do this, without relying on sessions or cookies or javascript is to write the order details to a hidden field. And no - I'm not talking about ViewState, although the principal is the same.

Sunday, April 1, 2012 7:18 AM - mojtaba

Lots Of thanks From Iran

Monday, April 23, 2012 7:09 PM - Hamid

Thanks - Good article.
I wonder if you can write similar article for webmatrix with Razor code as there is not much out there for carts and checkout codes. I have got all the books on webmatrix (including yours) and have benefited from yours and others advice on the forum but feel that some more coding samples would be welcome for webmatrix. Only Steve Lydford has written about shopping cart but more is needed - I feel.
Thanking you in advance.
Hamid

Wednesday, October 24, 2012 3:56 PM - Yadier

Hi Mike, thanks for another very helpful article. You are talking about using Ajax to keep sessions a life.
Do you have an example of that?

Thanks so much Yadier

Wednesday, October 24, 2012 11:14 PM - Mike

@Yadier,

You can use a timer to make an AJAX request to keep the session alive:
<script>

$(function () {
setInterval(function() {
$.get('KeepSessionAlive.ashx/');
}, 1000000);
});
</script>


The AJAX call points to a Generic Handler called KeepSessionAlive.ashx, which should do nothing in its ProcessRequest method except set the Response.ContentLength to 0.

Friday, September 13, 2013 11:12 AM - dev

nice

Tuesday, October 15, 2013 7:28 PM - Ashish Singh

Thanks

Thursday, July 10, 2014 11:28 PM - Manoj Patil

Thanks. Really Helpful
Add your comment

If you have any comments to make about this article, please use this form to do so. Make sure that your comment relates specifically to the article above. More general comments can be posted through the form on the Contact page.

Please note, all comments are moderated, and some may not be published. The kind of things that will ensure your comment is deleted without ever seeing the light of day are as follows:

  • Not relevant to the article
  • Gratuitous links to your own site or product
  • Anything abusive or libellous
  • Spam
  • Anything in a language I don't understand including gibberish.

I do not pass email addresses on to spammers, so a valid one will assist me in responding to you personally if required.