Page 1 of 1

Code to implement custom business rules in app

Posted: Fri Sep 28, 2007 12:56 pm
by Begby
I have a large app I am working on that a number of clients will use. Very often we get special requests from some of the clients to handle their orders or data in special ways, since each client is rather valuable we usually need to satisfty these requirements. Our current system has become a disaster of custom code all over the place to satisfy individual client needs, and if a client goes away there is no way to easily remove that code.

I came up with this method using the strategy pattern, please let me know if you think this would work ok or if there are other ways to improve it. One thing that is nice is I can also create a separate unit test for each client's custom set of rules.



This is the base class. This will have all the available events where a rule is triggered along with default behavior. Right now I only have two rules. The first will look at the client record and determine if this client has chosen in their preferences to have all orders submitted 'on hold' so that they can review the order before having us pack/ship it.

Code: Select all

/**
 * Abstract rules class
 *
 * Handles custom per client business rules on an event basis
 * 
 * @author Jeff Dorsch
 * @package FCP_Rules
 * @abstract 
 * 
 */
abstract class FCP_Rules_Abstract
{
	/**
	 * Client object to retrieve client settings
	 *
	 * @var FCP_Client
	 * @access private
	 */
	private $client ;
	
	/**
	 * Constructror
	 *
	 * @param FCP_Client $client
	 * @final
	 */
	final public function __construct(FCP_Client $client)
	{
		$this->client = $client ;
	}
	
	/**
	 * Called before a new order is inserted into the database.
	 *
	 * @param FCP_Order $order
	 */
	public function onBeforeOrderPlaced(FCP_Order $order)
	{
		if ($this->client->submitOrdersOnHold())
		{
			$order->setOnHold(true) ;
		}
	}
	
	/**
	 * Called after an order has been marked as shipped
	 *
	 * @param FCP_Order $order
	 */
	public function onAfterOrderShips(FCP_Order $order)
	{
		// do stuff
	}
}


This is the default concrete rules object. I have this here so that I can implement any abstract methods I might add later.

Code: Select all

/**
 * Default business rules object
 *
 * @author Jeff Dorsch
 * @package FCP_Rules
 * @final
 */
final class FCP_Rules_Default extends FCP_Rules_Abstract 
{
	
}

Here is a custom rules class to implement custom business rules specific to a client. The class ends in '5' which is the clientID.

Code: Select all

/**
 * Rules for test company
 *
 * @author Jeff Dorsch
 * @package FCP_Rules
 */
class FCP_Rules_5 extends FCP_Rules_Abstract 
{
	/**
	 * Called before a new order is inserted into the database.
	 *
	 * @param FCP_Order $order
	 */
	public function onBeforeOrderPlaced(FCP_Order $order)
	{
		parent::onBeforeOrderPlaced($order);
		/// do special stuff with order
	}
}


Here is the factory class. It uses a static array to hold previously referenced rule objects so they are only created once. This is because every time a rule object is created a query will need to be done to pull the client record.

Custom rules will be in a rules directory named with the clientID. So the rules class above will be called '5.php'.

Code: Select all

/**
 * Rules factory
 * 
 * This class is used to instantiate a business rules object for a specific clientID
 * 
 * Rules objects are cached for multiple calls
 * 
 * @author Jeff Dorsch
 * @package FCP_Rules
 * @final
 *
 */
final class FCP_Rules_Factory
{
	/**
	 * Array of rule objects
	 *
	 * @var array
	 * @access private
	 * @static
	 */
	private static $ruleObjects = array();
	
	
	/**
	 * Client mapper
	 *
	 * @var FCP_Client_Mapper
	 * @access private
	 */
	private $clientMapper ;
	
	
	/**
	 * Constructor
	 *
	 * @param FCP_Client_Mapper $clientMapper
	 */
	public function __construct(FCP_Client_Mapper $clientMapper)
	{
		$this->clientMapper = $clientMapper ;
	}
	
	
	/**
	 * Create a rules object for the given client
	 *
	 * @param int $clientID
	 * @return FCP_Rules_Abstract
	 * @static
	 */
	public function create($clientID)
	{
		// Assert integer
		if (!is_int($clientID)) throw new FCP_Rules_Exception('Integer expected') ;
		
		
		// Create a new singleton for this client if none is set yet
		if (!isset(self::$ruleObjects[$clientID])) 
		{
			$client = $this->clientMapper->findByID($clientID) ;
			
			// If the clientID is 123 the file name would be 'root/rules/123.php'
			$incFile = 	APPLICATION_ROOT
						.DIRECTORY_SEPARATOR.'rules'
						.DIRECTORY_SEPARATOR.$clientID.'.php' ;
			
			// If the file exists then include it and instantiate the rules object (named FCP_Rules_124 for clientID 123)
			if (file_exists($incFile))
			{
				require_once($incFile) ;
				$class = 'FCP_Rules_'.$clientID ;
				self::$ruleObjects[$clientID] = new $class($client) ;
			}
			// Otherwise use the default object
			else
			{
				self::$ruleObjects[$clientID] = new FCP_Rules_Default($client) ;
			}
		}
		
		
		// Return the rule object for the given ID
		return self::$ruleObjects[$clientID] ;
	}
	
}

Example usage. This would happen inside my OrderService object from the placeOrder() method. I don't intend to have the rules be called from anywhere outside of the service layer. A lot more happens inside the placeOrder() method, but this should give you an idea of where the rules come into play.

Code: Select all

// Create the objects I need
$clientMapper = new FCP_Client_Mapper($db) ;
$rulesFactory = new FCP_Rules_Factory($clientMapper) ;
$orderMapper = new FCP_Order_Mapper($db) ;

foreach ($orders as $order)
{
	// Get the rules object
	$rules = $rulesFactory->create($order->getClientID()) ;
	
	// Run the event
	$rules->onBeforeOrderPlaced($order);
	
	// Inser the order
	$orderMapper->insert($order) ;
}

Posted: Sun Sep 30, 2007 9:56 pm
by Begby
No comments good or bad? So is my code perfect?

Posted: Sun Sep 30, 2007 11:59 pm
by Christopher
No ... I'm just not sure that most members would have the time to read and think about all of what you posted. It seems within reason to me design-wise from what I understand of it.

Perhaps if you had specific questions about your design or wanted to go through the pieces one by one?

Posted: Mon Oct 01, 2007 2:07 pm
by Begby
Hopefully I can make this a little clearer; in a nutshell its like this: the clients that use this usually want some custom actions to take place at different points, like "email me when an order is over 20 items" or "submit all orders on hold".

One way to handle it is like this:

Code: Select all

if ($clientID == 583 && $order->count() > 20)
{
  // send out an email
}
elseif ($clientID == 345)
{
  // put the order on hold
}

This is bad if you have 250+ clients who all want different stuff at different points in the order process. I am guessing there are several hundred points in the system where we have custom logic for one or more clients.

The above attempts to encapsulate this custom stuff into a class that has a method for each possible point (event) in the system's many processes. To put in custom code instead of putting in a nasty if statement as above, I would extend the class and override the events where I wanted custom code.

Code: Select all

class rules_base_class
{

  // This gets called when an order gets placed
  function onOrderPlaced($order) { ... }

  // This gets called when an order is cancelled
  function onOrderCancelled($order) { ... }

  // This gets called when stock is received for a specific item
  function onInventoryReceived($itemID, $qty) { ... }

  ... etc... 
}

// Here would be a class for clientID 326 where they wanted something custom to happen after inventory is received
class rules_for_326 extends rules_base_class
{
  function onInventoryReceived($itemID, $qty)
  {
     parent::onInventoryReceived($itemID, $qty) ;
     // do stuff specific to this client
  }
}

So in the OP I have the base class, an interface, an example of the rules class extended for a client, and then a factory which uses an identity map to cache rules objects.

Am I being clear in my description?

Do you thing this is a good solution for this problem?

Is that a good implementation of a factory with an identity map?

Can you see any problems with this design as far as flexibility or unit testing is concerned?

How about performance? Some parts of the system will require calling an event method thousands of times in one run, for instance importing a CSV file of orders will call the onOrderPlaced() method for each order.