Monday, August 24, 2009

EPiServer Composer layouts should not replace mark-up

I’ve been using EPiServer Composer (formerly X3) for over a year now. EPiServer Composer can allow you to develop a very nice drag and drop interface for your editors, essentially allowing them to layout areas of new pages as and when they’re created. The temptation here, is to develop a series of functions (droppable objects like controls) which are so generic in nature, that you could allow your editors to layout the entire page. This might seem like a good idea at first, I mean, why limit the control that your editors have? Surely it’ll mean less development effort in the future?!

The problem is that Composer is not template based like ASP.NET is. It works on a per-page basis. Function properties (which includes layout functions) are all stored on a page. This means that it is impossible to give the editors the ability to make changes to page layouts globally.

Composer does have a system for allowing editors to define templates, which are useful if they want to create a series of pages which all have the same layout, however, once a page has been created using a template, all ties to that template are lost. Editing the template will not cascade the layout changes to the pages created from it.

It also has a feature called Global functions, which are functions whose properties are managed centrally, allowing site-wide changes where they are used. The problem here is that global functions do not (for implementation reasons) support layout functions.

No, Composer is much better suited for use in designing smaller areas of the page (the page body for instance where the layout will be different on each page anyway), where the surrounding controls (header, footer, navigation menu etc.) are all hard coded within the ASPX. This gives the developer a measure of control over the more important layouts, as well as the ability to make site-wide changes through simple code changes. As a rule of thumb, use Composer content areas where you might normally have used an XHTML property and Dynamic Content. Essentially Composer does the same job as Dynamic Content, but it’s so much easier for non-developers to use to layout the page.

Another of the reasons using Composer is so attractive, is that allowing pages to be changed without new code, means cutting out complex release management processes in larger businesses. However, if deployment of new code is a time consuming issue, and is something you’re trying to avoid, then consider using an EPiServer unified virtual path provider to allow developers (maybe even editors) to upload new ASPX templates at runtime, rather than flooding your pages with Composer functions where they shouldn’t be used, and potentially losing the manageability of your site.

At the end of the day, if you’re really intent on using Composer to the extreme but need to maintain a certain control over site-wide layout, then there is one suggestion I can make. It can be done using an elaborate system of url providers to point to a single Composer-designed page (a kind of template page) together with functions which fetch data from other pages or an external system, based on the requested URL. The problems with this though, are numerous. The development effort is huge and the performance impacts (unless you implement an equally convoluted caching strategy) will be nothing short of disastrous. I only mention this so that you can consider all possible solutions that might work for your business, but in general I would only recommend using Composer where layouts really do change on a per-page basis.

Custom Properties and Modal Dialogs

Ever wanted to be able to easily throw together a pop-up window custom property simply and easily? This blog entry describes the a set of base classes I use when I want to create such a property, and shows an implementation of a simple key-value editor.

Why use modal dialogs?

I use this approach whenever I think my new custom property is likely to get a bit too complex, or if I’m adding to an already large list of properties on a page type. There’s nothing worse than hitting that Edit tab and having to wait ages for the page to load all the properties. Users of Composer, with its browser busting page editor property, will know what I mean. Using dialogs keeps it all a little more lightweight, until the point of need. Writing for a standard ASP.NET page is also a lot easier and more flexible than writing a custom property in the usual way.

The Dialog Loader

Let’s start with the actual custom property control. Its responsibility is to load a modal window, accept the returned XML, and save the data. It is also responsible for showing something meaningful to the user both before and after the dialog is loaded (i.e, what they’re about to edit before they load the dialog, and what they are about to save after the dialog returns). If any of you have used the multi-page selection custom property in EPiCode, you’ll recognise the idea. My control looks like this...




A single custom property

One of the main aims of this work was to create a single reusable custom property that I could just register straight away, and not have to develop a different version of it each time I wanted this sort of property. It would work in such a generic way that each time I use it, I could configure it point it to any page (dialog) and it would load up that page when needed. Herein lied the first difficulty. There is no way to add fields to the custom property configuration screen. It’s number 1 on Henrik Nyström’s wish list for the EPiServer property framework.

To get round the problem I designed the custom property control so that it looks at the name of the instance of this property on the page type and locates .NET app settings in web.config which begin with that name.

In the example below, the configurable items are; the url of the dialog to load and the width and height of the dialog. This means my web.config app settings look like this...




<appSettings>
<add key="KeyValueListDialogURL" value="/Admin/Dialogs/KeyValueListDialog.aspx"/>
<add key="KeyValueListDialogWidth" value="450"/>
<add key="KeyValueListDialogHeight" value="150"/>
</appSettings>


Loading the Dialog

In order to keep things consistent, and to follow the EPiServer road-map as closely as possible, I have elected to use the EPi javascript library to open my dialogs (in essentially the same way as the page selection or file selection dialogs do). When the user clicks on the elipses button the property control loads that dialog and passes the previously saved value (if available) and the display text. It then waits for the dialog to close and receives the changed display text and property value. The property can then be saved. Here is the complete code for the custom property:




using System;
using System.Configuration;
using System.Web.UI.HtmlControls;
using System.Web.UI.WebControls;
using System.Xml.Linq;
using System.Xml.Serialization;
using EPiServer.Core;
using EPiServer.PlugIn;
using EPiServer.Web.PropertyControls;

namespace EPiDave.CustomProperties
{

/// <summary>
/// Custom property to store data that has come from a dialog box
/// </summary>
[PageDefinitionTypePlugIn]
public class PropertyDialogData : PropertyLongString
{

#region Overrides

/// <summary>
/// Creates a new PropertyDialogData Control.
/// This is used by the properties window.
/// </summary>
/// <returns>PropertyDialogData Control</returns>
public override IPropertyControl CreatePropertyControl()
{
return new PropertyDialogDataControl();
}

#endregion

#region Properties

/// <summary>
/// Gets the description from the raw xml of this property.
/// </summary>
public string DataDescription
{
get
{
XElement dialogData = null;
try { dialogData = XElement.Parse(this.ToString()); }
catch { }

XElement dataNode = GetDataFromDialogData(dialogData, "Description");

return (dataNode != null) ? dataNode.Value : String.Empty;
}
}

/// <summary>
/// Gets the data from the raw xml of this property
/// </summary>
public XElement Data
{
get
{
XElement dialogData = null;
try { dialogData = XElement.Parse(this.ToString()); }
catch { }

return GetDataFromDialogData(dialogData, "Data");
}
}

#endregion

#region Static Methods

/// <summary>
/// Helper to the Descendent nodes of the DialogData by name
/// </summary>
/// <param name="dialogData">The dialog data</param>
/// <param name="elementName">The descendent node name</param>
/// <returns></returns>
public static XElement GetDataFromDialogData(XElement dialogData, string elementName)
{
try
{
if (dialogData != null)
{
XElement dataElement = dialogData.Element(elementName);
if (dataElement != null)
return dataElement;
}
}
catch { }
return null;
}

/// <summary>
/// Helper to create dialog data from xml data and description
/// </summary>
/// <param name="description">The description of the dialog data</param>
/// <param name="data">The dialog data</param>
/// <returns></returns>
public static XElement CreateDialogData(string description, XElement data)
{
XElement newData = new XElement("Data");

if (data != null)
newData.Add(data);

XElement dialogData = new XElement("DialogData",
new XElement("Description", description),
newData);

return dialogData;
}

#endregion

}

/// <summary>
/// Server control used for the DialogData custom property
/// </summary>
public class PropertyDialogDataControl : PropertyLongStringControl
{

#region Constants

private const string DIALOG_JS_KEY = "PropertDialogDataJavaScript";

#endregion

#region Server Controls

private HiddenField _dataContainer;
private TextBox _statusText;

#endregion

#region Private Properties

private string EditorURL
{
get { return ConfigurationManager.AppSettings[Name + "DialogURL"]; }
}

private int DialogWidth
{
get { return Int32.Parse(ConfigurationManager.AppSettings[Name + "DialogWidth"]); }
}

private int DialogHeight
{
get { return Int32.Parse(ConfigurationManager.AppSettings[Name + "DialogHeight"]); }
}

#endregion

#region Overrides

public override void CreateEditControls()
{

// Hidden field
_dataContainer = new HiddenField();

if (DialogData.Value != null)
_dataContainer.Value = DialogData.Value.ToString();

Controls.Add(_dataContainer);

// Text Box
_statusText = new TextBox();
_statusText.EnableViewState = false;
_statusText.ReadOnly = true;
_statusText.Enabled = false;
_statusText.Text = DialogData.DataDescription;
Controls.Add(_statusText);

// Button
HtmlInputButton button = new HtmlInputButton();
button.Value = "...";
button.Attributes.Add("class", "epismallbutton");

// Add an onclick event to the button that will
// launch a popup window for the selected dialog url.
button.Attributes.Add("onclick", string.Format("LaunchDialog('{0}','{1}');", _dataContainer.ClientID, _statusText.ClientID));
Controls.Add(button);

// Create the dialog launch javascript.
string scriptString = @"
<script type='text/javascript'>
<!--
function LaunchDialog(hiddenXmlInputId, displayTextId)
{
var hiddenXmlInputCtrl = document.getElementById(hiddenXmlInputId);

var url = '"
+ EditorURL + @"';
var features = {width: "
+ DialogWidth.ToString() + @", height: " + DialogHeight.ToString() + @"};

var callbackArguments = new Object();
callbackArguments.hiddenXmlInputId = hiddenXmlInputId;
callbackArguments.displayTextId = displayTextId;

EPi.CreateDialog(
url,
WhenDialogClosed,
callbackArguments,
hiddenXmlInputCtrl.value,
features);

}

function WhenDialogClosed(returnValue, callbackArguments)
{
if (returnValue != null)
{
var statusTextboxCtrl = document.getElementById(callbackArguments.displayTextId);
var hiddenXmlInputCtrl = document.getElementById(callbackArguments.hiddenXmlInputId);

if (returnValue.isOk == true)
{
hiddenXmlInputCtrl.value = returnValue.xml;
statusTextboxCtrl.value = returnValue.displayText;
}
}
}
//-->
</script>
"
;

// register the javascript on the page.
if (!Page.ClientScript.IsStartupScriptRegistered(this.GetType(), DIALOG_JS_KEY))
Page.ClientScript.RegisterStartupScript(this.GetType(), DIALOG_JS_KEY, scriptString);

}

/// <summary>
/// Updates the data
/// </summary>
public override void ApplyChanges()
{
ApplyEditChanges();
}

/// <summary>
/// Saves new data
/// </summary>
public override void ApplyEditChanges()
{
// Encode current hidden value, and text field to save
string xmlString = _dataContainer.Value;

if (String.IsNullOrEmpty(xmlString))
{
// Check required property before clearing
if (IsRequired)
{
AddErrorValidator(string.Format("{0} must be specified.", Name));
return;
}
}

// Store the xml in the database
SetValue(xmlString);
}

#endregion

#region Public Properties

/// <summary>
/// Gets the data that the user has received from the dialog
/// </summary>
public PropertyDialogData DialogData
{
get
{
return PropertyData as PropertyDialogData;
}
}

/// <summary>
/// Returns that this control cannot be used for on page editing
/// </summary>
public override bool SupportsOnPageEdit
{
get
{
return false;
}
}

/// <summary>
/// Override the table layout so that the entry field
/// is placed to the right of the prompt rather then on
/// a new row.
/// </summary>
[XmlIgnore]
public override TableRowLayout RowLayout
{
get
{
return TableRowLayout.Default;
}
}

#endregion

}

}


The Dialog Page

When the dialog is loaded it receives the previously saved data from the parent window on the client side. We therefore perform a post-back, so that this data can be used by server side code. This functionality, as well as a method called ‘SaveAndClose’ (which sends the data back to the parent window) are standard requirements, so I have created a base class from which all Dialog pages derive. Imagine if you had to copy this code every time. Yuck. So here’s the code for the base class:




using System;
using System.Web.UI;
using System.Xml.Linq;
using EPiDave.CustomProperties;

namespace EPiDave.Admin
{

public class Dialog : System.Web.UI.Page
{

private XElement _dialogData = null;

protected System.Web.UI.WebControls.HiddenField ReturnDisplayText;
protected System.Web.UI.WebControls.HiddenField ReturnValueXml;
protected System.Web.UI.WebControls.HiddenField InitialXml;

protected override void OnInit(EventArgs e)
{

// Add required controls to the page's form
ReturnDisplayText = new System.Web.UI.WebControls.HiddenField();
ReturnValueXml = new System.Web.UI.WebControls.HiddenField();
InitialXml = new System.Web.UI.WebControls.HiddenField();
InitialXml.ID = "InitialXml";
InitialXml.EnableViewState = false;
Form.Controls.Add(ReturnDisplayText);
Form.Controls.Add(ReturnValueXml);
Form.Controls.Add(InitialXml);

base.OnInit(e);

}

protected override void OnLoad(EventArgs e)
{

base.OnLoad(e);

// Little work-around for adding the client side __doPostBack() function
// to a page that ASP.NET doesn't think requires it.
ClientScript.GetPostBackClientHyperlink(new Control(), String.Empty);

// Add dialog javascript
string javaScript = @"
// Global variables
var isPostBack = false;

// Safe way to have many onload functions. EPiServer might add
// it's own, we don't want to overwrite those
function addLoadEvent(func) {
var oldonload = window.onload;
if (typeof window.onload != 'function') {
window.onload = func;
} else {
window.onload = function() {
if (oldonload) {
oldonload();
}
func();
}
}
}

// Called when the page is loaded, we
// only want to do this the first time,
// not on subsequent post back.
// During the first load (not a postback)
// we'll set the global javascript var
// isPostBack to false, and true for all
// postbacks after that
function LoadedPage()
{

debugger

if (!isPostBack)
{
var InitialXmlCtrl = document.getElementById('InitialXml');
if (EPi.GetDialog().dialogArguments)
InitialXmlCtrl.value = EPi.GetDialog().dialogArguments;
else
InitialXmlCtrl.value = '';
__doPostBack('', '');
}

}

// User hit Save button
function OnCloseAndSave(xmlInputCtrlId, displayTextCtrlId)
{
var xmlInputCtrl = document.getElementById(xmlInputCtrlId);
var displayTextCtrl = document.getElementById(displayTextCtrlId);
var returnValues = new Object();

returnValues.isOk = true;

returnValues.displayText = displayTextCtrl.value;
returnValues.xml = xmlInputCtrl.value;

EPi.GetDialog().Close(returnValues);
}

function OnCloseAndCancel()
{
var returnValues = new Object();

returnValues.isOk = false;

returnValues.displayText = '';
returnValues.xml = '';

EPi.GetDialog().Close(returnValues);
}

// Global Calls

// Add load event handler
addLoadEvent(LoadedPage);"
;

// Register Javascript
ClientScript.RegisterClientScriptInclude(this.GetType(), "EPiJS1", EPiServer.UriSupport.ResolveUrlFromUtilBySettings("javascript/episerverscriptmanager.js"));
ClientScript.RegisterClientScriptInclude(this.GetType(), "EPiJS2", EPiServer.UriSupport.ResolveUrlFromUIBySettings("javascript/system.js"));
ClientScript.RegisterClientScriptInclude(this.GetType(), "EPiJS3", EPiServer.UriSupport.ResolveUrlFromUIBySettings("javascript/system.aspx"));
ClientScript.RegisterStartupScript(this.GetType(), "DialogJS1", javaScript, true);

// Tell Javascript that this is a postback. We need
// this value to stop the client script forcing another postback.
if (IsPostBack)
ClientScript.RegisterStartupScript(this.GetType(), "IsPostBackScriptKey", "isPostBack = true;", true);

// The first time we get here, we need to get the xml string from the
// client parameter and to the server. We do this by assigning the xml to
// the hidden InitialXml input field, and forcing a postback.
// The InitialXml will only have a value the first time the
// page is posted back.
if (IsPostBack)
{

// See if we have a value for the InitialXml field
// This should only happen once
if (!String.IsNullOrEmpty(InitialXml.Value))
{

if (_dialogData == null)
{
// Load control with saved data
_dialogData = XElement.Parse(InitialXml.Value);
}

// Remove the saved data from the hidden field.
InitialXml.Value = "";

}

}

}

/// <summary>
/// Closes the dialog box
/// </summary>
protected void CloseAndCancel()
{

string scriptCaller = "OnCloseAndCancel();";

Page.ClientScript.RegisterStartupScript(
this.GetType(),
"CloseAndCancelScriptKey",
scriptCaller,
true);
}

/// <summary>
/// Closes and saves the dialog box by assigning the xml
/// to a hidden field, runs javascript to return it to the
/// caller, and then closes the window.
/// </summary>
protected void CloseAndSave(string displayText, XElement xmlData)
{

// Store values in form
ReturnValueXml.Value = PropertyDialogData.CreateDialogData(displayText, xmlData).ToString();
ReturnDisplayText.Value = displayText;

// Call script to return values to caller
string scriptCaller = "OnCloseAndSave('{0}', '{1}');";

Page.ClientScript.RegisterStartupScript(
this.GetType(),
"OnCloseAndSaveScriptKey",
string.Format(scriptCaller, ReturnValueXml.ClientID, ReturnDisplayText.ClientID),
true);

}

/// <summary>
/// Gets the previously saved data for this dialog
/// </summary>
protected XElement DialogData
{
get
{
return PropertyDialogData.GetDataFromDialogData(_dialogData, "Data");
}
}

}

}


Sample Dialog Page

Finally here is an example dialog page which derives from the base, and which implements a VERY simple editable key-value list.




... and here’s the code:


KeyValueListDialog.aspx




<%@ Page Language="C#" EnableViewState="true" AutoEventWireup="true" CodeBehind="KeyValueListDialog.aspx.cs" Inherits="EPiDave.TestBed.Admin.Dialogs.KeyValueListDialog" %>

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" >
<html>
<head runat="server">
<base target="_self">

<title></title>

<link rel="stylesheet" type="text/css" href="<%=ResolveUrl("~/App_Themes/Default/styles/system.css") %>">
<link rel="stylesheet" type="text/css" href="<%=ResolveUrl("~/App_Themes/Default/styles/ToolButton.css") %>">

</head>
<body>
<form id="Form1" enctype="multipart/form-data" method="post" runat="server">
<div>

Key 1: <asp:TextBox ID="Key1" runat="server" /> Value 1: <asp:TextBox ID="Value1" runat="server" /><br />
Key 2: <asp:TextBox ID="Key2" runat="server" /> Value 2: <asp:TextBox ID="Value2" runat="server" /><br />
Key 3: <asp:TextBox ID="Key3" runat="server" /> Value 3: <asp:TextBox ID="Value3" runat="server" /><br /><br />

<span class="epitoolbutton"><img src="/App_Themes/Default/Images/Tools/Save.gif" alt="Save" /><asp:Button ID="InsertButton" Text="Save and Close" OnClick="InsertButton_Click" runat="server" /></span>
<span class="epitoolbutton"><img src="/App_Themes/Default/Images/Tools/Cancel.gif" alt="Cancel" /><asp:Button ID="CancelButton" Text="Cancel" OnClick="CancelButton_Click" runat="server" /></span>

</div>
</form>
</body>
</html>


KeyValueListDialog.aspx.cs




using System;
using System.Xml.Linq;
using EPiDave.Admin;

namespace EPiDave.TestBed.Admin.Dialogs
{
public partial class KeyValueListDialog : Dialog
{

protected override void OnLoad(EventArgs e)
{

base.OnLoad(e);

if (DialogData != null)
{

// Get the element we inserted into the dialog data
XElement DataContainer = DialogData.Element("Data");

if (DataContainer != null)
{

// The Dialog data has been loaded, so display the values.
XElement element;

// Key 1
element = DataContainer.Element("Key1");
if (element != null)
{
Key1.Text = element.Attribute("Key").Value;
Value1.Text = element.Attribute("Value").Value;
}

// Key 2
element = DataContainer.Element("Key2");
if (element != null)
{
Key2.Text = element.Attribute("Key").Value;
Value2.Text = element.Attribute("Value").Value;
}

// Key 3
element = DataContainer.Element("Key3");
if (element != null)
{
Key3.Text = element.Attribute("Key").Value;
Value3.Text = element.Attribute("Value").Value;
}

}

}

}

/// <summary>
/// Handles the cancel button click event closing the
/// dialog box
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
protected void CancelButton_Click(object sender, EventArgs e)
{
CloseAndCancel();
}

/// <summary>
/// Handles the save button click event saving the selected
/// pages and closing the dialog box.
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
protected void InsertButton_Click(object sender, EventArgs e)
{

// Get the data and serialize it.
XElement data = new XElement("Data");

int numSaved = 0;

// Key 1
if ((!String.IsNullOrEmpty(Key1.Text)) && (!String.IsNullOrEmpty(Value1.Text)))
{
numSaved++;
data.Add(new XElement("Key1", new XAttribute("Key", Key1.Text), new XAttribute("Value", Value1.Text)));
}

// Key 2
if ((!String.IsNullOrEmpty(Key2.Text)) && (!String.IsNullOrEmpty(Value2.Text)))
{
numSaved++;
data.Add(new XElement("Key2", new XAttribute("Key", Key2.Text), new XAttribute("Value", Value2.Text)));
}

// Key 1
if ((!String.IsNullOrEmpty(Key3.Text)) && (!String.IsNullOrEmpty(Value3.Text)))
{
numSaved++;
data.Add(new XElement("Key3", new XAttribute("Key", Key3.Text), new XAttribute("Value", Value3.Text)));
}

string displayText = String.Format("{0} keys saved.", numSaved.ToString());

CloseAndSave(displayText, data);
}

}
}


Summary


To recap, if you use this framework, all you have to do to create a modal custom property control is the following:

Add the standard custom property to your page type.
Add the required app settings to web.config,
Create a dialog which inherits from the Dialog page base class and calls the ‘SaveAndClose’ method on the appropriate event.

Easy!

Monday, August 3, 2009

Storing business objects in EPiServer

I recently had a discussion with someone about how best to store collections in EPiServer, such as RSS feeds, advertisements, frequently asked questions, customer details etc. Here are my thoughts...

Using custom properties

If you wanted to display a collection of objects on a page, then usually you might create a custom property with a suitable interface so that editors can add to, and maintain the list. You could then register this property for a particular page type - so that each page of this page type can have its own list, or as a dynamic property - so that it is available as a shared list, and available across multiple page types.

This probably makes the most sense. However, the problem with this is that there is often a lot of development that needs to be done, e.g.:

  • Developing a suitable editor for the collection (which might need to allow sorting, grouping, searching, validation etc.)
  • Developing the web control that will render the list from the serialised data.
  • Adding the more advanced features that your business might need, such as workflows for approvals, scheduling the inclusion and deletion of items from the list, security and so on.

Even if you are sure that you know exactly what your business requires, there is still the chance for scope creep, and a future of upgrades and maintenance.

Using pages

One of the ways to make your life easier is to create your collections as EPiServer pages. This instantly gives you a huge amount of the functionality you might require to manage the items. You will have the ability to:
  • Easily define the properties of the items through their page type
  • Add workflows such as approvals
  • Set up access for different users and groups with different privileges
  • Use the many EPiServer page list controls that are available to render the list

And your editors will be able to:

  • Perform the usual C.R.U.D. operations
  • Organise items in a list or hierarchy, with grouping and sorting.
  • Search for items etc. etc.

In short, EPiServer is designed to manage a large collection, and this is what it does well! Of course, an item (in these sorts of collections) is a kind of content that does not directly correlate to a page on a website, so it might seem a bit foreign to re-use this functionality. However, it is a simple matter make sure that no-one can browse to an item (which now has its own URL) by adding some redirection or error trapping logic to the load event of the item’s page type ASPX, or through an event handler on the BeginRequest event. Actually it might be good to have the ability to display a page per item, if you wanted to give editors a preview how the item might look when displayed in a list! :)

There are some down sides though. EPiServer makes some assumptions about your items. It thinks they are pages of a site! This means adding a huge amount of overhead to them. The database stores a lot of information about pages in EPiServer that you might not want, and this, combined with the fact that the relational model isn’t going to be entirely optimised for your needs, will make data access slower than it needs to be. The API used to get page data back from the database is optimised in certain ways (such as retrieving the properties of the page), but this won’t do you any good if you have related objects in your list. You will often need several database reads on each page request. Caching may only get you so far as well.

Nowhere can potential performance issues been seen better, than in the page tree in the EPiServer edit mode. Imagine if you had thousands, or even millions of items in your collection. This interface is not designed to support lists of this scale. Even when in optimised mode, if your items do not lend themselves nicely to being stored in a hierarchy, and end up all under one parent node, then the interface will eventually become unusable, and you’ll need to write your own.

With a large collection of pages, you may also face performance issues on individual page requests, since the default EPiServer URL provider will have to search through all of your pages to find the page ID which corresponds to the requested URL.

If performance and scale are issues for you then the last resort is to start...

Using a custom data access layer

In the end, creating your own tables and stored procedures etc, may be the way to go, since you have so much extra control over the functionality, but generally I wouldn’t recommend it unless you really need to. EPiServer has so much functionality that can be re-used, it would seem silly to me not to try.

Summary

To summarize, using pages for your business objects is often a very good solution which is quick to implement and easily adopted by your editors. But what you need to bear in mind is that EPiServer is not a content management system in the literal sense. ‘Page management system’ would be more accurate, and if you’re thinking of using pages to manage your collections, you need to be sure that performance not be a problem.

It would be nice to see EPiServer change (or expand) their architecture in the future for enterprise applications in need of managing more than just pages. Of course, for specific markets, products and plugins are already available (EPiServer Community for instance).