Architecture and Design Guidelines
J.D. Meier, Alex Homer, David Hill, Jason Taylor, Prashant Bansode, Lonnie Wall, Rob Boucher Jr, Akshay Bogawat
- Understand the fundamental concepts of software architecture.
- Learn the key design principles for software architecture.
- Learn the guidelines for key areas of software architecture.
Software architecture is often described as the organization or structure of a system, while the system represents a collection of components that accomplish a specific function or set of functions. In other words, architecture is focused on organizing components to support specific functionality. This organization of functionality is often referred to as grouping components into “areas of concern.” Figure 1 illustrates common application architecture with components grouped by different areas of concern. Figure 1 Common application architecture
In addition to the grouping of components, other areas of concern focus on interaction between the components and how different components work together. The guidelines in this chapter examine different areas of concern that you should consider when designing the architecture of your application.
Key Design Principles
When getting started with your design, bear in mind the key principles that will help you to create architecture that meets “best practices,” minimizes costs and maintenance requirements, and promotes usability and extendibility. The key principles are:
- Separation of concerns. Break your application into distinct features that overlap in functionality as little as possible.
- Single Responsibility Principle. Each component or a module should be responsible for only a specific feature or functionality.
- Principle of least knowledge.** A component or an object should not know about internal details of other components or objects. Also known as the Law of Demeter** (LoD).
- Don’t Repeat Yourself (DRY). There should be only one component providing a specific functionality; the functionality should not be duplicated in any other component.
- Avoid doing a big design upfront. If your application requirements are unclear, or if there is a possibility of the design evolving over time, avoid making a large design effort prematurely. This design principle is often abbreviated as BDUF.
- Prefer composition over inheritance. Wherever possible, use composition over inheritance when reusing functionality because inheritance increases the dependency between parent and child classes, thereby limiting the reuse of child classes.
When designing an application or system, the goal of a software architect is to minimize the complexity by separating the design into different areas of concern. For example, the user interface (UI), business processing, and data access all represent different areas of concern. Within each area, the components you design should focus on that specific area and should not mix code from other areas of concern. For example, UI processing components should not include code that directly accesses a data source, but instead should use either business components or data access components to retrieve data.
Follow these guidelines when designing an application:
- Avoid doing all your design upfront. If you are not clear with requirements or if there is the possibility of design evolution, it might be a good idea not to do complete design upfront. Instead, evolve the design as you progress through the project.
- Separate the areas of concern.** Break your application into distinct features that overlap in functionality as little as possible. The main benefit of this approach is that a feature or functionality can be optimized independently of other features or functionality. Also, if one feature fails, it will not cause other features to fail as well, and they can run independently of one another. This approach also helps to make the application easier to understand and design, and facilitates management of complex interdependent systems.
- Each component or module should have a single responsibility.** Each component or module should be responsible for only one specific feature or functionality. This makes your components cohesive and makes it easier to optimize the components if a specific feature or functionality changes.
- A component or an object should not rely on internal details of other components or objects.** Each component or object should** call a method of another object or component, and that method should have information about how to process the request and, if needed, route it to appropriate subcomponents or other components. This helps in developing an application that is more maintainable and adaptable.
- Do not duplicate functionality within an application. There should be only one component providing a specific functionality—this functionality should not be duplicated in any other component. Duplication of functionality within an application can make it difficult to implement changes, decrease clarity, and introduce potential inconsistencies.
- Identify the kinds of components you will need in your application. The best way to do this is to identify patterns that match your scenario and then examine the types of components that are used by the pattern or patterns that match your scenario. For example, a smaller application may not need business workflow or UI processing components.
- Group different types of components into logical layers.** Start by identifying different areas of concern, and then group components associated with each area of concern into logical layers.
- Keep design patterns consistent within each layer.** Within a logical layer, the design of components should be consistent for a particular operation. For example, if you choose to use the Table Data Gateway pattern to create an object that acts as a gateway to tables or views in a database, you should not include another pattern such as Repository, which uses a different paradigm for accessing data and initializing business entities.
- Do not mix different types of components in the same logical layer. For example, the UI layer should not contain business-processing components, but instead should contain components used to handle user input and process user requests.
- Determine the type of layering you want to enforce. In a strict layering system, components in layer A cannot call components in layer C; they always call components in layer B. In a more relaxed layering system, components in a layer can call components in other layers that are not immediately below it. In all cases, you should avoid upstream calls and dependencies.
- Use abstraction to implement loose coupling between layers. This can be accomplished by defining interface components such as a façade with well-known inputs and outputs that translate requests into a format understood by components within the layer. In addition, you can also use Interface types or abstract base classes to define a common interface or shared abstraction (dependency inversion) that must be implemented by interface components.
- Do not overload the functionality of a component. For example, a UI processing component should not contain data access code. A common anti-pattern named Blob is often found with base classes that attempt to provide too much functionality. A Blob object will often have hundreds of functions and properties providing business functionality mixed with cross-cutting functionality such as logging and exception handling. The large size is caused by trying to handle different variations of child functionality requirements, which requires complex initialization. The end result is a design that is very error-prone and difficult to maintain.
- Understand how components will communicate with each other. This requires an understanding of the deployment scenarios your application will need to support. You need to determine if communication across physical boundaries or process boundaries should be supported, or if all components will run within the same process.
- Prefer composition over inheritance. Wherever possible, use composition over inheritance when reusing functionality because inheritance increases the dependency between parent and child classes, thereby limiting the reuse of child classes. This also reduces the inheritance hierarchies, which can become very difficult to deal with.
- Keep the data format consistent within a layer or component. Mixing data formats will make the application more difficult to implement, extend, and maintain. Every time you need to convert data from one format to another, you are required to implement translation code to perform the operation.
- Keep cross-cutting code abstracted from the application business logic as much as possible. Cross-cutting code refers to code related to security, communications, or operational management such as logging and instrumentation. Attempting to mix this code with business logic can lead to a design that is difficult to extend and maintain. Changes to the cross-cutting code would require touching all of the business logic code that is mixed with the cross-cutting code. Consider using frameworks that can help to implement the cross-cutting concerns.
- Be consistent in the naming conventions used. Check to see if naming standards have been established by the organization. If not, you should establish common standards that will be used for naming. This provides a consistent model that makes it easier for team members to evaluate code they did not write, which leads to better maintainability.
- Establish the standards that should be used for exception handling. For example, you should always catch exceptions at layer boundaries, you should not catch exceptions within a layer unless you can handle them in that layer, and you should not use exceptions to implement business logic. The standards should also include policies for error notification, logging, and instrumentation when there is an exception.
The following table lists the key areas to consider as you develop your architecture. Refer to the key issues in the table to understand where mistakes are most often made. The sections following this table provide guidelines for each of these areas.Table 1 Architecture Frame
|Area ||Key issues|
|Authentication and Authorization ||Lack of authentication across trust boundaries |
| ||Lack of authorization across trust boundaries |
| ||Granular or improper authorization |
|Caching ||Caching data that is volatile |
| ||Caching sensitive data |
| ||Incorrect choice of caching store |
|Communication ||Incorrect choice of transport protocol |
| ||Chatty communication across physical and process boundaries |
| ||Failure to protect sensitive data |
|Composition ||Cooperating application modules coupled by dependencies making development, testing, and maintenance more difficult |
| ||Dependency changes between modules, forcing code recompilation and module redeployment |
| ||Difficulties in dynamic UI layout and update due to hard-coded dependencies |
| ||Difficulty in dynamic module loading due to hard-coded dependencies |
|Concurrency and Transactions ||Not protecting concurrent access to static data |
| ||Deadlocks caused by improper locking |
| ||Not choosing the correct data concurrency model |
| ||Long-running transactions that hold locks on data |
| ||Using exclusive locks when not required |
|Configuration Management ||Lack of or incorrect configuration information |
| ||Not securing sensitive configuration information |
| ||Unrestricted access to configuration information |
|Coupling and Cohesion ||Incorrect grouping of functionality |
| ||No clear separation of concerns |
| ||Tight coupling across layers |
|Data Access ||Per-user authentication and authorization when not required |
| ||Chatty calls to the database |
| ||Business logic mixed with data access code |
|Exception Management ||Failing to an unstable state |
| ||Revealing sensitive information to the end user |
| ||Using exceptions to control application flow |
| ||Not logging sufficient details about the exception |
|Layering ||Incorrect grouping of components within a layer |
| ||Not following layering and dependency rules |
| ||Not considering the physical distribution of layers |
|Logging and Instrumentation ||Lack of logging and instrumentation |
| ||Logging and instrumentation that is too fine-grained |
| ||Not making logging and instrumentation an option that is configurable at run time |
| ||Not suppressing and handling logging failures |
| ||Not logging business-critical functionality |
|State Management ||Using an incorrect state store |
| ||Not considering serialization requirements |
| ||Not persisting state when required |
|Structure ||Choosing the incorrect structure for your scenario |
| ||Creating an overly complex structure when not required |
| ||Not considering deployment scenarios |
|User Experience ||Not following published guidelines |
| ||Not considering accessibility |
| ||Creating overloaded interfaces with unrelated functionality |
|Validation ||Lack of validation across trust boundaries |
| ||Failure to validate for range, type, format, and length |
| ||Not reusing validation logic |
|Workflow ||Not considering management requirements |
| ||Choosing an incorrect workflow pattern |
| ||Not considering exception states and how to handle them |
Designing a good authentication strategy is important for the security and reliability of your application. Failure to design and implement a good authentication strategy can leave your application vulnerable to spoofing attacks, dictionary attacks, session hijacking, and other types of attacks.
Consider the following guidelines when designing an authentication strategy:
- Identify your trust boundaries and authenticate users and calls across the trust boundaries. Consider that calls might need to be authenticated from the client as well as from the server (mutual authentication).
- If you have multiple systems within the application that use different user repositories, consider a single sign-on strategy.
- Do not store passwords in a database or data store as plain text. Instead, store a hash of the password.
- Enforce the use of strong passwords or password phrases.
- Do not transmit passwords over the wire in plain text.
Designing a good authorization strategy is important for the security and reliability of your application. Failure to design and implement a good authorization strategy can make your application vulnerable to information disclosure, data tampering, and elevation of privileges.
Consider the following guidelines when designing an authorization strategy:
- Identify your trust boundaries and authorize users and callers across the trust boundary.
- Protect resources by applying authorization to callers based on their identity, groups, or roles.
- Use role-based authorization for business decisions.
- Use resource-based authorization for system auditing.
- Use claims-based authorization when you need to support federated authorization based on a mixture of information such as identity, role, permissions, rights, and other factors.
Caching improves the performance and responsiveness of your application. However, a poorly designed caching strategy can degrade performance and responsiveness. You should use caching to optimize reference data lookups, avoid network round trips, and avoid unnecessary and duplicate processing. To implement caching, you must decide when to load the cache data. Try to load the cache asynchronously or by using a batch process to avoid client delays.
Consider following guidelines when designing a caching strategy:
- Do not cache volatile data.
- Consider using ready-to-use cache data when working with an in-memory cache. For example, use a specific object instead of caching raw database data.
- Do not cache sensitive data unless you encrypt it.
- If your application is deployed in Web farm, avoid using local caches that need to be synchronized; instead consider using a transactional resource manager such as Microsoft® SQL Server® or a product that supports distributed caching.
- Do not depend on data still being in your cache. It may have been removed.
Communication concerns the interaction between components across different boundary layers. The mechanism you choose depends on the deployment scenarios your application must support. When crossing physical boundaries, you should use message-based communication. When crossing logical boundaries, you should use object-based communication.
Consider the following guidelines when designing communication mechanisms:
- To reduce round trips and improve communication performance, design chunky interfaces that communicate less often but with more information in each communication.
- Use unmanaged code for communication across AppDomain boundaries.
- Consider using message-based communication when crossing process or physical boundaries.
- If your messages don’t need to be received in exact order and don’t have dependencies on each other, consider using asynchronous communication to unblock processing or UI threads.
- Consider using Microsoft Message Queuing (MSMQ) to queue messages for later delivery in case of system or network interruption or failure. MSMQ can perform transacted message delivery and supports reliable once-only delivery.
Composition is the process used to define how interface components in a UI are structured to provide a consistent look and feel for the application. One of the goals of UI design is to provide a consistent interface in order to avoid confusing users as they navigate through your application. This can be accomplished by using templates, such as a master page in ASP.NET, or by implementing one of many common design patterns.
Consider the following guidelines when designing for composition:
- Avoid using dynamic layouts because they can be difficult to load and maintain.
- Be careful with dependencies between components. Use abstraction patterns when possible to avoid issues with maintainability.
- Consider creating templates with placeholders. For example, use the Template View pattern to compose dynamic Web pages to ensure reuse and consistency.
- Consider composing views from reusable modular parts. For example, use the Composite View pattern to build a view from modular, atomic component parts.
Concurrency and Transactions
When designing for concurrency and transactions related to accessing a database, it is important to identify the concurrency model you want to use and determine how transactions will be managed. For database concurrency, you can choose between an optimistic model, where the last update applied is valid, or a pessimistic model, where updates can only be applied to the latest version. Transactions can be executed within the database, or they can be executed in the business layer of an application. Where you choose to implement transactions depends on your transactional requirements. Concurrency should also be considered when accessing static data within the application or when using threads to perform asynchronous operations. Static data is not thread-safe, which means that changes made in one thread will affect other threads using the same data.
Consider the following guidelines when designing for concurrency and transactions:
- If you have business-critical operations, consider wrapping them in transactions.
- Use connection-based transactions when accessing a single data source.
- Use Transaction Scope (System.Transaction) to manage transactions that span multiple data sources.
- Where you cannot use transactions, implement compensating methods to revert the data store to its previous state.
- Avoid holding locks for long periods; for example, when using long-running atomic transactions.
- Updates to shared data should be mutually exclusive, which is accomplished by applying locks or by using thread synchronization. This will prevent two threads from attempting to update shared data at the same time.
- Use synchronization support provided by collections when working with static or shared collections.
Designing a good configuration-management mechanism is important for the security and flexibility of your application. Failure to do so can make your application vulnerable to a variety of attacks, and also leads to an administrative overhead for your application.
Consider the following guidelines when designing for configuration management:
- Consider using least-privileged process and service accounts.
- Categorize the configuration items into logical sections if your application has multiple tiers.
- If your server application runs in a Web farm, decide which parts of the configuration are shares and which parts are specific to the machine on which the application is running. Then choose an appropriate configuration store for each section.
- Encrypt sensitive information in your configuration store.
- Restrict access to your configuration information.
- Provide a separate administrative UI for editing configuration information.
Coupling and Cohesion
When designing components for your application, you should ensure that these components are highly cohesive, and that loose coupling is used across layers. Coupling
is concerned with dependencies and functionality. When one component is dependent upon another component, it is tightly coupled to that component. Functionality can be decoupled by separating different operations into unique components. Cohesion
concerns the functionality provided by a component. For example, a component that provides operations for validation, logging, and data access represents a component with very low cohesion. A component that provides operations for logging only represents high cohesion.
Consider the following guidelines when designing for coupling and cohesion:
- Partition application functionality into logical layers.
- Design for loose coupling between layers. Consider using abstraction to implement loose coupling between layers with interface components, common interface definitions, or shared abstraction. Shared abstraction is where concrete components depend on abstractions and not on other concrete components (the principle of dependency inversion).
- Design for high cohesion. Components should contain only functionality that is specifically related to that component.
- Know the benefits and overhead of loosely coupled interfaces. While loose coupling requires more code, the benefits include a shortened dependency chain and a simplified build process.
Designing an application to use a separate data access layer is important for maintainability and extensibility. The data access layer should be responsible for managing connections with the data source and for executing commands against the data source. Depending on your business entity design, the data access layer may have a dependency on business entities; however, the data access layer should never be aware of business processes or workflow components.
Consider the following guidelines when designing data access components:
- Avoid coupling your application model directly to the database schema. Instead, you should consider using an abstraction or mapping layer between the application model and database schema.
- Open connections as late as possible and release them as early as possible.
- Enforce data integrity in the database, not through data layer code.
- Move code that makes business decisions to the business layer.
- Avoid accessing the database directly from different layers in your application. Instead, all database interaction should be done through a data access layer.
Designing a good exception-management strategy is important for the security and reliability of your application. Failure to do so can make your application vulnerable to Denial of Service (DoS) attacks, and may also reveal sensitive and critical information. Raising and handling exceptions is an expensive process. It is important that the design also takes into account the performance considerations. A good approach is to design a centralized exception-management and logging mechanism, and to consider providing access points within your exception-management system to support instrumentation and centralized monitoring that assists system administrators.
Consider the following guidelines when designing an exception-management strategy:
- Do not catch internal exceptions unless you can handle them or need to add more information.
- Do not reveal sensitive information in exception messages and log files.
- Design an appropriate exception propagation strategy.
- Design a strategy for dealing with unhandled exceptions.
- Design an appropriate logging and notification strategy for critical errors and exceptions.
The use of layers in a design allows you to separate functionality into different areas of concern. In other words, layers represent the logical grouping of components within the design. You should also define guidelines for communication between layers. For example, layer A can access layer B, but layer B cannot access layer A.
Consider the following guidelines when designing layers:
- Layers should represent a logical grouping of components. For example, use separate layers for UI, business logic, and data access components.
- Components within a layer should be cohesive. In other words, the business layer components should provide only operations related to application business logic.
- When designing the interface for each layer, consider physical boundaries. If communication crosses a physical boundary to interact with the layer, use message-based operations. If communication does not cross a physical boundary, use object-based operations.
- Consider using an Interface type to define the interface for each layer. This will allow you to create different implementations of that interface to improve testability.
- For Web applications, implement a message-based interface between the presentation and business layers, even when the layers are not separated by a physical boundary. A message-based interface is better suited to stateless Web operations, provides a façade to the business layer, and allows you to physically decouple the business tier from the presentation tier if this is required by security policies or in response to a security audit.
Logging and Instrumentation
Designing a good logging and instrumentation strategy is important for the security and reliability of your application. Failure to do so can make your application vulnerable to repudiation threats, where users deny their actions. Log files may be required for legal proceedings to prove the wrongdoing of individuals. You should audit and log activity across the layers of your application. Using logs, you can detect suspicious activity, which can provide early indication of a serious attack. Generally, auditing is considered most authoritative if the audits are generated at the precise time of resource access, and by the same routines that access the resource. Instrumentation can be implemented by using performance counters and events to give administrators information about the state, performance, and health of an application.
Consider the following guidelines when designing a logging and instrumentation strategy:
- Centralize your logging and instrumentation mechanism.
- Design instrumentation within your application to detect system- and business-critical events.
- Consider how you will access and pass auditing and logging data across application layers.
- Create secure log file management policies. Protect log files from unauthorized viewing.
- Do not store sensitive information in the log files.
- Consider allowing your log sinks, or trace listeners, to be configurable so that they can be modified at run time to meet deployment environment requirements.
concerns the persistence of data that represents the state of a component, operation, or step in a process. State data can be persisted by using different formats and stores. The design of a state-management mechanism can affect the performance of your application. You should only persist data that is required, and you must understand the options that are available for managing state.
Consider the following guidelines when designing a state management mechanism:
- Keep your state management as lean as possible; persist the minimum amount of data required to maintain state.
- Make sure that your state data is serializable if it needs to be persisted or shared across process and network boundaries.
- If you are building a Web application and performance is your primary concern, use an in-process state store such as ASP.NET session state variables.
- If you are building a Web application and you want your state to persist through ASP.NET restarts, use the ASP.NET session state service.
- If your application is deployed in Web farm, avoid using local state management stores that need to be synchronized. For example, consider using a remote session state service or the SQL Server state store.
Software architecture is often defined as being the structure or structures of an application. When defining these structures, the goal of a software architect is to minimize the complexity by separating items into areas of concern by using different levels of abstraction. You start by examining the highest level of abstraction while identifying different areas of concern. As the design evolves, you dive deeper into the levels, expanding the areas of concern, until all of the structures have been defined.
Consider the following guidelines when designing the application structure:
- Identify common patterns used to represent application structure, such as client/server and N-tier.
- Understand security requirements for the environment in which your application will be deployed. For example, many security policies require physical separation of presentation logic from business logic across different subnets.
- Consider scalability and reliability requirements for the application.
- Consider deployment scenarios for the application.
Designing for an effective user experience can be critical to the success of your application. If navigation is difficult, or users are directed to unexpected pages, the user experience can be negative.
Consider the following guidelines when designing for an effective user experience:
- Design for a consistent navigation experience. Use composite patterns for the look and feel, and controller patterns such as Model-View-Controller (MVC), Supervising Controller, and Passive View for UI processing.
- Design the interface so that each page or section is focused on a specific task.
- Consider breaking large pages with a lot of functionality into smaller pages.
- Design similar components to have consistent behavior across the application. For example, a grid used to display data should implement a consistent interface for paging and sorting the data.
- Consider using published UI guidelines. In many cases, an organization will have published guidelines to which you should adhere.
Designing an effective validation mechanism is important for the security and reliability of your application. Failure to do so can make your application vulnerable to cross-site scripting, SQL injection, buffer overflow, and other types of malicious input attacks. However, there is no standard definition that can differentiate valid input from malicious input. In addition, how your application actually uses the input influences the risks associated with exploitation of the vulnerability.
Consider the following guidelines when designing a validation mechanism:
- Identify your trust boundaries, and validate all inputs across the trust boundaries.
- Centralize your validation approach, if it can be reused.
- Constrain, reject, and sanitize user input. In other words, assume that all user input is malicious.
- Validate input data for length, format, type, and range.
- Do not rely only on client-side validation for security checks. Instead, use client-side validation to give the user feedback and improve the user experience. Because client-side validation can be bypassed when attacking the server, use server-side validation to check for malicious input.
Workflow components are used when an application must execute a series of information-processing tasks that are dependent on the information content. The values that affect information-processing steps can be anything from data checked against business rules to human interaction and input. When designing workflow components, it is important to consider the options that are available for management of the workflow.
Consider the following guidelines when designing a workflow component:
- Determine management requirements. For example, if a business user needs to manage the workflow, you require a solution that provides an interface that the business user can understand.
- Determine how exceptions will be handled.
- Use service interfaces to interact with external workflow providers.
- If supported, use designers and metadata instead of code to define the workflow.
- With human workflow, consider the nondeterministic nature of users. In other words, you cannot determine when a task will be completed, or if it will be completed correctly.
Table 2 Pattern Map
|Category ||Relevant patterns|
|Caching ||Cache Dependency |
| ||Page Cache |
|Communication ||Intercepting Filter |
| ||Pipes and Filters |
| ||Service Interface |
|Concurrency and Transactions ||Capture Transaction Details |
| ||Optimistic Offline Lock |
| ||Pessimistic Offline Lock |
|Coupling and Cohesion ||Adapter |
| ||Dependency Injection |
|Data Access ||Active Record |
| ||Data Mapper |
| ||Query Object |
| ||Repository |
| ||Row Data Gateway |
| ||Table Data Gateway |
|Layering ||Façade |
| ||Layered Architecture |
- Active Record.** Include a data access object within a domain entity.
- Adapter.** An object that supports a common interface and translates operations between the common interface and other objects that implement similar functionality with different interfaces.
- Cache Dependency.** Use external information to determine the state of data stored in a cache.
- Capture Transaction Details.** Create database objects, such as triggers and shadow tables, to record changes to all tables belonging to the transaction.
- Data Mapper. Implement a mapping layer between objects and the database structure that is used to move data from one structure to another while keeping them independent.
- Dependency Injection. Use a base class or interface to define a shared abstraction that can be used to inject object instances into components that interact with the shared abstraction interface.
- Façade. Implement a unified interface to a set of operations to provide a simplified, reduced coupling between systems.
- Intercepting Filter. A chain of composable filters (independent modules) that implement common pre-processing and post-processing tasks during a Web page request.
- Optimistic Offline Lock.** Ensure that changes made by one session do not conflict with changes made by another session.
- Page Cache.** Improve the response time for dynamic Web pages that are accessed frequently, but that change less often and consume a large amount of system resources to construct.
- Pessimistic Offline Lock. Prevent conflicts by forcing a transaction to obtain a lock on data before using it.
- Pipes and Filters.** Route messages through pipes and filters that can modify or examine the message as it passes through the pipe.
- Query Object.** An object that represents a database query.
- Repository.** An in-memory representation of a data source that works with domain entities.
- Row Data Gateway. An object that acts as a gateway to a single record in a data source.
- Service Interface. A programmatic interface that other systems can use to interact with the service.
- Table Data Gateway. An object that acts as a gateway to a table in a data source.