Going beyond wizard interfacesIf someone had said to me "Process oriented application", in the past I would have thought to myself "Wizard-like interface". Whereas I find "Wizards" very useful in certain situations I also find that they are too time intensive for a user who knows what they want to do, how to do it, and just want to get on and do it!
The thing is, I think an application layer should be process driven. The business objects layer is there in order to represent the logical business entities of the application, and the application layer should be there to drive the logical flow of the users' interaction with those objects. So, the question is "How do we implement a process oriented framework without enforcing a wizard-like interface?" The answer I think is to use Signals.
SignalsThe process may imply that there is always a specific path through an application. Although the user may influence that path by selection options along the way, it is also necessary to allow the user to perform a selection of adhoc actions at any given time, these tasks should in some way relate to the action that the user is currently performing. For example, whilst selecting a customer from a list it might be reasonable for the user to decide to view the details of one of the customers within the list before deciding they have chosen the correct one, or to edit the customer's details if they should spot a mistake.
So what exactly is a Signal and how would it work? Think of a signal as a method. Instead of this method being assigned directly to a class and therefore available to each instance of that class, a signal is a more of a "command" object which may be sent to a target object instance. The properties of this command object are akin to the parameters of a method.
You may ask yourself "If a Signal is so similar to a method, why wouldn't I just use a method instead?". Well, there are a number of benefits:
- Either the SignalTarget is aware of the Signal type and reacts accordingly upon receiving it, or the Signal is aware of the SignalTarget and performs an operation upon it.
- It is possible to add methods to specific instances rather than only at class level.
- It is possible to extend the functionality of classes without having to alter their source code, which is excellent for allowing customers to have customisations written without having to resort to checking conditional compiler defines.
- It allows you to keep the source of the target class "clean" rather than polluting it with methods for every possible feature.
- A signal may be received by more than one type of target. Using this approach helps to introduce a kind of "business dictionary" for the application. If the same term is used throughout the application (eg "Stream to file") the user will find it much easier to understand the application.
- Using a signal approach makes it very easy to ascertain which operations are available for a given target. This also allows us effectively to hide or disable a method given the current state of the object. A method based approach would require the use of reflection, and also a way of determining which methods of the class are their for implementation purposes and which are there to serve the user.
- Signals may be registered from dynamically loaded assemblies, providing the ability to customise object behaviour at runtime.
- Signal permissions may be used to introduce role based behaviour. The available actions depend entirely on the roles of the person using the application.
Firstly I wanted to avoid instances. Signals are a way of behaving, a "thing you do" rather than a "thing you are", therefore a Signal should be an interface.
SignalNot surprisingly I have chosen the name ISignal for this interface, and it has only a single member:
Signal targetThe ISignalTarget interface is used to identify an object as a target for signals. This interface has two methods:
- GetSignalPermission(ISignalFactory) : SignalPermission
GetSignalPermission is a way of determining compatibility between the ISignal and the ISignalTarget. It returns one of the following values:
- Supported: The ISignalTarget is aware of the signal type and will perform an action in response to receiving it, therefore the target and signal are compatible.
- Unknown: The ISignalTarget is unaware of the signal type. Therefore the target and signal are only compatible if the signal itself indicates that this is the case.
- Prohibited: The ISignalTarget is aware of the signal type, and regardless of what the signal indicates it will not accept the signal, therefore the signal and target and incompatible.
- Disabled: The ISignalTarget is aware of the signal type. Although this signal is normally accepted it will not be accepted at this point in time, this could be due to the current state of the ISignalTarget (for example, "Archived"), therefore the signal and target are compatible but the signal may not be sent.
ISignalFactoryAs you probably know, in .NET there is no (easy) way to implement virtual class methods. Class methods would have been very useful in this scenario because it is possible for a signal to indicate support for a target, yet it makes no sense to create an instance of every ISignal for the purpose of performing this query. In fact memory consumption is not the only reason to not take this approach:
- We have no way of knowing how to create an instance of the ISignal type. We could assume that we should always look for a parameterless constructor and invoke it using reflection, but this would not work for classes which require a parameter in their constructor. For example, if instances of the class belong to some kind of "object space" or a transaction which must be passed as a parameter.
- If we did create an instance of every type they would be used only for obtaining permissions. In a multi-user environment such as remoting or ASP .NET applications those same instances would be used by every thread, so setting their properties (aka method parameters) would be nonsense.
The ISignalFactory interface has the following methods:
- GetSignalPermission(ISignalTarget): SignalPermission - Identical to the behaviour of the ISignalTarget method.
- CreateSignal(object applicationData): ISignal - Creates an instance of the ISignal to send to the target. The "applicationData" can be an object, struct, or whatever the developer decides is necessary to pass to the factory in order to construct an instance (Such as an "object space" or a transaction object of some kind).
- Category : string - This can take any format the developer wishes and may be used to categorise signals within the UI. For example "\MainMenu\File\Edit" would indicate to the UI layer that the signal should appear in the File->Edit menu of the application's main menu.
- DisplayName : string - This is the text to display in the UI, for example "Copy". This could either be the literal text to display or the identity of a string resource within the UI to retrieve and display.
- UniqueId : string - This is a way of uniquely identifying the signal, it could be the namespace + classname, or better still a Guid as this will never change.
Signal catalogueThere is one final piece to the Signal jigsaw, the ISignalCatalogue. I have implemented a class for this item as it has very specific behaviour and I can't see any way in which it would be useful to modify it. Despite this I have created an ISignalCatalogue interface for the purpose of interacting with the class just in case somebody wants to write an alternative implementation.
The signal catalogue is a single point from which it is possible to get a list of compatible signal factories and signal targets. My implementation scans all loaded assemblies during its construction and looks for classes which implement ISignalFactory and creates an instance, this instance is then held in a list owned by the catalogue. It is also possible to manually register an instance or an assembly in case you need to dynamically load signals/factories from a DLL in order to provide customisation.
Once the signal factory is created it provides the following functionality:
- Given an ISignalTarget it will return a list of compatible ISignalFactory instances.
- Given an ISignalTarget and an ISignalFactory it will return a "SignalPermission".
- Given the UniqueId of an ISignalFactory it will return the ISignalFactory so that an ISignal may be created and sent to the target.
A SignalPermission is obtained from both the ISignalFatory and the ISignalTarget. The combinations listed below will provide the specified result:
- Factory = Prohibited : Prohibited
- Factory = Unknown : Returns the permission of the ISignalTarget.
- Factory = Supported:
- Target = Disabled : Disabled
- Target = Prohibited : Prohibited
- Target = Supported : Supported
- Target = Unknown : Supported
- Factory = Disabled:
- Target = Supported : Disabled
- Target = Unknown : Disabled
- Target = Disabled : Disabled
- Target = Prohibited : Prohibited
ConclusionIt is now easy to find a list of "commands" that are compatible with any given ISignalTarget by retrieving a collection of ISignalFactory. The information from this list of factories may be presented in the UI for the user to choose from, and may be logically grouped by the UI through use of the factories' "Category". In fact it would also be possible to differentiate signals available to the UI from internal signals via this category, maybe anything starting with "\UI" should be displayed.
If a "Process" within the application layer implements ISignalTarget then we now have the ability to specify within our application what exactly the user can do with that process. Rather than having "Next" and "Back" methods on our process we can implement "Next", "Back", "Okay", "Finish", "Confirm" or whatever type signals are most appropriate for the given task. In addition it is possible to display to the user a menu of possible actions, a group of actions (similar to the XP control panel) to the side of the form showing related tasks they could perform.
Most importantly though these signals provide a way of telling a physically separate layer what commands are available without that layer having to have any knowledge of the signal or the target. This makes it possible not only to have separate layers on a single machine, but also very easy to stream the information as XML to a physically separate layer on a client machine.