Layers

The Layers architectural pattern helps to structure applications that can be decomposed into groups of subtasks in which each group of subtasks is at a particular level of abstraction.

Motivation

A layered approach is considered better practise than monolithic alternatives. It accentuates decoupling, aides development by teams, and supports incremental coding and testing. Using semi-independent parts also enables the easier exchange of individual parts at a later date.

Example

Networking protocols specify agreements at a variety of abstraction levels, ranging from the the details of bit transmission, to packetizing, to routing, to high-level application logic. Each layer deals with a specific aspect of communication and uses the services of the next lower layer. The OSI 7-layer model has the following architectural model.

Application  layer 7  Provides miscellaneous protocols for common activities
Presentation  layer 6   Structures information and attaches semantics
Session  layer 5   Provides dialog control and synchronization facilities
Transport  layer 4   Breaks messages into packets and guarantees delivery
Network  layer 3   Selects a route from sender to receiver
Data Link  layer 2   Detects and corrects errors in bit sequences
Physical  layer 1   Transmits bits: velocity, bit-code, connections, etc.

Implementation

The following steps describe a step-wise refinement approach to the definition of a layered architecture. This is not necessarily the best method for all applications - often a bottom-up or "yo-yo" approach is better. See also the discussion in step 5.

Not all the following steps are mandatory - it depends on your application. For example, the results of several implementation steps can be heavily influenced or even strictly prescribed by a standards specification that must be followed.

1. Define the abstraction criterion for grouping tasks into layers. This criterion is often the conceptual distance from the platform. Sometimes you encounter other abstraction paradigms, for example the degree of customization for specific domains, or the degree of conceptual complexity. For example, a chess game application may consist of the following layers, listed from bottom to top:

In American Football these levels may correspond respectively to linebacker, blitz, a sequence of plays for a two-minute drill, and finally a full game plan.

In the real world of software development we often use a mix of abstraction criterions. For example, the distance from the hardware can shape the lower levels, and conceptual complexity governs the higher ones. An example layering obtained using a mixed-mode layering principle like this is as follows, ordered from top to bottom:

2. Determine the number of abstraction levels according to your abstraction criterion. Each abstraction level corresponds to one layer of the pattern. Sometimes this mapping from abstraction levels to layers is not obvious. Think about the trade-offs when deciding whether to split particular aspects into two layers or combine them into one. Having too many layers may impose unnecessary overhead, while too few layers can result in a poor structure.

3. Name the layers and assign tasks to each of them. The task of the highest layer is the overall system task, as perceived by the client. The tasks of all other layers are to be helpers to higher layers. If we take a bottom-up approach, then lower layers provide an infrastructure on which higher layers can build. However, this approach requires considerable experience and foresight in the domain to find the right abstractions for the lower layers before being able to define specific requests from higher layers.

4. Specify the services. The most important implementation principle is that layers are strictly separated from each other, in the sense that no component may spread over more than one layer. Argument, return, and error types of functions offered by Layer J should be built-in types of the programming language, types defined in Layer J, or types taken from a shared data definition module. Note that modules that are shared between layers relax the principles of strict layering.

It is often better to locate more services in higher layers than in lower layers. This is because developers should not have to learn a large set of slightly different low-level primitives - which may even change during concurrent development. Instead the base layers should be kept "slim" while higher layers can expand to cover a broader spectrum of applicability. This phenomenon is also called the "inverted pyramid of reuse".

5. Refine the layering. Iterate over steps 1 to 4. It is usually not possible to define an abstraction criterion precisely before thinking about the implied layers and their services. Alternatively, it is usually wrong to define components and services first and later impose a layered structure on them according to their usage relationships. Since such a structure does not capture an inherent ordering principle, it is very likely that system maintenance will destroy the architecture. For example, a new component may ask for the services of more than one other layer, violating the principle of strict layering.

The solution is to perform the first four steps several times until a natural and stable layering evolves. "Like almost all other kinds of design, finding layers does not proceed in an orderly, logical way, but consists of both top-down and bottom-up steps, and a certain amount of inspiration..." [Joh95]. Performing both top-down and bottom-up steps alternately is often called "yo-yo" development, mentioned at the start of the this section.

6. Specify an interface for each layer. If Layer J should be a "black box" for Layer J+1, design a "flat interface" that offers all of Layer J's services, and perhaps encapsulate this interface in a Facade object [GHJV95]. A flat interface is an API of function specifications that does not betray a hint of internal components and relationships. A "white-box" approach is that in which Layer J+1 sees the internals of Layer J. And a "gray-box" approach provides a compromise - layer J+1 is aware of the fact that Layer J consists of some number of components, and addresses them separately, but does not see the internal workings of individual components.

Good design practise tells us to use the black-box approach whenever possible, because it supports system evolution better than other approaches. Exceptions to this rule can be made for reasons of efficiency, or a need to access the innards of another layer. The latter occurs rarely, and may be helped by the Reflection pattern [p193], which supports more controlled access to the internal functioning of a component. Arguments over efficiency are debatable, especially when inlining can simply do away with a thin layer of indirection.

7. Structure individual layers. Traditionally, the focus was on the proper relationships between layers, but inside individual layers there was often free-wheeling chaos. When an individual layer is complex it should be broken into separate components. This subdivision can be helped by using finer-grained patterns. For example, you can use the Bridge pattern [GHJV95] to support multiple implementations of services provided by a layer. The Strategy pattern [GHJV95] can support the dynamic exchange of algorithms used by a layer.

8. Specify the communication between adjacent layers. The most often used mechanism for inter-layer communication is the push model. When Layer J invokes a service of Layer J-1, any required information is passed as part of the service call. The reverse is known as the pull model and occurs when the lower layer fetches available information from the higher layer at its own discretion. The Publisher-Subscriber [p339] and Pipes and Filters patterns [p53] give details about push and pull model information transfer. However, such models may introduce additional dependencies between a layer and its adjacent higher layer. If you want to avoid dependencies of lower layers on higher layers introduced by the pull model, use callbacks, as described in the next step.

9. Decouple adjacent layers. There are many ways to do this. Often an upper layer is aware of the next lower layer, but the lower layer is unaware of the identity of its users. This implies a one-way coupling only: changes in Layer J can ignore the presence and identity of Layer J+1 provided that the interface and semantics of the Layer J services being changed remain stable. Such a one-way coupling is perfect when requests travel top-down and return values are sufficient to transport the results in the reverse direction.

For bottom-up communication, you can use callbacks and still preserve a top-down one-way coupling. Here the upper layer registers callback functions with the lower layer. This is especially effective when only a fixed set of possible events is sent from lower to higher layers. During start-up the higher layer tells the lower layer what functions to call when specific events occur. The lower layer maintains the mapping from events to callback functions in a registry. The Reactor pattern [Sch94] illustrates an object-oriented implementation of the use of callbacks in conjunction with event demultiplexing. The Command pattern [GHJV95] shows how to encapsulate functions into first-class objects.

You can also decouple the upper layer from the lower layer to a certain degree. Here is an example of how this can be done using object-oriented techniques. The upper layer is decoupled from specific implementation variants of the lower layer by coding the upper layer against an interface (or pure abstract base class). The lower-level implementations can then be easily exchanged, even at run-time. A Layer 2 requestor talks to a Layer 1 provider but does not know which implementation of Layer 1 it is talking to.

For communicating stacks of layers where messages travel both up and down, it is often better explicitly to connect lower levels to higher levels. We therefore again introduce base classes, for example classes L1Provider, L2Provider, and L3Provider, and additionally L1Parent, L2Parent, and L1Peer. Class L1Parent provides the interface by which Layer 1 classes access the next higher layer, for example to return results, send confirmations, or pass data streams. An analogous argument holds for L2Parent. L1Peer provides the interface by which a message is sent to the layer 1 peer module in another protocol stack in another process or machine. A Layer 1 implementation class therefore inherits from two base classes: L1Provider and L1Peer. A second-level implementation class inherits from L2Provider an L1Parent, as it offers the services of Layer 2 and can serve as the parent of a Layer 1 object. A third-level implementation class finally inherits from L3Provider and L2Parent.

If your programming language separates inheritance and subtyping at the language level, as Java [AG96] does, the above base classes can be transformed into interfaces by pushing data into subclasses and implementing all methods there.

10. Design an error-handling strategy. Error handling can be rather expensive for layered architectures with respect to processing time and, notably, programming effort. An error can either be handled in the layer where it occurred, or be passed to the next higher layer. In the latter case, the lower layer must transform the error into an error description meaningful to the higher layer. As a rule of thumb, try to handle errors at the lowest layer possible. This prevents higher layers from being swamped with many different errors and voluminous error-handling code. At a minimum, try to condense similar error types into more general error types, and only propagate these more general errors. If you do not do this, higher layers can be confronted with error messages that apply to lower-level abstractions that the higher layer does not understand. And who hasn't seen totally cryptic error messages being popped up to the highest layer of all - the user?

Variants

Relaxed layered architecture. Each layer may use the services of all (or some of the) layers below it. The gain in flexibility and performance in this approach is paid for by loss of maintainability. This is often a high price to pay, and you should consider carefully before giving in to the demands of developers asking for shortcuts.

Virtual machines. We can speak of each lower layer as a virtual machine that insulates its higher layers from low-level details, or varying hardware. For example, Java is predicated on the Java Virtual Machine (JVM). Java code can "write once, run anywhere" because it relies on the illusion of a universal platform created by a plethora of JVMs - each one platform-specific in its implementation, but perfectly common in its interface.

C++ Demo