다음을 통해 공유


1 - Introduction

patterns & practices Developer Center

On this page: Download:
Motivations | Maintainability | Testability | Flexibility and Extensibility | Late Binding | Parallel Development | Crosscutting Concerns | Loose Coupling | A Simple Example | When Should You Use a Loosely Coupled Design? | Principles of Object-Oriented Design | Single Responsibility Principle | The Open/Closed Principle | The Liskov Substitution Principle | Interface Segregation Principle | Dependency Inversion Principle | Summary

Download code

Download PDF File

Order Paperback

Before you learn about dependency injection and Unity, you need to understand why you should use them. And in order to understand why you should use them, you should understand what types of problems dependency injection and Unity are designed to help you address. This introductory chapter will not say much about Unity, or indeed say much about dependency injection, but it will provide some necessary background information that will help you to appreciate the benefits of dependency injection as a technique and why Unity does things the way it does.

The next chapter, Chapter 2, "Dependency Injection," will show you how dependency injection can help you meet the requirements outlined in this chapter, and the following chapter, Chapter 3, "Dependency Injection with Unity," shows how Unity helps you to implement the dependency injection approach in your applications.

Dn178470.note(en-us,PandP.30).gifMarkus says:
Markus This chapter introduces a lot of requirements and principles. Don’t assume that they are all relevant all of the time. However, most enterprise systems have some of the requirements, and the principles all point towards good design and coding practices.

Motivations

When you design and develop software systems, there are many requirements to take into account. Some will be specific to the system in question and some will be more general in purpose. You can categorize some requirements as functional requirements, and some as non-functional requirements (or quality attributes). The full set of requirements will vary for every different system. The set of requirements outlined below are common requirements, especially for line-of-business (LOB) software systems with relatively long anticipated lifetimes. They are not all necessarily going to be important for every system you develop, but you can be sure that some of them will be on the list of requirements for many of the projects you work on.

Maintainability

As systems become larger, and as the expected lifetimes of systems get longer, maintaining those systems becomes more and more of a challenge. Very often, the original team members who developed the system are no longer available, or no longer remember the details of the system. Documentation may be out of date or even lost. At the same time, the business may be demanding swift action to meet some pressing new business need. Maintainability is the quality of a software system that determines how easily and how efficiently you can update it. You may need to update a system if a defect is discovered that must be fixed (in other words, performing corrective maintenance), if some change in the operating environment requires you to make a change in the system, or if you need to add new features to the system to meet a business requirement (perfective maintenance). Maintainable systems enhance the agility of the organization and reduce costs.

Dn178470.note(en-us,PandP.30).gifJana says:
Jana It is very hard to make existing systems more maintainable. It is much better to design for maintainability from the very start.

Therefore, you should include maintainability as one of your design goals, along with others such as reliability, security, and scalability.

Testability

A testable system is one that enables you to effectively test individual parts of the system. Designing and writing effective tests can be just as challenging as designing and writing testable application code, especially as systems become larger and more complex. Methodologies such as test-driven development (TDD) require you to write a unit test before writing any code to implement a new feature and the goal of such a design technique is to improve the quality of your application. Such design techniques also help to extend the coverage of your unit tests, reduce the likelihood of regressions, and make refactoring easier. However, as part of your testing processes you should also incorporate other types of tests such as acceptance tests, integration tests, performance tests, and stress tests.

Running tests can also cost money and be time consuming because of the requirement to test in a realistic environment. For example, for some types of testing on a cloud-based application you need to deploy the application to the cloud environment and run the tests in the cloud. If you use TDD, it may be impractical to run all the tests in the cloud all of the time because of the time it takes to deploy your application, even to a local emulator. In this type of scenario, you may decide to use test doubles (simple stubs or verifiable mocks) that replace the real components in the cloud environment with test implementations in order to enable you to run your suite of unit tests in isolation during the standard TDD development cycle.

Dn178470.note(en-us,PandP.30).gifPoe says:
Poe Using test doubles is a great way to ensure that you can continuously run your unit tests during the development process. However, you must still fully test your application in a real environment.
Dn178470.note(en-us,PandP.30).gifJana says:
Jana For a great discussion on the use of test doubles, see the point/counterpoint debate by Steve Freeman, Nat Pryce and Joshua Kerievsky in IEEE Software (Volume: 24, Issue: 3), May/June 2007, pp.80-83.

Testability should be another of the design goals for your system along with maintainability and agility: a testable system is typically more maintainable, and vice versa.

Flexibility and Extensibility

Flexibility and extensibility are also often on the list of desirable attributes of enterprise applications. Given that business requirements often change, both during the development of an application and after it is running in production, you should try to design the application to make it flexible so that it can be adapted to work in different ways and extensible so that you can add new features. For example, you may need to convert your application from running on-premises to running in the cloud.

Late Binding

In some application scenarios, you may have a requirement to support late binding. Late binding is useful if you require the ability to replace part of your system without recompiling. For example, your application might support multiple relational databases with a separate module for each supported database type. You can use declarative configuration to tell the application to use a specific module at runtime. Another scenario where late binding can be useful is to enable users of the system to provide their own customization through a plug-in. Again, you can instruct the system to use a specific customization by using a configuration setting or a convention where the system scans a particular location on the file system for modules to use.

Dn178470.note(en-us,PandP.30).gifJana says:
Jana Not all systems have a requirement for late binding. It is typically required to support a specific feature of the application such as customization using a plug-in architecture.

Parallel Development

When you are developing large scale (or even small and medium scale) systems, it is not practical to have the entire development team working simultaneously on the same feature or component. In reality, you will assign different features and components to smaller groups to work on in parallel. Although this approach enables you to reduce the overall duration of the project, it does introduce additional complexities: you need to manage multiple groups and to ensure that you can integrate the parts of the application developed by different groups to work correctly together.

Dn178470.note(en-us,PandP.30).gifCarlos says:
Carlos It can be a significant challenge to ensure that classes and components developed independently do work together.

Crosscutting Concerns

Enterprise applications typically need to address a range of crosscutting concerns such as validation, exception handling, and logging. You may need these features in many different areas of the application and you will want to implement them in a standard, consistent way to improve the maintainability of the system. Ideally, you want a mechanism that will enable you to efficiently and transparently add behaviors to your objects at either design time or run time without requiring you make changes to your existing classes. Often, you need the ability to configure these features at runtime and in some cases, add features to address a new crosscutting concern to an existing application.

Dn178470.note(en-us,PandP.30).gifPoe says:
Poe For a large enterprise system, it’s important to be able to manage crosscutting concerns such as logging and validation in a consistent manner. I often need to change the logging level on a specific component at run time to troubleshoot an issue without restarting the system.

Loose Coupling

You can address many of the requirements listed in the previous sections by ensuring that your design results in an application that loosely couples the many parts that make up the application. Loose coupling, as opposed to tight coupling, means reducing the number of dependencies between the components that make up your system. This makes it easier and safer to make changes in one area of the system because each part of the system is largely independent of the other.

Dn178470.note(en-us,PandP.30).gifJana says:
Jana Loose coupling should be a general design goal for your enterprise applications.

A Simple Example

The following example illustrates tight coupling where the ManagementController class depends directly on the TenantStore class. These classes might be in different Visual Studio projects.

public class TenantStore
{
  ...

  public Tenant GetTenant(string tenant)
  {
    ...
  }

  public IEnumerable<string> GetTenantNames()
  {
    ...
  }
}

public class ManagementController
{
  private readonly TenantStore tenantStore;

  public ManagementController()
  {
    tenantStore = new TenantStore(...);
  }

  public ActionResult Index()
  {
    var model = new TenantPageViewData<IEnumerable<string>>
      (this.tenantStore.GetTenantNames())
    {
      Title = "Subscribers"
    };
    return this.View(model);
  }

  
  public ActionResult Detail(string tenant)
  {
    var contentModel = this.tenantStore.GetTenant(tenant);
    var model = new TenantPageViewData<Tenant>(contentModel)
    {
      Title = string.Format("{0} details", contentModel.Name)
    };
    return this.View(model);
  }

  ...
}

Note

The ManagementController and TenantStore classes are used in various forms throughout this guide. Although the ManagementController class is an ASP.NET MVC controller, you don’t need to know about MVC to follow along. However, these examples are intended to look like the kinds of classes you would encounter in a real-world system, especially the examples in Chapter 3.

In this example, the TenantStore class implements a repository that handles access to an underlying data store such as a relational database, and the ManagementController is an MVC controller class that requests data from the repository. Note that the ManagementController class must either instantiate a TenantStore object or obtain a reference to a TenantStore object from somewhere else before it can invoke the GetTenant and GetTenantNames methods. The ManagementController class depends on the specific, concrete TenantStore class.

If you refer back to the list of common desirable requirements for enterprise applications at the start of this chapter, you can evaluate how well the approach outlined in the previous code sample helps you to meet them.

  • Although this simple example shows only a single client class of the TenantStore class, in practice there may be many client classes in your application that use the TenantStore class. If you assume that each client class is responsible for instantiating or locating a TenantStore object at runtime, then all of those classes are tied to a particular constructor or initialization method in that TenantStore class, and may all need to be changed if the implementation of the TenantStore class changes. This potentially makes maintenance of the TenantStore class more complex, more error prone, and more time consuming.
  • In order to run unit tests on the Index and Detail methods in the ManagementController class, you need to instantiate a TenantStore object and make sure that the underlying data store contains the appropriate test data for the test. This complicates the testing process, and depending on the data store you are using, may make running the test more time consuming because you must create and populate the data store with the correct data. It also makes the tests much more brittle.
  • It is possible to change the implementation of the TenantStore class to use a different data store, for example Windows Azure table storage instead of SQL Server. However, it might require some changes to the client classes that use TenantStore instances if it was necessary for them to provide some initialization data such as connection strings.
  • You cannot use late binding with this approach because the client classes are compiled to use the TenantStore class directly.
  • If you need to add support for a crosscutting concern such as logging to multiple store classes, including the TenantStore class, you would need to modify and configure each of your store classes independently.

The following code sample shows a small change, the constructor in the client ManagementController class now receives an object that implements the ITenantStore interface and the TenantStore class provides an implementation of the same interface.

public interface ITenantStore
{
  void Initialize();
  Tenant GetTenant(string tenant);
  IEnumerable<string> GetTenantNames();
  void SaveTenant(Tenant tenant);
  void UploadLogo(string tenant, byte[] logo);
}

public class TenantStore : ITenantStore
{
  ...

  public TenantStore()
  {
    ...
  }

  ...
}

public class ManagementController : Controller
{
  private readonly ITenantStore tenantStore;

  public ManagementController(ITenantStore tenantStore)
  {
    this.tenantStore = tenantStore;
  }

  public ActionResult Index()
  {
    ...
  }
  
  public ActionResult Detail(string tenant)
  {
    ...
  }

  ...
}

This change has a direct impact on how easily you can meet the list of requirements.

  • It is now clear that the ManagementController class, and any other clients of the TenantStore class are no longer responsible for instantiating TenantStore objects, although the example code shown doesn't show which class or component is responsible for instantiating them. From the perspective of maintenance, this responsibility could now belong to a single class rather than many.
  • It’s now also clear what dependencies the controller has from its constructor arguments instead of being buried inside of the controller method implementations.
  • To test some behaviors of a client class such as the ManagementController class, you can now provide a lightweight implementation of the ITenantStore interface that returns some sample data. This is instead of creating a TenantStore object that queries the underlying data store for sample data.
  • Introducing the ITenantStore interface makes it easier to replace the store implementation without requiring changes in the client classes because all they expect is an object that implements the interface. If the interface is in a separate project to the implementation, then the projects that contain the client classes only need to hold a reference to the project that contains the interface definition.
  • It is now also possible that the class responsible for instantiating the store classes could provide additional services to the application. It could control the lifetime of the ITenantStore instances that it creates, for example creating a new object every time the client ManagementController class needs an instance, or maintaining a single instance that it passes as a reference whenever a client class needs it.
  • It is now possible to use late binding because the client classes only reference the ITenantStore interface type. The application can create an object that implements the interface at runtime, perhaps based on a configuration setting, and pass that object to the client classes. For example, the application might create either a SQLTenantStore instance or a BlobTenantStore instance depending on a setting in the web.config file, and pass that to the constructor in the ManagementController class.
  • If the interface definition is agreed, two teams could work in parallel on the store class and the controller class.
  • The class that is responsible for creating the store class instances could now add support for the crosscutting concerns before passing the store instance on to the clients, such as by using the decorator pattern to pass in an object that implements the crosscutting concerns. You don't need to change either the client classes or the store class to add support for crosscutting concerns such as logging or exception handling.

The approach shown in the second code sample is an example of a loosely coupled design that uses interfaces. If we can remove a direct dependency between classes, it reduces the level of coupling and helps to increase the maintainability, testability, flexibility, and extensibility of the solution.

Dn178470.note(en-us,PandP.30).gifJana says:
Jana Loose coupling doesn’t necessarily imply dependency injection, although the two often do go together.

What the second code sample doesn't show is how dependency injection and the Unity container fit into the picture, although you can probably guess that they will be responsible for creating instances and passing them to client classes. Chapter 2 describes the role of dependency injection as a technique to support loosely coupled designs, and Chapter 3 describes how Unity helps you to implement dependency injection in your applications.

When Should You Use a Loosely Coupled Design?

Before we move on to dependency injection and Unity, you should start to understand where in your application you should consider introducing loose coupling, programming to interfaces, and reducing dependencies between classes. The first requirement we described in the previous section was maintainability, and this often gives a good indication of when and where to consider reducing the coupling in the application. Typically, the larger and more complex the application, the more difficult it becomes to maintain, and so the more likely these techniques will be helpful. This is true regardless of the type of application: it could be a desktop application, a web application, or a cloud application.

At first sight, this perhaps seems counterintuitive. The second example shown above introduced an interface that wasn't in the first example, it also requires the bits we haven't shown yet that are responsible for instantiating and managing objects on behalf of the client classes. With a small example, these techniques appear to add to the complexity of the solution, but as the application becomes larger and more complex, this overhead becomes less and less significant.

Dn178470.note(en-us,PandP.30).gifCarlos says:
Carlos Small examples of loosely coupled design, programming to interfaces, and dependency injection often appear to complicate the solution. You should remember that these techniques are intended to help you simplify and manage large and complex applications with many classes and dependencies. Of course small applications can often grow into large and complex applications.

The previous example also illustrates another general point about where it is appropriate to use these techniques. Most likely, the ManagementController class exists in the user interface layer in the application, and the TenantStore class is part of the data access layer. It is a common approach to design an application so that in the future it is possible to replace one tier without disturbing the others. For example, replacing or adding a new UI to the application (such as creating an app for a mobile platform in addition to a traditional web UI) without changing the data tier or replacing the underlying storage mechanism and without changing the UI tier. Building the application using tiers helps to decouple parts of the application from each other. You should try to identify the parts of an application that are likely to change in the future and then decouple them from the rest of the application in order to minimize and localize the impact of those changes.

The list of requirements in the previous section also includes crosscutting concerns that you might need to apply across a range of classes in your application in a consistent manner. Examples include the concerns addressed by the application blocks in Enterprise Library (https://msdn.microsoft.com/entlib) such as logging, exception handling, validation, and transient fault handling. Here you need to identify those classes where you might need to address these crosscutting concerns, so that responsibility for adding these features to these classes resides outside of the classes themselves. This helps you to manage these features consistently in the application and introduces a clear separation of concerns.

Principles of Object-Oriented Design

Finally, before moving on to dependency injection and Unity, we want to relate the five SOLID principles of object-oriented programming and design to the discussion so far. SOLID is an acronym that refers to the following principles:

  • Single responsibility principle
  • Open/close principle
  • Liskov substitution principle
  • Interface segregation principle
  • Dependency inversion principle

The following sections describe each of these principles and their relationship to loose coupling and the requirements listed at the start of this chapter.

Single Responsibility Principle

The single responsibility principle states that a class should have one, and only one, reason to change. For more information, see the article Principles of Object Oriented Design by Robert C. Martin (Principles of OOD).

In the first simple example shown in this chapter, the ManagementController class had two responsibilities: to act as a controller in the UI and to instantiate and manage the lifetime of TenantStore objects. In the second example, the responsibility for instantiating and managing TenantStore objects lies with another class or component in the system.

The Open/Closed Principle

The open/closed principle states that "software entities (classes, modules, functions, and so on) should be open for extension, but closed for modification" (Meyer, Bertrand (1988). Object-Oriented Software Construction.)

Although you might modify the code in a class to fix a defect, you should extend a class if you want to add any new behavior to it. This helps to keep the code maintainable and testable because existing behavior should not change, and any new behavior exists in new classes. The requirement to be able to add support for crosscutting concerns to your application can best be met by following the open/closed principle. For example, when you add logging to a set of classes in your application, you shouldn’t make changes to the implementation of your existing classes.

The Liskov Substitution Principle

The Liskov substitution principle in object-oriented programming states that in a computer program, if S is a subtype of T, then objects of type T may be replaced with objects of type S without altering any of the desirable properties, such as correctness, of that program.

In the second code sample shown in this chapter, the ManagementController class should continue to work as expected if you pass any implementation of the ITenantStore interface to it. This example uses an interface type as the type to pass to the constructor of the ManagementController class, but you could equally well use an abstract type.

Interface Segregation Principle

The interface segregation principle is a software development principle intended to make software more maintainable. The interface segregation principle encourages loose coupling and therefore makes a system easier to refactor, change, and redeploy. The principle states that interfaces that are very large should be split into smaller and more specific ones so that client classes only need to know about the methods that they use: no client class should be forced to depend on methods it does not use.

In the definition of the ITenantStore interface shown earlier in this chapter, if you determined that not all client classes use the UploadLogo method you should consider splitting this into a separate interface as shown in the following code sample:

public interface ITenantStore
{
  void Initialize();
  Tenant GetTenant(string tenant);
  IEnumerable<string> GetTenantNames();
  void SaveTenant(Tenant tenant);
}

public interface ITenantStoreLogo
{
  void UploadLogo(string tenant, byte[] logo);
}


public class TenantStore : ITenantStore, ITenantStoreLogo
{
  ...

  public TenantStore()
  {
    ...
  }

  ...
}

Dependency Inversion Principle

The dependency inversion principle states that:

  • High-level modules should not depend on low-level modules. Both should depend on abstractions.
  • Abstractions should not depend upon details. Details should depend upon abstractions.

The two code samples in this chapter illustrate how to apply this principle. In the first sample, the high-level ManagementController class depends on the low-level TenantStore class. This typically limits the options for re-using the high-level class in another context.

In the second code sample, the ManagementController class now has a dependency on the ITenantStore abstraction, as does the TenantStore class.

Summary

In this chapter, you have seen how you can address some of the common requirements in enterprise applications such as maintainability and testability by adopting a loosely coupled design for your application. You saw a very simple illustration of this in the code samples that show two different ways that you can implement the dependency between the ManagementController and TenantStore classes. You also saw how the SOLID principles of object-oriented programming relate to the same concerns.

However, the discussion in this chapter left open the question of how to instantiate and manage TenantStore objects if the ManagementController is no longer responsible for this task. The next chapter will show how dependency injection relates to this specific question and how adopting a dependency injection approach can help you meet the requirements and adhere to the principles outlined in this chapter.

Next Topic | Previous Topic | Home | Community