I've been talking about how I'm using a small business object wrapper around LINQ to SQL to provide better abstraction of the LINQ to SQL functionality and I suppose the time's come to be bit more specific. This is going to be a long post with a fair bit of code and a brief discussion of the whys and hows. So I want to share my ideas of what I'm thinking here and an overview of what I've built so far. It's not complete, but enough to work with to get a feel for it and I'm using it successfully with a sample app I'm building for my ASP.NET Connections session. Granted it's not a complicated app (a Time Trakker) but I have to admit - especially given my original misgivings and skepticism towards LINQ to SQL - it's working out pretty damn well.
I'm throwing this out now to get some thoughts and feedback and I'll post my current code so any so inclined can play around with this, but keep in mind this is rougher than stuff I usually put out publicly so it may take some twiddling.
So... where to start?
LINQ to SQL is essentially an OR/M tool that requires modeling your data model to an object model. But while you are using objects, in essence LINQ to SQL still is little more than a fancy Data Access Layer. It does not make a business object layer or middle tier.
It seems these days the concept of business objects is getting buried somewhat under the veil of technology. I was reminded of that the other day while reading a post on Rocky's blog regarding CSLA in comparison Enterprise Framework. We are so absorbed by the discussion of the technological choices that the business and logistical aspects and separation of responsibility often just seems to get lost. It's rare these days to hear people talking about business objects anymore - the focus is all on the front end and the data access technology itself which is kinda sad, because in my mind at least business objects really provide the biggest productivity and stability boost in app development.
My goal with what I'm building is not to build an end all framework - I've never been a fan of big frameworks, but ever since the dawn of time it seems I've built myself a small set of business object wrappers that facilitate the process of building data centric applications. And this is just one more iteration of it. This is nowhere as complete as CSlA or Mere Mortals or some other framework would be but it is also smaller and easier to work with. In time, I'm sure bigger frameworks will integrate with LINQ to SQL or Entity Framework too, but right now if you want to use this technology you have to roll your own. Again, this is my vision for my needs and I'm simply sharing it here - I'm not trying to get into a holy war on which approach is better <s>.
So, Business Objects in my mind serve several main purposes (at a minimum):
Logical Abstraction
They abstract the object model in such a way that all code related to accessing the object model (in this case LINQ to SQL Entities) as well as any other incidental data access in one place. While an object model may map database tables and relationships 1 - 1 , the business layer can combine these objects in any combination necessary to provide the logical high level abstraction. The typical example here is an invoice object that controls Invoice header, line items and customers for example. The invoice knows how to access all of these components and its the business object that accesses the model not the front end code. Front end code typically interacts with the business object and receives result data (queries or data objects) or in the case of CRUD operations entity objects to work with in the UI.
Code Abstraction
Just as important as the logical abstraction is the code abstraction in that all code related to a business object ends up in one place. This may be a single class or a set of classes, but there's a distinct place where are 'business logic' is addressed - always in one place. Never does the front end code talk to the data directly (although with LINQ there's some blurring of this line because it gets a little fuzzy of what 'data' means when you're dealing with LINQ expressions) it only sees the results which are put together by the business object. One huge advantage with this scenario is that you can easily reuse business object code in different applications or even different parts of your application. So if you build an ASP.NET page that access the business object, you use the same business object you might use a in a Web Service or even a Windows Forms application. This sort of code reuse simply cannot happen if you stick any sort of data access code into the UI using DataSource controls <g>.
CommonBusiness Object and Data Functionality
LINQ to SQL provides a pretty easy data access model through the LINQ to SQL entity classes and it's pretty straight forward. A business layer built ontop of LINQ to SQL can certainly take advantage of this functionality and reduce the amount of code that needs to get written to provide the basic DAL functionality (which in non-OR/M environments must be built separately). With LINQ to SQL a business layer can leverage that functionality directly and even provide much additional value for data retrieval operations by returning results as LINQ queries as I mentioned yesterday.
But there are also many things missing that a business layer needs. Validation for example, is not really something that you can or should handle on the entity model itself. LINQ to SQL does provide OnValidate methods for each mapped property it creates, but this sort of mechanism is very difficult to consolidate in a unified validation strategy. So at minmium there should be a ValidationErrors facility that can be checked and used to ask a business object to validation itself with a simple Validate() method perhaps.
Loading and Saving too should be simpler for front end code than what LINQ to SQL provides. Realistically UI code should have to know very little about the data it needs to access and certainly for simple tasks like retrieving an entity instance you should just be able to provide a key or potentially call specialized bus object methods that return an entity. This translates into standard Load(pk) and Save() methods that know what to do without any further setup.
Given that LINQ to SQL has some 'issues' with disconnected operation that model should also be abstracted. In my simple framework I have an option flag on the business object that allows either connected or disconnected operation. By default the same connected mode that LINQ to SQL uses is used where a DataContext instance jacked to the business object holds the context's change state. Any Save Operation then simply calls submit changes. In disconnected mode the business object creates a new context for each operation - to feed entities out and to save them back. The connected mode is more efficient, but if you need to disconnect it's easily done or can be overridden for the default behavior altogether when the object is created.
Finally LINQ to SQL provides essentially DAL functionality - but it doesn't provide much in the way of core ADO.NET operations which in some situations might be handy. There's no direct support for executing a SQL command and returning a DataReader or DataTable for example. There's not a lot of need for this functionality, but I've found that at times it's damn handy to be able to return data in otherways. So there's an extension of the DataContext that provides the abillity to easily create commands, parameters, and run database queries and commands more easily.
There's also a Converter class on the business object that can take a query (generated through this same business object's DataContext) and convert it into a resultset other than an EntityList. This it turns out is pretty important in that data binding to good old ADO.NET types is much more efficient than binding to entity lists which require Reflection to bind each data item.
A simple Business Framework for LINQ to SQL
So without much further ado here's what I've built so far. Keep in mind that this is just a start although I'm finding that what I have so far is quite adequate to work with.
Here's a a class diagram (with the hacked markup since VS can't deal with the generic associations) that shows what the object model looks like:
From a usability point of view compared to LINQ to SQL there's basically just one extra level of indirection: The business object. So to use this model, you create a new business object that inherits from wwBusinessObject<TEntity, TContext> and you provide the generated DataContext and EntityType from your LINQ to SQL model as generic parameters.
For example:
public class busEntry : wwBusinessObject<EntryEntity, TimeTrakkerContext>
A DataContext is created for each business object and it manages its own context. So two business object instances don't share the same context state.
The TEntity generic parameter is used for providing a link to the 'main' Entity that is associated with the business object. The business object may deal with more entities internally but there's usually going to be a primary entity that drives the BO. The primary entity is what is updated for the built in CRUD methods, so when you call Load() you get an instance of the provided entity type.
Note that the business object also has an internal Entity member which is loaded by each of the load operations. I've always found this very convenient because it makes it easy to pass around entity data in the context of a page without having to create separate instances. Load() and NewEntity() will automatically set the internal entity member, but also return the entity as return value. The .Entity property is merely a convenience.
For CRUD operations the business object doesn't require any setup or configuration code. Simply the above definition is enough to use code like the following:
this.Entry = new busEntry();
if (this.Entry.Load(10) == null) // load by pk
return false;
this.Entry.Entity.Title = "My Entry";
if (!this.Entry.Save())
{
this.SetError(this.Entry.ErrorMessage);
return;
}
this.Entry.NewEntity();
this.Entity.Title = "New Entity";
this.Entity.Save();
// *** Still pointing at new entity
this.Entry.Entity.Title = "Something else";
// *** Delete the entity
if (this.Entry.Delete())
this.SetError("Couldn't delete: " + this.Entry.ErrorMessage);
This may not seem like a big improvement over LINQ to SQL, but it's actually a lot less code than you'd have to write even with plain LINQ to SQL code and it handles a fair amount of details behind the scenes such as error handling.
Here the client code is not talking directly to the data model to retrieve or save data - all it does is interact with the entity and then call the business object 'to deal with it'. No LINQ syntax for any of this which is as it should be IMHO. The UI doesn't talk to the data or even the model directly, only to the entities.
Query methods in the business object (such as GetRecentEntries() for example) generally are implemented as methods that return Query objects. Preferrably you'd want to return strongly typed entities like this bus object method:
/// <summary>
/// Get open entries for a given user
/// </summary>
/// <param name="userPk"></param>
/// <returns></returns>
public IQueryable<EntryEntity> GetOpenEntries(int userPk)
{
IQueryable<EntryEntity> q =
from e in this.Context.EntryEntities
where !e.PunchedOut
orderby e.TimeIn
select e;
// *** Add filter for User Pk - otherwise all open entries are returned
if (userPk > 0)
q = q.Where(e => e.UserPk == userPk);
return q;
}
So that the front end code can optionally further filter the query. When a strongly typed query is returned there are a lot of options to deal with the data as shown in the following snippet:
protected override void OnInit(EventArgs e)
{
base.OnInit(e);
// *** Set up the base query
IQueryable<EntryEntity> entries = this.entry.GetOpenEntries(this.TimeTrakkerMaster.UserPk);
int count = entries.Count();
if (count == 1)
{
int? Pk = entries.Select(en => en.Pk).FirstOrDefault();
if (Pk == null)
Response.Redirect("~/Default.aspx");
Response.Redirect("~/punchout.aspx?id=" + Pk.ToString());
}
// *** Assign the data source - note we can filter the data here!
this.lstEntries.DataSource = this.entry.Converter.ToDataReader(
entries.Select(en => new { en.Pk, en.Title, en.TimeIn }));
this.lstEntries.DataBind();
}
All the queries that actually hit the database are shown in bold. Notice that there are 3 different queries that are run from the original query returned from the business object, giving the front end code a ton of control of how to present the data in the UI.
Notice also the entry.Converter.ToDataReader() call for databinding. This isn't strictly necessary - you could directly bind the result of the query. However, databinding to a DataReader() is 3-4 times faster than binding to an entity list as Entity list binding requires use of Reflection for each data item. The DataConverter provides an easy way to convert to DataReader, DataTable (useful for paging, editing and still way faster binding than Entities) and List. Another advantage of the Converter is that it fires any errors into the business object so errors can be trapped more effectively.
Ok... so the CRUD code above is in connected mode. If you want to run CRUD operations in disconnected this should work:
// *** You can also work on the entities disconnected
this.Entry.Options.TrackingMode = Westwind.BusinessFramework.TrackingModes.Disconnected;
EntryEntity entry = this.Entry.Load(10);
entry.Title = "Updated Title";
this.Entry = null; // kill business object
// *** Create a new one
this.Entry = new busEntry();
this.Entry.Options.TrackingMode = Westwind.BusinessFramework.TrackingModes.Disconnected;
this.Entry.Save(entry); // update disconnected entity
To give you an idea of what this looks like in a Web page here's some code handles displaying and then saving entity data for a new time entry:
protected override void OnInit(EventArgs e)
{
base.OnInit(e);
this.Proxy.TargetCallbackType = typeof(Callbacks); // wwMethodCallback Ajax Callbacks handler
if (this.Proxy.IsCallback)
return;
object projectQuery = Project.GetOpenProjects();
this.lstProjects.DataSource = Project.Converter.ToDataReader(projectQuery);
this.lstProjects.DataValueField = "Pk";
this.lstProjects.DataTextField = "ProjectName";
this.lstProjects.DataBind();
object customerQuery = Customer.GetCustomerList();
this.lstCustomers.DataSource = Customer.Converter.ToDataReader(customerQuery);
this.lstCustomers.DataTextField = "Company";
this.lstCustomers.DataValueField = "Pk";
this.lstCustomers.DataBind();
}
protected override void OnLoad(EventArgs e)
{
base.OnLoad(e);
if (this.Proxy.IsCallback)
return;
this.TimeTrakkerMaster.SubTitle = "Punch In New Entry";
if (this.Entry.NewEntity() == null)
{
this.ErrorDisplay.ShowError("Unable to load new Entry:<br/>" + this.Entry.ErrorMessage);
return;
}
if (!this.IsPostBack)
{
// *** Get the User's last settings
// we need to load user here no association yet
busUser user = TimeTrakkerFactory.GetUser();
if (user.Load(this.TimeTrakkerMaster.UserPk) != null)
{
if ( user.Entity.LastCustomer > 0 )
this.Entry.Entity.CustomerPk = user.Entity.LastCustomer;
if (user.Entity.LastProject > 0)
this.Entry.Entity.ProjectPk = user.Entity.LastProject;
}
// *** Now bind it
this.DataBinder.DataBind();
}
}
protected void btnPunchIn_Click(object sender, EventArgs e)
{
// *** Start by unbinding the data from controls into Entity
this.DataBinder.Unbind();
// *** Manual fixup for the split date field
DateTime PunchinTime = Entry.GetTimeFromStringValues(this.txtDateIn.Text, this.txtTimeIn.Text);
if (PunchinTime <= App.MIN_DATE_VALUE)
{
this.DataBinder.BindingErrors.Add( new Westwind.Web.Controls.BindingError("Invalid date or time value", this.txtDateIn.ClientID));
Entry.ValidationErrors.Add("Invalid date or time value", this.txtTimeIn.ClientID);
}
Entry.Entity.TimeIn = PunchinTime;
// *** Validate for binding errors - and error out if we have any
if (this.DataBinder.BindingErrors.Count > 0)
{
this.ErrorDisplay.ShowError(this.DataBinder.BindingErrors.ToHtml(), "Please correct the following:");
return;
}
// *** Have to make sure we associate a user with this entry
Entry.Entity.UserPk = this.TimeTrakkerMaster.UserPk;
// *** Validate business rules
if (!this.Entry.Validate())
{
foreach (ValidationError error in this.Entry.ValidationErrors)
{
this.DataBinder.AddBindingError(error.Message,error.ControlID);
}
this.ErrorDisplay.ShowError(this.DataBinder.BindingErrors.ToHtml(), "Please correct the following:");
return;
}
// *** Finally save the entity
if (!this.Entry.Save())
this.ErrorDisplay.ShowError("Couldn't save entry:<br/>" +
this.Entry.ErrorMessage);
else
{
this.ErrorDisplay.ShowMessage("Entry saved.");
Response.AppendHeader("Refresh", "2;default.aspx");
// *** Remember last settings for Project and Customer for the user
// *** NOTE: Entry.Entity.User is not available here because it's a NEW record
// so we explicitly load and save settings
busUser User = TimeTrakkerFactory.GetUser();
User.SaveUserPreferences(Entry.Entity.UserPk, Entry.Entity.CustomerPk, Entry.Entity.ProjectPk);
}
}
This is one of the simpler examples that deals mostly with a single business object but it should give you an idea of how things work. I mentioned the query functionality yesterday.
The business object's typical implementation will likely provide:
Query Result Methods
Methods that return data that is used in the front end. These are methods like GetOpenProjects() or GetCustomerList() that generally take input parameters and then create Queries that get returned as a result. Remember queries are not executed until enumerated so effectively no SQL access occurs until databinding happens.
Overridden CRUD operations
This is actually quite common: You'll want to set default values or perform other operations on new entities, set default values (say an Updated column) on or before saving. This can also mean speciaty methods that basically overload CRUD operations. For example in Time Trakker I have things like PunchIn and PunchOut that are essentially overloads of the Save() method that assign specific values first. For my user object I have AuthenticateAndLoad(string username, string password) which is essentially an overloaded Load() method.
Convenience Methods
Often you also have convenience methods that format data a certain way or perform special taks in batch on the entity object for example. Or you may have special operations that run updates against the database. Maybe a 'UpdateTimeTotals()' that ensures that all data in the tables are properly calculated (which incidentally would be a non-LINQ operation using just a command object).
Again to give you an idea of what this looks like here's my business object for the Entry object:
/// <summary>
/// Business object related a time entry.
/// </summary>
public class busEntry : wwBusinessObject<EntryEntity, TimeTrakkerContext>
{
/// <summary>
/// Get open entries for a given user
/// </summary>
/// <param name="userPk"></param>
/// <returns></returns>
public IQueryable<EntryEntity> GetOpenEntries(int userPk)
{
IQueryable<EntryEntity> q =
from e in this.Context.EntryEntities
where !e.PunchedOut
orderby e.TimeIn
select e;
// *** Add filter for User Pk - otherwise all open entries are returned
if (userPk > 0)
q = q.Where(e => e.UserPk == userPk);
return q;
}
/// <summary>
/// Get all open entries
/// </summary>
/// <returns></returns>
public IQueryable<EntryEntity> GetOpenEntries()
{
return this.GetOpenEntries(-1);
}
/// <summary>
/// Gets a list of recent entries
/// </summary>
/// <param name="userPk"></param>
/// <param name="Count"></param>
/// <returns></returns>
public IQueryable<EntryEntity> GetEntries(int userPk)
{
IQueryable<EntryEntity> q =
from e in this.Context.EntryEntities
orderby e.TimeIn descending
select e;
return q;
}
#region overridden CRUD operation
/// <summary>
/// Sets default time value
/// </summary>
/// <returns></returns>
public override EntryEntity NewEntity()
{
EntryEntity entry = base.NewEntity();
if (entry == null)
return null;
entry.TimeIn = TimeUtilities.RoundDateToMinuteInterval(DateTime.Now,
App.Configuration.MinimumMinuteInterval,
RoundingDirection.RoundUp);
entry.TimeOut = App.MIN_DATE_VALUE;
return entry;
}
/// <summary>
/// Fixes up times for Universal Time to the database
/// </summary>
/// <returns></returns>
public override bool Save()
{
//if (this.Entity.TimeIn != null)
// this.Entity.TimeIn = this.Entity.TimeIn.ToUniversalTime();
//if (this.Entity.TimeOut != null)
// this.Entity.TimeOut = this.Entity.TimeOut.ToUniversalTime();
return base.Save();
}
/// <summary>
/// Fixes up times for local time from the database
/// </summary>
/// <param name="pk"></param>
/// <returns></returns>
public override EntryEntity Load(object pk)
{
if (base.Load(pk) == null)
return null;
//if (this.Entity.TimeIn != null)
// this.Entity.TimeIn = Entity.TimeIn.Value.ToLocalTime();
//if (this.Entity.TimeOut != null)
// this.Entity.TimeOut = Entity.TimeOut.Value.ToLocalTime();
return this.Entity;
}
/// <summary>
/// Checks for empty title and time in values and associations for user, customer and project
/// </summary>
/// <returns></returns>
public override bool Validate()
{
base.Validate();
if (string.IsNullOrEmpty(this.Entity.Title))
this.ValidationErrors.Add("The title is required","txtTitle");
if (this.Entity.TimeIn <= App.MIN_DATE_VALUE)
this.ValidationErrors.Add("Time and/or date value is invalid","txtTimeIn");
if (this.Entity.CustomerPk < 1)
this.ValidationErrors.Add("A customer must be associated with this entry", "txtCustomerpk");
if (this.Entity.ProjectPk < 1)
this.ValidationErrors.Add("A project must be associated with this entry", "txtProjectPk");
if (this.Entity.UserPk < 1)
this.ValidationErrors.Add("A user must be associated with this entry", "txtUserPk");
if (ValidationErrors.Count > 0)
return false;
return true;
}
/// <summary>
/// punches out an individual entry and saves it
/// </summary>
/// <returns></returns>
public bool PunchOut()
{
this.Entity.PunchedOut = true;
if (this.Entity.TimeOut == null || this.Entity.TimeOut < this.Entity.TimeIn)
this.Entity.TimeOut = DateTime.Now;
return this.Save();
}
/// <summary>
/// Punches in a new entry by setting punch in time
/// </summary>
/// <returns></returns>
public bool PunchIn()
{
return this.PunchIn(null);
}
/// <summary>
/// Punches in a new entry by setting punch in time
/// </summary>
/// <param name="entity"></param>
/// <returns></returns>
public bool PunchIn(EntryEntity entity)
{
this.Entity.PunchedOut = false;
if ( this.Entity.TimeIn <= App.MIN_DATE_VALUE )
this.Entity.TimeIn = DateTime.Now;
if (Entity == null)
return this.Save();
else
return this.Save(entity);
}
/// <summary>
/// Punches out an individual entry and saves it
/// </summary>
/// <param name="entry"></param>
/// <returns></returns>
public bool PunchOut(EntryEntity entry)
{
entry.PunchedOut = true;
if (entry.TimeOut == null || entry.TimeOut < entry.TimeIn)
entry.TimeOut = DateTime.Now;
this.CalculateItemTotals();
return this.Save(entry);
}
#endregion
/// <summary>
/// Utility function that converts a date time entry value from
/// a date and time string to a DateTime value
/// </summary>
/// <param name="Date"></param>
/// <param name="Time"></param>
/// <returns></returns>
public DateTime GetTimeFromStringValues(string Date, string Time)
{
DateTime val = App.MIN_DATE_VALUE;
DateTime.TryParse(Date + " " + Time,out val);
return val;
}
/// <summary>
/// Calculates Item and Rate totals and sets it on the passed entry object
/// </summary>
public void CalculateItemTotals(EntryEntity Entry)
{
if (Entry == null)
Entry = this.Entity;
if (Entry.TimeIn == null ||
Entry.TimeOut== null ||
Entry.TimeOut < Entry.TimeIn)
Entry.TotalHours = 0.00M;
else if ( Entry.TimeOut > App.MIN_DATE_VALUE &&
Entry.TimeIn > App.MIN_DATE_VALUE)
Entry.TotalHours = (decimal)Entry.TimeOut.Subtract(Entry.TimeIn).TotalHours;
Entry.ItemTotal = Entry.TotalHours * Entry.Rate;
}
/// <summary>
/// Calculates Item and Rate totals. This version works off the internal Entity object
/// </summary>
public void CalculateItemTotals()
{
this.CalculateItemTotals(null);
}
/// <summary>
/// Adjusts the time values for rounding conditions
/// </summary>
public void RoundTimeValues()
{
if (this.Entity.TimeIn > App.MIN_DATE_VALUE)
this.Entity.TimeIn = TimeUtilities.RoundDateToMinuteInterval(this.Entity.TimeIn,
App.Configuration.MinimumMinuteInterval,
RoundingDirection.RoundUp);
if (this.Entity.TimeOut > App.MIN_DATE_VALUE)
this.Entity.TimeOut = TimeUtilities.RoundDateToMinuteInterval(this.Entity.TimeOut,
App.Configuration.MinimumMinuteInterval,
RoundingDirection.RoundUp);
}
}
Most of the methods are pretty minimalistic - as you would expect in a simple application like this. But it should give a pretty clear view of what typically happens in the business object. The code essentially deals with the core data manipulation, pre and post processing and of course generation of queries. The code tends to break down into very clearly defined responsibilities that are easy to test against. Further given the structure of the business object you have a sort of template that you follow with most business objects. You implement Validate() and probably one more Load() related methods and possibly Save() if there's some special save syntax.
The business object uses LINQ to SQL for data access and passes out either LINQ queries (IQueryable<T> for 'adjustable' results or IQueryable for fixed non-mutable results) or returns individual entities for the CRUD methods. In simple applications like the time tracking app I'm building nearly 90% of the code deals with CRUD and convenience methods and just a few simple queries which is quite common for transactional applications.
Most operations require relatively little amounts of code because of the high level of abstraction that LINQ offers. From a pure code usability perspective LINQ to SQL is making code cleaner and resulting in quite a bit less of it. So far I haven't run into any of my own 'warning' points - mainly because I haven't gotten there yet. I know I will have issues when I get to the Web Service portion of things and already had to dodge one issue when using AJAX serialization. But other than that so far so good.
So there you have it <g>. I'd be interested to hear thoughts.
[ 2/5/2008 code updated for .NET 3.5 RTM ]
Grab the code
Other Posts you might also like