Saturday 2 June 2012

Complete Generic MVVM in Silverlight


Introduction 
Almost everyone doing development in Silverlight is talking about MVVM. This is the part 1 on the subject. I decided to split the articles into different parts as this tends to be a confusing subject for the beginners in the subject. What is good about this article is that I will cover the basics of MVVM and give you a full explanation on how you can retrieve, Edit, add, Update and delete data in MVVM and this is the only article I have seen so far that can do that. Another great thing about this article is that we are not going to use any Toolkit to enforce the rules of MVVM, So this is a Generic MVVM Article, you don’t need to install anything or download anything to follow this article.

Objective


The objective of this article of this first part of the series is to give you a brief description of MVVM.

 

What is MVVM

 MVVM is nothing else but a pattern. A pattern is meant to solve a problem. One pattern cannot solve the entire problems that you have across different applications. Before you design an application you must decide on a pattern to use. There are other Patterns that work well for certain Technologies.
Every Pattern has its own advantages and disadvantages. MVVM is good for XAML based applications like Silverlight and WPF. One of the main advantages that I saw in MVVM is the code reusability and Data binding.

Why Choose MVVM

       Separation of concerns
       Developers and designers can coexist in harmony
       Makes unit testing easier
       Reduces repetitive/mundane code by using data binding
       Improves maintenance and scalability 

The history Before MVVM

Before MVVM there was N-Tier Architecture. This Pattern did well and there are still some other applications that are Build using that pattern or architecture. The N-Tier Pattern is still good till today, but it solve certain problems. The Following diagram shows you how N-Tier looks
As you can see from this diagram, the separation is done well and re-usability of code is encouraged and separation of concern is also promoted. If you are not familiar with the pattern let me explain how it works. The Presentation Layer is what the user interact with (ASP.NET Pages, Silverlight Page, WinForm). The idea here is that the Presentation Layer will access the data via the business Layer and the Business Layer will access the Data via the Data Layer. So the Presentation Layer cannot access the Data Layer directly, in fact the Presentation Layer does not know that the data Layer Exist or the data Layer does not know that the Presentation Layer exist. So Basically the Business Layer is the Middle man between the Presentation Layer and the data Layer. The purpose of the Business Layer is to make sure that the business rules are not broken. But due to types of validation we have in different technologies, most of the business rules are done on the Database or on the Presentation layer on the client side. So this leaves the business layer as a message transporter and the code that is in there is just redundant. So this becomes an extra work when working in Silverlight and WPF, which means you need to call the data Layer functions and nothing else, one might agree that it is now turning to be a redundant layer in the pattern.
MVVM solves this problem by making sure that the Business Layer comes into the party and become an active Layer in a pattern that plays an important role and in MVVM it was named “ViewModel”. The DataLayer in MVVM is called a “Model”.  The Presentation Layer is called a “View”. The following diagram demonstrates MVVM.
From the explanation I gave, you now have a clear understanding of the pattern so far. The Model will be the DataLayer, where we execute our StoredProcedure. TheViewModel will be our Business Layer that comes into the party in this pattern. This is where the code that we normally use to call the Model resides. This is where we make sure that our business rules are not broken. The View is our Silverlight Page or WPF Forms.




Objective


The objective of this article of this second part of the series is to give you a brief structure of the MVVM pattern in Visual Studio.


How Should my Solution Look?


 This is always a problem for beginners in this subject.  Your Solution Explorer should look like this 


 


When you create a new Silverlight application in visual studio, You will get a web application created for you in the solution explorer and the Silverlight application above it as depicted. You start start renaming the folders to follow the MVVM pattern naming convensions. So in the Silverlight project you will have a Viewmodel and the View. So you will have to create these folders, you might be lucky to find that the “View” Folder is created for you. In the website you will notice there is a Folder “Model” Well our Model will be there or it can be a webservice sitting somewhere , but for the purpose of this demonstration i will put everything in the same solution. The Reason you have a model in the website is that you cannot create a webservice in a Silverlight project and you cannot access ado.net directly in Silverlight, which brings us to a point why we have the Model folder in other project. This is because the Model is the one that connects to the database and retrieve the data for us and after the data is begin retrieved, the ViewModel will get the data from the Model and the View will get data from the ViewModel. 


I don’t want to say a lot of things about the ViewModel because it I beyond the scope of this article, but in this part of the series I will explain to you and demonstrate on how you can prepare the Model for the ViewModel. 



Model 


 


The model prepares the data for the ViewModel. We normally use a wcf service to create our model. I am going to show you the complete Model that we will use in the coming series of this article. What you will see in the Model is just data retrieval from the database and some classes to Map the data and that will be all. Let us see what we can get inside the model.


What is inside the Model? 


 


These are the files you will normally have inside the model. I am not against old technologies, but I prefer using a wcf service than a soap web service. Let us start from the bottom.  We have ICustomers.cs. In WCF, this will be other Service Contract. This is the interface that defines our operations.

    [ServiceContract]
public interface ICustomers
{
[OperationContract]
ObservableCollection<CustomersModel> GetCustomers();

[OperationContract]
Boolean UpdateCustomers(CustomersModel Model);

[OperationContract]
Boolean AddCustomers(CustomersModel Model);

[OperationContract]
Boolean DeleteCustomers(CustomersModel Model);

}


As you can see that, these are the operations that need to be implemented in the class that will implement these operations. This includes our CRUD. 


The following file is the one that maps what comes from the database to the one going to ViewModel, our function GetCustomers will convert the data from a raw data to the Data that can be understood by the ViewModel, but we normally convert the data so that it can be ready for Silverlight also, we don’t want to do another conversion again in the ViewModel. 


using System;
using System.Collections.Generic;
using System.Configuration;
using System.Data;
using System.Data.SqlClient;
using System.Data.SqlTypes;
using System.Globalization;
using System.ComponentModel;
using System.Runtime.Serialization;

namespace MVVM_TestService
{
[DataContract]
public class CustomersModel
{
private int _CustomerID;
private string _CustomerName;
private string _CustomerSurname;
private string _CustomerTelephone;
private string _customeraddrees;

[DataMember]
public int CustomerID
{
get
{
return _CustomerID;
}
set
{
_CustomerID = value;
}
}

[DataMember]
public string CustomerName
{
get
{
return _CustomerName;
}
set
{
_CustomerName = value;
}
}

[DataMember]
public string CustomerSurname
{
get
{
return _CustomerSurname;
}
set
{
_CustomerSurname = value;
}
}

[DataMember]
public string CustomerTelephone
{
get
{
return _CustomerTelephone;
}
set
{
_CustomerTelephone = value;
}
}

[DataMember]
public string customeraddrees
{
get
{
return _customeraddrees;
}
set
{
_customeraddrees = value;
}
}
}
}



As you can see these are just setters and getters and nothing else. 


The Following file is Customers.svc, this is a wcf file, your service is accessed through this file, without this file there is no service, though it does not contain any code behind, but you can choose to add one, I separated it for easy readability of my project structure. If you open this file you will find the following


 


As you can see there is References to the files WCF need’s to run your service. 


Now we come to our operation class that implements the Interface and it looks like this including all the crud functions.


public class Customers : ICustomers
{
#region Connection objects
String strCon = ConfigurationManager.ConnectionStrings["DBConnectionString"].ConnectionString;


SqlCommand cmdselect;
SqlConnection con;
SqlDataAdapter da;

#endregionpublic ObservableCollection<CustomersModel> GetCustomers()
{
con = new SqlConnection(strCon);
cmdselect = new SqlCommand();
cmdselect.CommandText = "spx_GetCustomers";
cmdselect.CommandType = CommandType.StoredProcedure;
cmdselect.Connection = con;
da = new SqlDataAdapter();
DataTable dtpermissions = new DataTable();
da.SelectCommand = cmdselect;
ObservableCollection<CustomersModel> lstCustomers = new ObservableCollection<CustomersModel>();


try
{
con.Open();
da.Fill(dtpermissions);
if (dtpermissions.Rows.Count > 0)
{
for (int i = 0; i < dtpermissions.Rows.Count; i++)
{
CustomersModel m = new CustomersModel();
m.CustomerID = Convert.ToInt32(dtpermissions.Rows[i][0]);
m.CustomerName = dtpermissions.Rows[i][1].ToString();
m.CustomerSurname = dtpermissions.Rows[i][2].ToString();
m.CustomerTelephone = dtpermissions.Rows[i][3].ToString();
m.customeraddrees = dtpermissions.Rows[i][4].ToString();
lstCustomers.Add(m);
}
}

catch (SqlException ex)
{
throw ex;
}
finally
{
con.Close();
}
return lstCustomers;
}

//Upadte
public Boolean UpdateCustomers(CustomersModel Model)
{
con = new SqlConnection(strCon);
cmdselect = new SqlCommand();
cmdselect.CommandText = "spx_UpdateCustomer";
cmdselect.CommandType = CommandType.StoredProcedure;
cmdselect.Connection = con;
cmdselect.Parameters.Add("@CUSTOMERNAME", SqlDbType.VarChar).Value = Model.CustomerName;
cmdselect.Parameters.Add("@CUSTOMERSURNAME", SqlDbType.VarChar).Value = Model.CustomerSurname;
cmdselect.Parameters.Add("@CUSTOMERTELEPHONE", SqlDbType.VarChar).Value = Model.CustomerTelephone;
cmdselect.Parameters.Add("@CUSTOMERADDRESS", SqlDbType.VarChar).Value = Model.customeraddrees;
cmdselect.Parameters.Add("@CUSTOMERID", SqlDbType.Int).Value = Model.CustomerID;

Boolean Res = false;

try
{
con.Open();
cmdselect.ExecuteNonQuery();
}
catch (SqlException ex)
{
Res = true;
throw ex;
}
finally
{
con.Close();
}
return Res;
}

/// <summary>
///
/// </summary>
/// <param name="Model"></param>
/// <returns></returns>

public Boolean AddCustomers(CustomersModel Model)
{
con = new SqlConnection(strCon);
cmdselect = new SqlCommand();
cmdselect.CommandText = "spx_AddCustomer";
cmdselect.CommandType = CommandType.StoredProcedure;
cmdselect.Connection = con;
cmdselect.Parameters.Add("@CUSTOMERNAME", SqlDbType.VarChar).Value = Model.CustomerName;
cmdselect.Parameters.Add("@CUSTOMERSURNAME", SqlDbType.VarChar).Value = Model.CustomerSurname;
cmdselect.Parameters.Add("@CUSTOMERTELEPHONE", SqlDbType.VarChar).Value = Model.CustomerTelephone;
cmdselect.Parameters.Add("@CUSTOMERADDRESS", SqlDbType.VarChar).Value = Model.customeraddrees;

Boolean Res = false;

try
{
con.Open();
cmdselect.ExecuteNonQuery();
}
catch (SqlException ex)
{
Res = true;
throw ex;
}
finally
{
con.Close();
}
return Res;
}

//Upadte

public Boolean DeleteCustomers(CustomersModel Model)
{
con = new SqlConnection(strCon);
cmdselect = new SqlCommand();
cmdselect.CommandText = "spx_DELETECustomer";
cmdselect.CommandType = CommandType.StoredProcedure;
cmdselect.Connection = con;
cmdselect.Parameters.Add("@CUSTOMERID", SqlDbType.Int).Value = Model.CustomerID;
Boolean Res = false;
try
{
con.Open();
cmdselect.ExecuteNonQuery();
}
catch (SqlException ex)
{
Res = true;
throw ex;
}
finally
{
con.Close();
}
return Res;
}
}      

As you can see I am using the StoredProcedure, I have script my database and here is the Script


USE [Customer]
GOSET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
SET ANSI_PADDING ON
GO
CREATE TABLE [CUSTOMERS](
[CUSTOMERID] [int] IDENTITY(1,1) NOT NULL,
[CUSTOMERNAME] [varchar](50) NULL,
[CUSTOMERSURNAME] [varchar](50) NULL,
[CUSTOMERTELEPHONE] [varchar](50) NULL,
[CUSTOMERADDRESS] [varchar](max) NULL,
PRIMARY KEY CLUSTERED
([CUSTOMERID] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
) ON [PRIMARY]
GOSET ANSI_PADDING OFF
GO
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
CREATE PROC [spx_UpdateCustomer]
(@CUSTOMERNAME VARCHAR(50),
@CUSTOMERSURNAME VARCHAR(50),
@CUSTOMERTELEPHONE VARCHAR(50),
@CUSTOMERADDRESS VARCHAR(100),
@CUSTOMERID INT
)ASUPDATE CUSTOMERS
SET CUSTOMERNAME = @CUSTOMERNAME,
CUSTOMERSURNAME =@CUSTOMERSURNAME,
CUSTOMERTELEPHONE = @CUSTOMERTELEPHONE,
CUSTOMERADDRESS = @CUSTOMERADDRESS
WHERE CUSTOMERID = @CUSTOMERID
GOSET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
CREATE PROC [spx_GetCustomers]
ASSELECT * FROM CUSTOMERS
GOSET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
CREATE PROC [spx_DELETECustomer]
(
@CUSTOMERID INT
)ASDELETE CUSTOMERS
WHERE CUSTOMERID = @CUSTOMERID
GOSET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
CREATE PROC [spx_AddCustomer]
(@CUSTOMERNAME VARCHAR(50),
@CUSTOMERSURNAME VARCHAR(50),
@CUSTOMERTELEPHONE VARCHAR(50),
@CUSTOMERADDRESS VARCHAR(100)
)
ASINSERT INTO CUSTOMERS
VALUES(@CUSTOMERNAME,@CUSTOMERSURNAME,@CUSTOMERTELEPHONE,@CUSTOMERADDRESS)
GO


I have explained to you about all the files needed in the model. There are other two extra files, the Policy files. You will only need them when you debug your application so that you will not run into cross domain exceptions, but after you are done, you can just add them to the root directory of the virtual directory in your IIS. I will not go through them; they will be part of the example project that will be in the last part of this series. 



Let us remind our Selves with Part 1


If you remember in the part 1, I explained the purpose of each part of MVVM. 



 



The Model prepares the Data for the ViewModel and all the inserts, Update’s, Delete’s are done through the Model. The ViewModel can’t Update or do anything on the Data without the Model. 







Objective

 The objective of this article of this first part of the series is to give you a brief description of the ViewModel.

Looking back at the Overview of the Pattern? 

As you can see, I am using this diagram in all of the series of these articles. In my previous article i have demonstrated the Model and the things you are expected to have in the model and explained that the Model does not have knowledge of the ViewModel , it exist independently and it promotes better unit testing of the application. Let us look at our Diagram. 
From here we can see that the ViewModel is being accessed by the “View” and that means the “View” cannot access the Model directly, but it needs to go via the “ViewModel”. This thus confirms for us that the “ViewModel” is the middle man between the “View” and the “Model”. 

Why do we need a ViewModel? 

The ViewModel allows us to write a code once and use it in many places without rewriting it. Let me share a scenario with you. I built an N-Tier architecture application with more than 40 Silverlight pages. In that Page I obviously call the service asynchronously. 30 of my Pages I call a WCF service function that returns a list of “Customers” and I bind that list to a Combobox. I have written that asynchronously calls on the pages 30 Times. This means I have redundant code that is screaming at me. “Please write me somewhere and reuse me”.
If I was using MVVM pattern in my Project, I would have written that function once and never write any code to bind my Combobox. I know you might think I am too ambitious, but if you follow the series of this article you will see what magic MVVM brought into my life.  
So the ViewModel facilitate what I just explained, the code that needs to be reused is stored in the ViewModel and it is reused in all the Views in your application. The ViewModel does not call any StoredProcedure, but it is the one that calls the WCF services from the model. The Asynchronous calls are done once and exposed to be used as many times as it can be used.  
The View Model removes a lot of View that normally sites in the “View”. The Click event of the Button is handled in the View via the Commands. The Commands allows you to respond to the events fired on the View. e.g. if someone clicks on the button, If someone selected an item on the dropdown. The Commands are beyond the scope of this article and they can confuse a new person in MVVM. So I decided to exclude them from this part of the series and deal with the in the next part of the Series.  

What do we have inside a ViewModel?

 

From the diagram above, you can see that we only have two things on the ViewModel folder. There is the Command that I explained earlier and the actual ViewModel class that contains everything we need. Please note that you can have as many ViewModel as you can. You can have ViewModel for Customers; you can have ViewModel for Human Resource and so on. You are you not limited to any number of ViewModel. The Command Class will always look the same; I just don’t want to get into to it, because I will dedicate the next part of the article to it. Let us look at the ViewModel.  
This is how your ViewModel will look like 

namespace MVVM.ViewModel
{
public class ViewModelCustomers : INotifyPropertyChanged
{
bool isReset = false;


public ViewModelCustomers()
{
InitiateViewModel();
_insertCommand = new Commands(Add) { IsEnabled = true };
_updateCommand = new Commands(Update) { IsEnabled = true };
_deleteCommand = new Commands(Delete) { IsEnabled = true };

}

private void InitiateViewModel()
{
GetCustomers();
}

private void GetCustomers()
{
CustomerService.CustomersClient client = new CustomerService.CustomersClient();
client.GetCustomersAsync();
client.GetCustomersCompleted += new EventHandler(client_GetCustomersCompleted);
}


void client_GetCustomersCompleted(object sender, CustomerService.GetCustomersCompletedEventArgs e)
{
CustomersList = e.Result;
}


private ObservableCollection _customerlist;


public ObservableCollection CustomersList
{
get
{
return _customerlist;
}
set
{
if (_customerlist != value)
{
_customerlist = value;
OnNotifyPropertyChanged("CustomersList");
}
}
}


#region Commands

private void ClearControls()
{


isReset = true; //This Tells our Property that we are clearing the textboxes in the View



//Instead of trying to access the Controls form the View directly
//its better to crear the Controls from the Properties
CustomerName = "Enter Customer";
customeraddrees = "Enter Address";
CustomerSurname = "Enter Surname";
CustomerTelephone = "Enter Telephine";
}

private void Add()
{
CustomerService.CustomersClient customer = new CustomersClient();


if (!string.IsNullOrEmpty(CustomerName) & !string.IsNullOrEmpty(customeraddrees) & !string.IsNullOrEmpty(CustomerSurname))
{
if (isReset)
{
CustomerService.CustomersModel model = new CustomersModel()
{


CustomerName = CustomerName,
customeraddrees = customeraddrees,
CustomerSurname = CustomerSurname,
CustomerTelephone = CustomerTelephone
};


customer.AddCustomersCompleted += new EventHandler(customer_AddCustomersCompleted);
customer.AddCustomersAsync(model);
}
}
else
{
MessageBox.Show("Please make sure all Fields are Filled");
}
}

void customer_AddCustomersCompleted(object sender, AddCustomersCompletedEventArgs e)
{
if (e.Error == null)
{


InitiateViewModel();
ClearControls();
}
}

//Update

private void Update()
{
if (!string.IsNullOrEmpty(CustomerName) & !string.IsNullOrEmpty(customeraddrees) & !string.IsNullOrEmpty(CustomerSurname))
{
CustomerService.CustomersClient client = new CustomerService.CustomersClient();
CustomerService.CustomersModel model = new CustomersModel()
{
CustomerID = Convert.ToInt32(SessionManagement.GenericMethods.GenericMethods.GetCookie("CustomerID")),
CustomerName = CustomerName,
customeraddrees = customeraddrees,
CustomerSurname = CustomerSurname,
CustomerTelephone = CustomerTelephone
};


client.UpdateCustomersAsync(model);
client.UpdateCustomersCompleted += new EventHandler(client_UpdateCustomersCompleted);
}
else
{
MessageBox.Show("Please make sure all Fields are Filled");
}
}


void client_UpdateCustomersCompleted(object sender, UpdateCustomersCompletedEventArgs e)
{
if (e.Error == null)
{
InitiateViewModel();
}
}

private void Delete()
{
CustomerID = Convert.ToInt32(SessionManagement.GenericMethods.GenericMethods.GetCookie("CustomerID"));


if (CustomerID != null & CustomerID != 0)
{
CustomerService.CustomersClient client = new CustomerService.CustomersClient();


CustomerService.CustomersModel model = new CustomersModel()
{


CustomerID = CustomerID


};


client.DeleteCustomersAsync(model);


client.DeleteCustomersCompleted += new EventHandler(client_DeleteCustomersCompleted);
}
else
{
MessageBox.Show("Please make sure that you select a record to delete");
}
}

void client_DeleteCustomersCompleted(object sender, DeleteCustomersCompletedEventArgs e)
{
if (e.Error == null)
{
InitiateViewModel();
SessionManagement.GenericMethods.GenericMethods.DeleteCookie("CustomerID");
}
}


private readonly ICommand _deleteCommand;


public ICommand DeleteCommand
{
get
{
return _deleteCommand;
}
}


private readonly ICommand _updateCommand;


public ICommand UpdateCommand
{
get
{
return _updateCommand;
}
}


private readonly ICommand _insertCommand;


public ICommand InsertCommand
{
get
{
return _insertCommand;
}
}
#endregion Commands


#region Properties


///
/// Occurs when a property value changes.
///
public event PropertyChangedEventHandler PropertyChanged;


///
/// Called when [notify property changed].
///
/// Name of the property.
protected void OnNotifyPropertyChanged(string propertyName)
{
if (PropertyChanged != null)
{
PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
}
}


private int _CustomerID;
private string _CustomerName;
private string _CustomerSurname;
private string _CustomerTelephone;
private string _customeraddrees;

public int CustomerID
{
get
{
return _CustomerID;
}


set
{
_CustomerID = value;


OnNotifyPropertyChanged("CustomerID");
}
}

public string CustomerName
{
get
{
return _CustomerName;
}


set
{
if (!string.IsNullOrEmpty(value))
{
_CustomerName = value;

OnNotifyPropertyChanged("CustomerName");
}
else
{
throw new Exception("Invalid Customer name");
}
}
}


public string CustomerSurname
{
get
{
return _CustomerSurname;
}


set
{
if (!string.IsNullOrEmpty(value))
{
_CustomerSurname = value;

OnNotifyPropertyChanged("CustomerSurname");
}
else
{
throw new Exception("Invalid Surname");
}
}
}

public string CustomerTelephone
{
get
{
return _CustomerTelephone;
}

set
{
if (!string.IsNullOrEmpty(value))
{
_CustomerTelephone = value;

OnNotifyPropertyChanged("CustomerTelephone");
}
else
{
throw new Exception("Invalid Telephone");
}
}
}

public string customeraddrees
{
get
{
return _customeraddrees;
}


set
{
if (!string.IsNullOrEmpty(value))
{
_customeraddrees = value;

OnNotifyPropertyChanged("customeraddrees");
}
else
{
throw new Exception("Invalid Address");
}
}
}
#endregion
}
} 

Do not be offended by this code, I will break it into bit and peace and explain it to you, like a father or mother teaching a child to walk. 

The Constructor in a ViewModel? 

Every View model must have a Constructor. The Constructor exposes what will be available to the View. The Constructor of the ViewModel is an important part of this pattern. Without the Constructor the ViewModel cannot expose its objects. No Add, Update, Delete if the object is not initialised in the contractor of the ViewModel
The above diagram shows the Constructor of the ViewModel and there is a method that is used to call the functions or methods that the View will need when it make contact with the ViewModel.
<!--[if !supportLineBreakNewLine]-->
<!--[endif]-->
 

Getters and Setters and Events 

In our Viewmodel you have noticed that we have getters and setters that match the definition of our Model. If you have noticed closely, you will see that our setters have something extra on them

 

This is a Property tracker. The First line we declare a Propertychanged event handler that will get fired when a Property value changes. The second part is a method that will handle the event, so when a property changes, we need to make sure that it has surely been fired, So with the “if” statement there we are checking if the event is not null, if it is null, it means the event has not been triggered, else we are going all allow some operation on the “setter”. Now as you can see the “OnNotifyPropertyChanged” method is fired after the value has been set. I have seen a lot of examples on the net, where it is done before the value is set, which is not correct. Make sure it is after the value has been assigned because you want to assign the value and inspect it and throw an Exception if you have to, as depicted below.
 As the Diagram demonstrate, Here we do our business Rules, This is where we Check if the Rules are not being broken and if they are, we throw an exception and this exception is handled gracefully by the View, which I will demonstrate in our last part of the series.  

No comments:

Post a Comment