This content is no longer actively maintained. It is provided as is, for anyone who may still be using these technologies, with no warranties or claims of accuracy with regard to the most recent product version or service release.

Columns & Rows

Building Enterprise Apps

An Introduction to Multi-tier Architecture for Visual Basic Developers

By Glenn E. Mitchell II, Ph.D.

Today's corporate environment is intensely competitive. Corporate leaders look to their information systems for an edge over their competition. Their information needs are insatiable and always changing.

Business software tends to be a moving target for business enterprises. It used to be that a software application would be used for years after it was written. All that is different now. Constantly changing business needs mean constantly changing software requirements.

Multi-tier architecture supports change. You create your application from a set of independent components and distribute it across as many computers as it takes to get the job accomplished.

Dividing the application into components means it can be tuned with extreme precision. If we design the components right, we can reuse them in other applications. Distributing the processing across multiple machines improves fault tolerance and gives us the chance to optimize performance through load balancing. The result, multi-tier applications, adapt easily to changing business conditions. Multi-tier architecture just might be the best solution for your current enterprise application needs.

Client/Server: Old Models and New

There are many ways to do everything. One of the most difficult things about developing enterprise applications is deciding how to split the work between client processes and server processes. There is a lot of discussion about fat clients, fat servers, and thin clients in the literature on client/server development.

Two-tier. Traditional client/server applications for the PC separated data services from the rest of the application's logic. Data services were moved off the workstation and onto a separate database server which was optimized to store and retrieve information as efficiently as possible. Separating out the data services offered important advantages. Data persistence became a shared resource that was more secure and more easily scaled as the enterprise grew.

These two-tier applications relied on fat clients or fat servers. When the client application on the workstation handled user interaction and business-rule logic, the typical result was a fat client. It was common to use something like FoxPro or dBASE or Microsoft Access as the front end. And even in their run-time versions, these front ends were far from petite.

Another twist on the two-tier application was to move much of the business-rules logic from the client to the database server. That slimmed the client, but at the expense of the database server. It also made good sense, for another reason, to move some of the business logic from the workstation to stored procedures and triggers. When business logic changed, changes would migrate to the database server. That sure beat updating software on dozens or hundreds of workstations.

Two-tier applications fell victim to their own success. As enterprises converted their applications from the desktop to client/server, demands on the database server increased. Systems expanded from a handful of users to dozens of users, all banging away at the applications. Two-tier applications worked fine for a few dozen users, but beyond that, performance often deteriorated.

The database server can become a performance bottleneck when enterprise applications use two-tier architecture. Each concurrent client consumes database server resources. As a result, it soon became apparent that two-tier client/server applications were not powerful or flexible enough to handle enterprise applications. Three-tier applications emerged.

Three-tier. Three-tier applications attempt to conserve database server resources by partitioning the application into three sets of services. Business rules are separated from the user interface and the data services. Once they are separated, business rules can be encapsulated in separate components. The new business-rule components can then be installed on application servers.

In a typical three-tier application, the client application provides the user interface for the system. It becomes a thin client. It becomes especially thin when a Web browser hosts the user interface. Application servers handle business-rule processing, ensuring that all of the business-rule processing is done correctly. The application servers interact directly with the database server.

Three-tier applications conserve database server resources by reducing the number of concurrent database connections. Connections to the database server come from application servers instead of directly from client workstations. Users do not require separate logins or persistent connections to the database server. This not only saves database resources, it also eases database account maintenance and improves data security.

Separating business-rule services from the rest of the application offers other important advantages. Moving business-rule components to an application server can boost performance. Multiple application servers give us opportunities to further boost performance with load balancing and improve system fault tolerance. Changes to business rules can migrate to a small number of application servers instead of every workstation in the enterprise.

Multi-tier. Multi-tier (sometimes called four-tier or n-tier) client/server applications take the partitioning of application services even further. They divide the business-rules tier into two collaborating tiers: one for business-rule processing that supports the user interface, and the other for business-rule processing that integrates and manipulates data.

Multi-tier architecture was also the result of three-tier applications quickly becoming victims of their own success. Three-tier applications can generate a lot of network activity. Consider what happens when you validate fields on a form: Because users expect highly interactive forms, validating all of the fields at one time is usually impractical. So each validation can require a separate call to the application server. Validating the data on one form might involve dozens of cross-network calls to an application server.

Three-tier architecture assumes we can easily decide where to separate business-rule services from user-interface functionality and data services. In practice, it is often hard to partition business rules neatly from the remaining application logic. For example, in the case of a lone textbox on a Visual Basic form, we decide the textbox cannot be empty and the text should be displayed in uppercase. These are business rules.

If we are slavish to three-tier principles, the user-interface tier in a three-tier application should concern itself with presenting data to the user, and nothing else. Detecting the blank textbox is a business rule, not part of the presentation of data. Detecting the blank textbox belongs in the business-rule tier. Notifying the user of the blank textbox is the concern of the user interface. Where do we put the code to convert the text to uppercase for our lonely textbox? Things start to break down when we consider the uppercase requirement. There are tradeoffs to putting it either place - in the user-interface tier or the business-rule tier.

Converting the text to uppercase is, strictly speaking, performing a business rule. We should keep it out of the user-interface tier. Replacing the existing text in the textbox with an uppercase copy - after the business rule converts it to uppercase - is performing a user-interface task.

Typical VB applications use the Change or the LostFocus events to convert textbox contents to uppercase. VB encourages developers to put most of the business-rule processing behind controls on forms. That leaves core business logic straddling both the user interface and the business-rule tiers.

Three-tier applications pay a severe performance penalty for slavish adherence to principle. In our example, each time our application validates the data in the textbox, a network call crosses to the application server. This degrades network bandwidth and can severely impede application performance. This is where design tradeoffs enter the scenario.

The difference between three-tier and multi-tier appears trivial: We simply partition the business rules tier. One tier supports the user interface, the other supports data integration and data manipulation. What appears to be a trivial change, partitioning business objects, offers considerable flexibility. We can put UI-centric business-rule objects on the workstation to keep them close to the client and improve user-interface performance. Data-centric business-rule objects are excellent candidates for an application server.

Multi-tier architecture, shown in FIGURE 1, is a component-based architecture. That is one of the reasons multi-tier applications scale well. The performance of both core business logic and data persistence can be tuned with extreme precision. We have maximum flexibility, making it possible to deploy a multi-tier solution today on a single workstation and re-deploy tomorrow on a network that contains multiple application and database servers. Business-rule services that support the UI can remain physically close to the client. Remaining application services can scale as resources become available.


FIGURE 1: Multi-tier architecture.

Distributed Processing

Client/server applications use distributed processing. Among client/server possibilities, multi-tier applications give the fullest range of options for distributing application processing.

Developers tend to the shortsighted view of PCs as an individual resource. PCs are plentiful and cheap. With some imagination, we can combine their capacities to perform real multi-user/multi-tasking processing.

Today, multiple servers running Windows NT can perform the same tasks as UNIX servers and mainframe computers. A PC server farm is likely to have a much lower total cost of ownership. Individual PC-based servers are not the equals of the typical UNIX servers, but, with scalable application architecture, we can take our software and distribute it across 2, 4, 10, 50, or even 100 PC-based servers, using as many servers as it takes to accomplish the task.

While PC-based hardware can support applications distributed across many different machines, application development is only now beginning to keep pace. The major barrier to effective enterprise application development is antiquated development and deployment practices.

Multi-tier architecture focuses on business objects. Business-rule logic is easily encapsulated in reusable classes. Those business object classes can be flexibly combined into pre-compiled components. The application can include or later substitute different user interfaces and back-end databases with minimal impact on the application's core business logic. Components can move from individual workstations to remote servers when the need arises.

Multi-tier architecture is flexible about the placement and the presence of application servers. A small enterprise might choose to have the database server pull double-duty as the application server. As the enterprise grows, a separate server can take over as the application server. With more growth the single application server can become a small server farm.

It's easy to underestimate the growing need for application servers. Data persistence services always run faster than data manipulation services. Data manipulation services spawn data persistence services. No matter how fast the data persistence services run, they will always finish before the data manipulation services that spawn them. Data persistence services tend to be very efficient compared to data manipulation services. As the number of concurrent users grows, tuning of the application servers can result in more performance than additional tuning of the database server.

Data manipulation and integration services in the data-centric business-rule objects wrap around the diverse data sources for an enterprise and provide access to enterprise data as though the data existed on a single machine. Data from SQL databases can be combined with legacy mainframe data, flat files, or even Internet resources.

Data-centric business-rule services are responsible for data manipulation and integration. They work together to locate the correct data resources, invoke the necessary commands to retrieve the data, and then marshal the data back and forth from the data persistence objects to UI-centric business-rule objects. Data-centric business-rule services run faster than UI-centric business-rule services. By now, the reason is obvious. UI-centric business-rule services spawn data-centric business-rule services. They do not finish until their data manipulation and integration requests complete.

UI-centric business objects cater to the UI programmer. UI-centric business objects tend to be more specialized than data-centric business objects. A typical UI-centric business object will offer many different services. UI-centric business objects' lifecycles tend to lengthen compared to data-centric business-rule objects as a result. The longer lifecycle that is typical of UI-centric business objects means they consume server resources differently compared to data-centric business-rule objects, or data persistence services. Better performance can sometimes result when multiple application servers are present, some dedicated to data-centric business-rule objects and others dedicated to user-centric business-rule objects.

Server scalability in a multi-tier processing environment tends to be shaped like a pyramid (see FIGURE 2), with one or a few mirrored servers to handle data persistence, a handful of servers to handle data-centric business-rule objects, a larger handful of servers for UI-centric business-rule objects, and a large number of workstations for presentation services.


FIGURE 2: Server resources for multi-tier architecture.

A Practical Example

The sample application for this article is practical, consisting of a set of classes to collect basic personal information about people: customers, clients, vendors, etc. It also includes forms to select a person and to edit the personal information for individual person objects. (The sample application is available for download; see end of article for details.)

The sample classes are cPersonUI, cPersonDM, cPersonDP, and cPersons. The cPersonUI class is a UI-centric business object that handles object properties and performs object validation. The data-centric business object class is cPersonDM (the DM stands for data manipulation). The data persistence class is cPersonDP.

The user interface for the sample application consists of two VB forms: fTestHarness and fEditPerson. Multi-tier applications are broadminded about user interfaces. The idea is to de-couple the user interface from the rest of the application, and make the user interface a client of the business objects. That way, different user interfaces can be substituted in the future without recompiling and redeploying the entire application. The user interface could just as easily be something like ASP pages displayed in a Web browser.

The UI-centric Business Rules Object: cPersonUI

The cPersonUI has a method skeleton that is a useful template for creating UI-centric business objects. Once the object properties unique to person objects are stripped from cPersonUI, we're left with a smaller number of methods and properties. The skeleton for cPersonUI is shown in FIGURE 3.

' cPersonUI: Sample class for UI-centric business. 
Option Explicit
  
' Event declarations. 
Event Deleted(bDeleted As Boolean)
Event Editing(bEditing As Boolean)
Event Valid(bValid As Boolean)
  
' Declaration for data-centric business-rule object. 
Private m_oPersonDM As cPersonDM
' Declaration for ADO Recordset object. 
Private m_oADORecordset As ADOR.Recordset
  
' Boolean flags. 
Private m_bDeleted As Boolean
Private m_bDirty As Boolean
Private m_bEditing As Boolean
Private m_bExpanded As Boolean
Private m_bNew As= Boolean
Private m_bValid As Boolean
  
Private Sub delete()
Public Sub editApply()
Public Sub editBegin()
Public Sub editCancel()
Friend Sub expand()
Friend Sub load(Optional vRecordID As Variant, _
   Optional bExpanded As Boolean)
Private Function save()As Variant
Public Property Get isDeleted()As Boolean
Public Property Let isDeleted(bDeleted As Boolean)
Public Property Get isDirty()As Boolean
Private Property Let isDirty(bDirty As Boolean)
Public Property Get isEditing()As Boolean
Private Property Let isEditing(bEditing As Boolean)
Public Property Get isExpanded()As Boolean
Private Property Let isExpanded(bExpanded As Boolean)
Public Property Get isNew()As Boolean
Private Property Let isNew(bNew As Boolean)
Public Property Get isValid()As Boolean
Private Property Let isValid(bValid As Boolean)
Public Property Get recordID()As Variant
Private Property Let recorded (vRecordID As Variant)
Private Property Get ADORecordset()As ADOR.Recordset
Private Property Set ADORecordset( _
  oADORecordset As ADOR.Recordset) 
Private Property Get DataManager()As cPersonDM
Private Property Set DataManager(oPersonDM As cPersonDM) 
Private Sub Class_Initialize()
Private Sub Class_Terminate()

FIGURE 3: The skeleton for cPersonUI.

With the person details removed, we're left with a handful of Boolean variables and two object references for class properties. One of the object references is an instance of cPersonDM, the collaborating data-centric business object. The user interface and the cPersonUI class do not handle data mechanics. The user interface passes its requests to cPersonUI, which then passes the request to an instance of cPersonDM. The user interface is completely unaware of the delegation between cPersonUI and cPersonDM, and cPersonDM is completely unaware of the user interface. The other object reference in cPersonUI is an instance of the ADO Recordset object. The ADOR library is used to keep cPersonUI lightweight.

Multi-tier applications need a mechanism to pass data from one collaborating object to another across process boundaries. ADO Recordset objects are excellent means of moving data from the data-focused tiers to the UI-focused tiers. Another excellent possibility is XML. This article uses the ADO solution for reasons of performance and reduced network traffic.

Boolean variables help track object state. Objects should be responsible for themselves. Making cPersonUI track its own state frees the user interface from tracking those details. The cPersonUI class tracks six states:

  • If the instance is deleted
  • If it is dirty
  • If it is in edit mode
  • If it is expanded
  • If it is new
  • If it is valid

The question of whether the object is expanded deserves some discussion. Multi-tier applications need to consider network bandwidth. Often, an application will create multiple instances of an object, but not need all of the details about each instance. Selecting one instance from a collection of objects is one example. We might instantiate dozens or hundreds of objects in order to pick the one instance that interests us. Do we need to populate every property for each instance just to select one instance? Probably not, just populating one or two properties may suffice.

The isExpanded property allows instances of cPersonUI to self-expand. Instances of cPersonUI can initially load a minimal set of properties, and then either expand them on demand through the expand method, or expand them automatically as property methods for uninstantiated properties are invoked. The cPersonUI class loads data in three properties initially to facilitate working with selection lists. Accessing any other class property causes the object to expand. Here, for example, is the code for the Property Get address1 method:

Property Get address1()As String
   If Not isExpanded Then expand
  address1 = m_uClassData.sAddress1
End Property

Self-expanding objects can save bandwidth when you foresee many object instances using few properties. This is a common situation, not a universal one. If objects are in full use most of the time from the moment of creation, self-expanding features increase bandwidth, i.e. two trips to the data server instead of one to populate object instances. The load method accepts an optional parameter to specify whether the instance should be fully loaded, or should self-expand.

The cPersonUI class uses events to communicate with the user interface. While the cPersonUI class is responsible for maintaining its own state, that doesn't mean the user interface is uninterested when instances of cPersonUI transition from one state to another. Our user interface, for example, wants to know when objects move from a valid to invalid state, so it can enable/disable a command button to save edits. Using events is an artifact of VB and VBA. Events will not propagate to other environments. When VB/VBA events are inaccessible, the property accessor methods - isDeleted, isEditing, and isValid - will need to be polled instead.

The user interface interacts with instances of cPersonUI through just a small part of the class skeleton. The load method is used to populate the instance with data. The expand method overrides the default self-expanding behavior of cPersonUI instances. A handful of Property Get statements provides access to object state. The only Property Let statement accessible to the user interface is the isDeleted property, which is used to mark objects for deletion.

Three edit methods control changes to the class instance properties. The cPersonUI class protects its instances. This is another principle of good object-oriented design: objects should protect themselves. Class instance properties cannot be edited until the editBegin method is called. Changes to instance properties occur when the editApply method is invoked. The editCancel method drops those changes.

Object instance properties aren't stored in separate variables in cPersonUI. This might seem strange. Instead, instance properties are stored in a user-defined type (a UDT). Storing object data in a UDT facilitates cloning object data, as seen in FIGURE 4.

' UDT definition; state data for the object goes here. 
Private Type udtPerson
  vRecordID As Variant
  sNameLast As String
  sNameFirst As String
  sNameMI As String
  sAddress1 As String
  sAddress2 As String
  sCity As String
  sState As String
  sZipcode As String
  sPhoneHome As String
  sPhoneWork As String
  sPhoneFax As String
  sEmail As String
  sURL As String
End Type
  
' UDT instances. 
Private m_uClassData As udtPerson
Private m_uClone As udtPerson

FIGURE 4: Cloning object data stored in a UDT.

The cPersonUI class uses two UDTs to hold instance data. This allows cPersonUI instances to clone their data just before edits begin, and then roll the values back if the edits are cancelled. Cloning and rollbacks merely copy the instance data from one UDT to another.

The edit methods handle the UDT cloning and set a flag or two. The editApply method takes responsibility for calling the save method to persist object data, and the delete method to expunge object data. The user interface is freed from all these details and knows nothing about the save or delete methods. This can be seen in FIGURE 5.

Public Sub editApply()
   If Not isEditing Then Err.Raise ERR_ILLEGAL_OPERATION
   If Not isValid Then Err.Raise ERR_INVALID_OBJECT
  
   If isDeleted Then
    delete
    reinitialize
   ElseIf isDirty Or isNew Then
     ' Commit changes. 
    m_uClassData.vRecordID = save
    m_uClone = m_uClassData
    isDirty = False
    isNew = False
    isEditing = False
   Else
    isEditing = False
   End If
End Sub
  
Public Sub editBegin()
   If isEditing Then Err.Raise ERR_ILLEGAL_OPERATION
   ' Expand the object, if not already expanded. 
   If Not isExpanded Then
    expand
   End If
   ' Store a copy of the state data for the possibility
  ' of a rollback, i.e. editing is cancelled. 
  m_uClone = m_uClassData
  isEditing = True
End Sub
  
Public Sub editCancel()
   If Not isEditing Then Err.Raise ERR_ILLEGAL_OPERATION
   ' Rollback editing changes. 
  m_uClassData = m_uClone
  isDirty = False
  isEditing = False
End Sub

FIGURE 5: An example of edit methods in action.

The load and save methods work with an ADO Recordset object. Both methods are shown in Listing One. The load method accepts a Variant for the record ID to retrieve. Using a Variant makes the routine generic. The load method serves two purposes. It loads data for previously persisted objects, and it loads metadata for new objects. The createNew method calls the load method with a non-existent record ID in order to load metadata. Using a non-existent record ID to load metadata is a programming trick familiar to experienced ADO programmers. When ADO attempts to retrieve a non-existent record, it returns an empty Recordset object. The empty Recordset object contains no instance data, but it does contain metadata. The save method can later populate the empty ADO Recordset object with instance data.

The load and save methods delegate object persistence to similarly named methods on cPersonDM. The load method expects an ADO Recordset. It cycles through the fields in the recordset and populates the UDT for holding property values. The save method works in reverse, populating an ADO Recordset with the values from the UDT.

My preference is to follow good object-oriented practice and not allow direct access to object properties from inside the object as well as outside the object. All property access should be through Property Get/Let/Set methods. Unfortunately, Visual Basic UDTs cannot be wrapped in this way. If we wrap the m_uClassData UDT with Property Get/Set methods, VB won't generate an error when compiling or at run time. The UDT elements, however, won't successfully populate. The load method will leave the UDT elements empty of data. The usefulness of the UDTs outweighs the violation of principle in this case, so cPersonUI doesn't encapsulate the UDTs.

The delete method is private to cPersonUI. Instances are deleted by setting the isDeleted property to True and calling the editApply method. Instances of cPersonUI are recycled after their instance data is deleted with the reinitialize method, as shown in FIGURE 6.

Private Sub delete()
   ' We need a valid key value to identify
  ' the record to delete. 
   If IsEmpty(m_uClassData.vRecordID) Then _
    Err.Raise ERR_NULL_KEY_VALUE
   If CStr(m_uClassData.vRecordID) = "0" Then _
    Err.Raise ERR_INVALID_KEY_VALUE
   ' Make certain the object is marked for deletion. 
   If Not isDeleted Then Err.Raise ERR_ILLEGAL_OPERATION
   ' Delegates to cBusinessObjectDM.delete; 
  ' passes the record ID. 
   DataManager.delete m_uClassData.vRecordID
End Sub
  
Private Sub reinitialize()
   Set DataManager = Nothing
   Set ADORecordset = Nothing
  
   If isDeleted Then RaiseEvent Deleted(False)
   If isEditing Then RaiseEvent Editing(False)
  Class_Initialize
End Sub

FIGURE 6: The delete method is private to PersonUI.

The delete method follows the example of the load and save methods, and delegates persistence to cPersonDM. The reinitialize method can help application performance in a non-MTS environment. Recycling objects avoids the expensive operation to instantiate them. Recycling objects in this manner is unnecessary under MTS, since MTS handles object pooling.

The complete specification for the cPersonUI class contains a number of property assignments. Using multiple properties is best avoided when cross-process calls are involved. The preferred deployment for instances of cPersonUI is on the client PC. This facilitates a highly interactive UI without lots of network bandwidth consumption, and performance degradation from cross-network calls for property validations. You should re-engineer the Property Gets/Lets for cPersonUI, if its instances are intended for deployment on a network server. Replace the multiple properties for elements like first name, last name, street address, etc. Instead, pass the object instance properties in a single method call. This will improve performance markedly under DCOM. The trade-off is that the UI will be less interactive, since validation will be at the object-level instead of at the property level.

Additional Support for the UI: The cPersons Class

The cPersons class is useful for working with stored data from multiple instances of cPersonUI. The cPersons class is lightweight. It supports the user interface by providing methods to generate lists of person objects and delete individual instances. There is only one property for cPersons, an object reference to the cPersonDM class. The cPersons class, like the cPersonUI class, delegates data persistence to the cPersonDM class.

The fTestHarness form uses an instance of cPersons to fill an MSFlexGrid with person object data. The list method returns an ADO Recordset object, ideal for populating an MSFlexGrid. The grid is a convenient selector for editing individual instances, and selecting instances for deletion.

The code for cPersons is straightforward, as seen in FIGURE 7. All of its services use delegation.

' cPersons: Sample class for working with multiple
' instances of cPersonUI. 
Option Explicit
  
' Declaration for data-centric business-rule object. 
Private m_oPersonDM As cPersonDM
  
Friend Sub delete(vRecordID As Variant)
  DataManager.delete vRecordID
End Sub
  
Friend Function list()As ADOR.Recordset
   Set list = DataManager.list()
End Function
  
Private Property Get DataManager()As cPersonDM
   Set DataManager = m_oPersonDM
End Property
  
Private Property Set DataManager(oPersonDM As cPersonDM) 
   Set m_oPersonDM = oPersonDM
End Property
  
Private Sub Class_Initialize()
   Set DataManager = New cPersonDM
End Sub
  
Private Sub Class_Terminate()
   Set DataManager = Nothing
End Sub

FIGURE 7: All of the services for cPersons code use delegation.

The Data-centric Business Rules Class: cPersonDM

The cPersonDM class handles data manipulation and integration services. For this sample application, data manipulation and integration services are - in a word - uninspiring. The services all use delegation to temporary instances of CPersonDP.

In practice, data-centric business-rules objects sometimes perform elaborate data manipulation and integration services. More than just delegating to a data persistence class, they often combine data from multiple data sources, or run complex data validation routines. Our person business objects simply have no need for such services.

The data-centric business-rules objects are excellent candidates for MTS object pooling. The class is stateless. The data persistence methods create local object references to the data persistence class, and release those references when they complete (see FIGURE 8).

' cPersonDM: Sample class for data-centric business
' rules object.
Option Explicit
  
Public Sub delete(vRecordID As Variant)
   Dim oPersonDP As New cPersonDP
  oPersonDP.delete vRecordID
   Set oPersonDP = Nothing
End Sub
  
Public Function list()As ADODB.Recordset
   Dim oPersonDP As New cPersonDP
   Dim oADORecordset As ADODB.Recordset
  
   Set oADORecordset = oPersonDP.list()
   Set list = oADORecordset
   Set oADORecordset = Nothing
   Set oPersonDP = Nothing
End Function
  
Public Function load(vRecordID As Variant, _
  bExpanded As Boolean) As ADODB.Recordset
  
   Dim oPersonDP As New cPersonDP
   Dim oADORecordset As ADODB.Recordset
  
   Set oADORecordset = oPersonDP.load(vRecordID, bExpanded) 
   Set load = oADORecordset
   Set oADORecordset = Nothing
   Set oPersonDP = Nothing
End Function
  
Public Function save(oADORecordset As ADODB.Recordset) _
   As Variant
  
  oADORecordset.UpdateBatch
  save = oADORecordset!recordID
End Function

FIGURE 8: cPersonDM data persistence methods.

The Data Persistence Class: cPersonDP

Implementation of data persistence services is the responsibility of the cPersonDP class. None of the other classes knows the data sources or the persistence mechanics. Data persistence is completely de-coupled from the rest of the application. As a result, a Microsoft SQL Server database could easily substitute for the Microsoft Access database and not affect the rest of the application. The Microsoft Access database could move to a different server, and so on.

The example application uses SQL strings for data persistence. In practice, stored procedures running against a SQL database are more common. With ADO providers, data can easily reside in flat files, spreadsheets, Microsoft Exchange servers, Active Directory servers, etc.

The code for cPersonDP is simple, as shown in Listing Two. Each data persistence method composes any required SQL string and runs them against a Microsoft Access database using ADO. To keep the sample code simple, error handling is ignored. Production code would, of course, feature error handling.

The User Interface: fTestHarness and fEditPerson

The user interface for the example application contains two forms. The fTestHarness form (see FIGURE 9) is the main form for the application. It contains an MSFlexGrid for selecting individual person objects to edit or delete. It uses the lightweight cPersons class to populate the grid. The fEditPerson form handles the edits to individual person business objects.


FIGURE 9: The fTestHarness form.

Most of the code for fTestHarness does not require discussion. The form contains just an MSFlexGrid and a control array of command buttons. (The source code behind fTestHarness is not shown in this article. Of course, the entire VB project is available for download.)

The personAdd, personDelete, and personEdit methods are where the interesting activities occur. The personAdd and personEdit methods differ only in the value they store in the recordID form property. The personAdd method, shown in FIGURE 10, stores a non-existent record ID. Remember from our discussion of cPersonUI how a non-existent record ID loads an empty ADO Recordset with the requisite metadata for a blank cPersonUI object.

Private Sub personAdd()
  recordID = ID_DUMMY_RECORD
  fEditPerson.Show vbModal, Me
  grid_reload
End Sub
  
Private Sub personDelete()
   ' Get the record ID for the selected row. 
  grdTestHarness.Col = 0
  recordID = grdTestHarness.Text
  persons.delete recordID
  grid_reload
End Sub
  
Private Sub personEdit()
   ' Get the record ID for the selected row. 
  grdTestHarness.Col = 0
  recordID = grdTestHarness.Text
  fEditPerson.Show vbModal, Me
  grid_reload
End Sub

FIGURE 10: The personAdd, personDelete, and personEdit methods.

The only other method on fTestHarness worthy of discussion is the grid_reload method, shown in FIGURE 11. Data in the MSFlexGrid can change as a result of calls to the personAdd, personDelete, or personEdit methods. The grid_reload method clears the grid and reloads the data, keeping the data in the MSFlexGrid current.

' Reloads data in the listbox after a row is
' inserted or deleted. 
Private Sub grid_reload()
   Dim sHeaders, sEntry As String
  
   ' Empty the MSFlexGrid. 
  grdTestHarness. Clear
  grdTestHarness.Rows = 1
   ' Fetch the list of record summaries
   ' from the cPersonDM instance. 
   Set ADORecordset = persons.list
   ' Move to the first record in the recordset. 
  ADORecordset.MoveFirst
   ' Set column headers on the MSFlexGrid object. 
  sHeaders="<Record ID |<Last Name " & "|<First Name |>MI " 
  grdTestHarness.FormatString = sHeaders
   ' Load the MSFlexGrid object with data. 
   Do While Not ADORecordset.EOF
     With ADORecordset
      sEntry = !recordID & Chr(9) & !nameLast & Chr(9) & _
               !nameFirst & Chr(9) & !name
      grdTestHarness.AddItem sEntry
      .MoveNext
     End With
   Loop
   ' Select the first row of the MSFlexGrid object. 
  grdTestHarness.Row = 1
  grdTestHarness.RowSel = 1
End Sub

FIGURE 11: The grid_reload method keeps the data in the MSFlexGrid current.

The fEditPerson form (shown in FIGURE 12) works with the cPersonUI class to edit person business objects. The object reference to cPersonUI includes the WithEvents keyword in order to respond to changes in the isValid property. This makes it easy for the Save button to change from enabled to disabled in response to changes in the isValid property. There is no point in enabling the Save button until the save method can succeed.


FIGURE 12: The fEditPerson form.

The fEditPerson form does not enable the Save button until the edit mode is initiated. This allows the fEditPerson form to do double duty as a form for viewing data and editing data. Edit mode is initiated by clicking the Edit button. Locking out controls until ready to make edits avoids inadvertent changes to object data. This is another good programming practice.

There's no data validation code in fEditPerson. This makes the code relatively uncomplicated. The code for fEditPerson concerns itself with populating the textboxes with object data and enabling/disabling various controls as the associated cPersonUI object changes state. Data validation in our example application is the responsibility of the cPersonUI class.

Wrapping up the Example

The example classes demonstrate quite nicely how multi-tier architecture distributes application services. In a typical VB application, a lot of business-rule processing resides in the user interface. Validation code is spread about in the Change and LostFocus events for controls on VB forms. This makes business-rule migration more difficult, and reduces maintainability and reuse.

More experienced VB programmers pull the code for business-rule processing out into .BAS modules. This begins to separate business-rule logic from user-interface code and improves maintainability and reuse. Multi-tier architecture pulls the code for business-rule processing out into class files instead of .BAS modules. This provides better encapsulation and maximizes reuse. Business logic becomes the responsibility of two collaborating classes. You can deploy cPersonUI and cPersonDM on the same machine, or you can deploy cPersonDM on a remote server and let the two classes collaborate via DCOM.

Less experienced VB programmers favor data-bound controls attached to VB data controls. This lets them add database access quickly to their application, but it intertwines the user interface and data persistence.

Multi-tier architecture pulls the code for data persistence out into class files. Separating data persistence supports data source switching, and allows data services to be handled by servers optimized for data persistence. Again, you can deploy cPersonDP on the same machine as cPersonUI and cPersonDM, or you can separate the various classes as you see fit across different server configurations.

The example for this article was simplified in one important respect. The class files do not employ interfaces. This was done to keep the article to a reasonable length, and to keep the article and accompanying code more tightly focused on multi-tier architecture. In practice, multi-tier architecture with VB benefits from the use of interfaces. I use a set of VB classes as my foundation for multi-tier development to do nothing more than create interfaces. They serve the same function as virtual classes in C++ or interfaces in Java. If you prefer, the example classes for this article can easily be modified to include interfaces.

Glenn Mitchell is president of .Com Consulting Group, a consulting firm in Tallahassee, FL specializing in Microsoft enterprise solutions. In addition to consulting, he also lectures and writes about enterprise application development for Microsoft-sponsored conferences and journals such as Microsoft Office & VBA Developer. He can be reached at mitchell@dot-com-group.com.

Practical Advice for Developing Multi-tier Applications

Creating an effective multi-tier design requires a series of judgments when planning and implementing the application. Those judgments can lead to time-consuming performance problems that are difficult to solve.

Tip 1. Do not control transactions from the presentation tier. Latency between the tiers of an application vary. Calls between the presentation tier and the UI business-logic tier is often much slower than calls between the data-centric business-logic tier and the data-persistence tier. The speed difference can be an order of magnitude or more. Always keep in mind that UI services are slower than data manipulation services, and data manipulation services are slower than data-persistence services.

Tip 2. The most efficient way to pass data between tiers is with a single method call. A single call for passing data means that one call has to manage the entire set of information. The information might be a single data element, a single row of information, or multiple rows of information. The rows might not even have the same information, e.g. a header row followed by detail rows. Several options are available for passing data between tiers: parameters, object arrays, ADO recordsets, and XML. This article encourages the use of ADO recordsets. ADO recordsets can pass all the information in a single step and provide automatic support for data concurrency through optimistic record locking.

Tip 3. Business logic operates faster in COM/COM+ components. It's possible to implement business logic for Web applications in a scripting language. Scripts, however, are interpreted - not compiled. Compiling business logic into COM/COM+ components improves performance and increases reuse. With a Web application, use ASP for delivering the user interface, not executing business logic.

Tip 4. Don't build business objects with a single use in mind. Consider reuse and build components that can be used on multiple projects. Get a full set of requirements before you begin designing interfaces.

Tip 5. Avoid object state wherever possible, especially in the business-logic tiers and the data persistence tier. Stateless objects can be reused in environments like COM+, MTS, and RDS, taking advantage of object pooling and just-in-time activation.

Tip 6. Reuse is maximized when objects are de-coupled from their environment. Using ADO recordsets for passing information between tiers encourages flexible and generic interfaces. Try to build generic objects to support the UI, components for your business objects, and standardized data access objects that can work with a variety of data sources.

Tip 7. Optimize interactions with the data-persistence tier. Avoid fetching multiple recordsets with ADO on a single connection. When you do, ADO creates multiple connections in the background. To take advantage of connection pooling, you need to take control over the creation of connections. If you create components that access Microsoft SQL Server, for better performance use COM+ object pooling rather than Microsoft SQL Server connection pooling.

Tip 8. Use the database server to the fullest. SQL database servers are tuned to handle high-volume data access and many concurrent users. Don't use components to filter or sort data. Stored procedures and triggers improve scalability and performance. Keeping data access logic in the data-persistence tier reduces the logic needed in the business-logic tiers, and avoids unnecessary round trips to the database server. It is the same with referential integrity. Use the database engine. Data integrity will be better maintained, and the business-logic components will have less work to accomplish.

Tip 9. Tune your database. Even with multiple processors, poor database design can severely impact scalability. Performance will be affected by index structure and normalization. Avoid BLObs. Choose the right cursor. Minimize the use of locks. Optimize your queries. Many developers make educated guesses at the impact of indexes, normalization, etc. Avoid assumptions. Carefully tune your database through load testing instead.

Tip 10. Undertake thorough performance testing. Discovering poor performance after deployment is usually hazardous to your career. Application developers are notorious for their neglect of testing. Have a formal test plan from the start that emphasizes stress testing. Don't assume that COM or ADO or RDS can deliver adequate performance for your particular scenario. Simulate real-world conditions as much as possible during testing.

Multi-tier applications running on Windows 2000 can perform the same tasks as UNIX servers and mainframe computers and, very likely, do it with a much lower total cost of ownership. Successful implementation of a multi-tier design requires careful planning and thorough testing. If you follow the tips above, you should be rewarded for your efforts with an enterprise application that runs well, and can adapt to changing business conditions.

- Glenn E. Mitchell II, Ph.D.

Begin Listing One - cPersonUI class load and save methods

Friend Sub load(Optional vRecordID As Variant, _
   Optional bExpanded As Boolean)
  
   ' Make certain the object is not already editing. We
  ' don't want to change it, if edits are occurring. 
   If isEditing Or isDirty Then _
    Err.Raise ERR_ILLEGAL_OPERATION
  
   ' Handle optional method parameters. 
   If IsEmpty(vRecordID) Then _
    vRecordID = m_uClassData.vRecordID
   If bExpanded Then isExpanded = True
  
   ' Delegate to cBusinessObjectDM.load
   Set ADORecordset = _
    DataManager.load(vRecordID, isExpanded) 
  
   With ADORecordset
     ' If therecordset contains no records and there is no
    ' error, then insert a new record. 
     If .RecordCount = 0 Then .AddNew
  
     ' Get the first record.
    ADORecordset.MoveFirst
     ' Map database fields to UDT elements. 
     If Not isExpanded Then
      m_uClassData.vRecordID = vRecordID
      m_uClassData.sNameFirst = !nameFirst & "" 
      m_uClassData.sNameLast = !nameLast & "" 
     Else
      m_uClassData.vRecordID = vRecordID
      m_uClassData.sNameFirst = !nameFirst & "" 
      m_uClassData.sNameLast = !nameLast & "" 
      m_uClassData.sNameMI = !nameMI & "" 
      m_uClassData.sAddress1 = !address1 & "" 
      m_uClassData.sAddress2 = !address2 & "" 
      m_uClassData.sCity = !city & "" 
      m_uClassData.sState = !state & "" 
      m_uClassData.sZipcode = !zipcode & "" 
      m_uClassData.sPhoneHome = !phoneHome & "" 
      m_uClassData.sPhoneWork = !phoneWork & "" 
      m_uClassData.sPhoneFax = !phoneFax & "" 
      m_uClassData.sEmail = !email & "" 
      m_uClassData.sURL = !url & "" 
     End If
   End With
  isNew = False
End Sub
  
Friend Sub createNew()
  load ID_DUMMY_RECORD, True
End Sub
  
Private Function save()As Variant
   ' Make certain the object is in edit mode and has
  ' changes pending. 
   If (Not isEditing) Or (Not isDirty) Then _
     Err.Raise ERR_ILLEGAL_OPERATION
  
   With ADORecordset
     ' Map UDT to ADO recordset. 
     If Not isExpanded Then
      !nameFirst = m_uClassData.sNameFirst
      !nameLast = m_uClassData.sNameLast
     Else
      !nameFirst = m_uClassData.sNameFirst
      !nameLast = m_uClassData.sNameLast
      !nameMI = m_uClassData.sNameMI
      !address1 = m_uClassData.sAddress1
      !address2 = m_uClassData.sAddress2
      !city = m_uClassData.sCity
      !state = m_uClassData.sState
      !zipcode = m_uClassData.sZipcode
      !phoneHome = m_uClassData.sPhoneHome
      !phoneWork = m_uClassData.sPhoneWork
      !phoneFax = m_uClassData.sPhoneFax
      !email = m_uClassData.sEmail
      !url = m_uClassData.sURL
     End If
   End With
  
   ' Delegate to cBusinessObjectDM.save
   ' Return the record ID for the record. 
  save = DataManager.save(ADORecordset) 
End Function

End Listing One

Begin Listing Two - Class cPersonDP

' cPersonDP: Sample data persistence class.
Option Explicit
  
Public Sub delete(vRecordID As Variant)
   Dim oADOConnection As ADODB.Connection
   Dim oADORecordset As ADODB.Recordset
   Dim sSQL As String
  
  sSQL = "DELETE FROM Person" & _
          "WHERE RecordID = " & CStr(vRecordID) 
  
   Set oADOConnection = New ADODB.Connection
   With oADOConnection
    .Provider = "Microsoft.Jet.OLEDB.3.51" 
    .ConnectionString = "C:\TestHarness.mdb" 
    .Open
   End With
  
   Set oADORecordset = New ADODB.Recordset
   With oADORecordset
    .CursorLocation = adUseClient
    .Open sSQL, oADOConnection, adOpenKeyset, _
    adLockBatchOptimistic, adCmdText
   End With
  
   Set oADORecordset = Nothing
   Set oADOConnection = Nothing
End Sub 
  
Public Function list()As ADODB.Recordset
   Dim oADOConnection As ADODB.Connection
   Dim oADORecordset As ADODB.Recordset
   Dim sSQL As String
  
  sSQL = "SELECT recordID, nameLast," & _
         "nameFirst, nameMI FROM Person" 
   Set oADOConnection = New ADODB.Connection
   With oADOConnection
    .Provider = "Microsoft.Jet.OLEDB.3.51" 
    .ConnectionString ="C:\TestHarness.mdb" 
    .Open
   End With
  
   Seto ADORecordset = New ADODB.Recordset
   With oADORecordset
    .ActiveConnection = oADOConnection
    .Source = sSQL
    .CursorType = adOpenKeyset
    .CursorLocation = adUseClient
    .LockType = adLockBatchOptimistic
    .Open
   End With
  
   Set list = oADORecordset
   Set oADORecordset = Nothing
   Set oADOConnection = Nothing
End Function
  
Public Function load(vRecordID As Variant, _
  bExpanded As Boolean) As ADODB.Recordset
  
   Dim oADOConnection As ADODB.Connection
   Dim oADORecordset As ADODB.Recordset
   Dim sSQL As String
  
   If Not bExpanded Then
    sSQL = "SELECT RecordID, NameLast," & _
           "NameFirst FROM Person" & _
            "WHERE RecordID = " & CStr(vRecordID) 
   Else
    sSQL = "SELECT * FROM Person" & _
           "WHERE RecordID = " & CStr(vRecordID) 
   End If
  
   Set oADOConnection = New ADODB.Connection
   With oADOConnection
    .Provider = "Microsoft.Jet.OLEDB.3.51" 
    .ConnectionString = "C:\TestHarness.mdb" 
    .Open
   End With
  
   Set oADORecordset = New ADODB.Recordset
   With oADORecordset
    .ActiveConnection = oADOConnection
    .Source = sSQL
    .CursorType = adOpenKeyset
    .CursorLocation = adUseClient
    .LockType = adLockBatchOptimistic
    .Open
   End With
  
   Set load = oADORecordset
   Set oADORecordset = Nothing
   Set oADOConnection = Nothing
End Function
  
Public Function save( _
  oADORecordset As ADODB.Recordset) As Variant
  
  oADORecordset.UpdateBatch
  save = oADORecordset!recordID
End Function

End Listing Two