When preparing a steak, the key technique is to let the meat rest before slicing. During the resting period, juices redistribute throughout the fibers, resulting in a more flavorful and tender cut. Conversely, cutting too early causes all the juices to escape, leaving the meat dry and disappointing. Understanding both best practices and their problematic counterparts is equally valuable in software development.
Recognizing the distinction between sound practices and problematic ones forms the foundation of meaningful learning. This is precisely why the previous chapter examined examples of tightly coupled code and analyzed their weaknesses. Understanding the "why" behind these issues provides essential context for improvement.
In summary, loose coupling delivers numerous advantages including late binding, scalability, maintainability, testability, and parallel development capabilities. Tight coupling strips away these benefits. While not all tight coupling scenarios are problematic, excessive coupling with volatile dependencies should be avoided. Dependency injection offers solutions to these challenges. Since DI represents a fundamentally different approach from Mary's original application methodology, we won't modify her existing code. Instead, we'll rebuild the system from the ground up.
Note This decision shouldn't be interpreted as impossibility to refactor existing applications toward DI, nor should it suggest rewriting applications from scratch is the only path. Large-scale rewrites carry substantial costs and risks. Incremental, gradual refactoring is typically the safer approach, though it remains challenging in practice.
Let's briefly revisit Mary's original application structure and outline our reconstruction strategy along with anticipated outcomes.
Rebuilding the E-Commerce System
Analysis of Mary's application in Chapter 2 revealed that volatile dependencies created tight coupling across application layers. As the dependency diagram illustrates, both the domain layer and UI layer depended directly on the data access layer.
Our objective in this chapter involves inverting the dependency relationship between the domain layer and data access layer. This means the data access layer will depend on the domain layer rather than the domain layer depending on data access, as demonstrated in the target architecture diagram.
By creating this inversion, we gain the ability to swap out the data access implementation without rewriting the entire application. We'll apply several design patterns during this process, including constructor injection discussed in Chapter 1. We'll also employ method injection and composition root patterns, which we'll explore in depth.
This approach increases the number of classes when focusing on separation of concerns. Where Mary defined four classes, we'll create nine classes and three interfaces. The detailed class and interface structure will be examined as we progress through the implementation.
The sequence diagram illustrates how the primary application classes will interact. We'll revisit this diagram with more detail at the chapter's conclusion.
When building software, we prefer starting from the most visible area for stakeholders. Following Mary's e-commerce example, the UI serves as a natural starting point. From there, we add functionality incrementally until each feature is complete before moving forward. This outside-in approach helps maintain focus on required functionality while avoiding over-engineering.
Note The outside-in approach aligns closely with the YAGNI principle—"You Aren't Gonna Need It." This principle advocates implementing only required features in their simplest possible form.
Since we practice test-driven development (TDD), the outside-in method prompts us to write unit tests immediately after creating each new class. While tests were written to develop this example, TDD isn't a prerequisite for DI implementation, so tests won't be displayed in this book. Source code accompanying this book contains the complete test suite. Let's begin with the project setup, starting from the user interface layer.
Building a Maintainable User Interface
Mary's specification for the featured products list involved an application that retrieves items from a database and displays them. Since stakeholders primarily care about visual results, the UI represents an ideal starting point.
After launching Visual Studio, the first step involves adding a new ASP.NET Core MVC application to the solution. Since the featured products list appears on the homepage, you need to modify the Index.cshtml file with appropriate markup.
The following code shows the updated view structure:
@model FeaturedProductsViewModel
<h2>Featured Products</h2>
<div>
@foreach (ProductViewModel product in this.Model.Products)
{
<div>@product.SummaryText</div>
}
</div>
Compare this with Mary's original implementation:
<h2>Featured Products</h2>
<div>
@{
var products = (IEnumerable<Product>)this.ViewData["Products"];
foreach (Product product in products)
{
<div>@product.Name (@product.UnitPrice.ToString("C"))</div>
}
}
</div>
The first enhancement eliminates the need to cast dictionary entries to a product collection before iteration. Using MVC's @model directive accomplishes this directly. The page's Model property becomes strongly typed to FeaturedProductsViewModel, with MVC ensuring the controller's return value conforms to this type. Second, the display string derives entirely from ProductViewModel.SummaryText, eliminating formatting logic from the view.
Both improvements stem from introducing view-specific models that encapsulate view behavior. These models are plain old CLR objects (POCOs). The following structure outlines their organization:
public class FeaturedProductsViewModel
{
public FeaturedProductsViewModel(
IEnumerable<ProductViewModel> products)
{
this.Products = products;
}
public IEnumerable<ProductViewModel> Products { get; }
}
public class ProductViewModel
{
private static CultureInfo PriceCulture = new CultureInfo("en-US");
public ProductViewModel(string name, decimal unitPrice)
{
this.SummaryText = string.Format(PriceCulture,"{0} ({1:C})", name, unitPrice);
}
public string SummaryText { get; }
}
View models simplify the view significantly, which is valuable since views are difficult to test. This also makes it easier for UI designers to work on the application.
Note Did you notice the bug in Mary's original markup? While the
UnitPrice.ToString("C")call formats the decimal as currency, it bases this on the user's browser-provided culture preference. This means visitors from the US see a dollar sign, while Danish visitors see Danish Krone. If both currencies shared equivalent value, this wouldn't be problematic, but they don't. This would cause Danish visitors to purchase products at a fraction of the intended price. This explains whyProductViewModelexplicitly specifies culture information.
For the code in the first listing to function properly, HomeController must return a view containing a FeaturedProductsViewModel instance. The initial implementation within HomeController looks like this:
public ViewResult Index()
{
var vm = new FeaturedProductsViewModel(new[]
{
new ProductViewModel("Chocolate", 34.95m),
new ProductViewModel("Asparagus", 39.80m)
});
return this.View(vm);
}
Hardcoding the featured products list in the Index method isn't ideal, but it enables the web application to function correctly and allows demonstrating an incomplete but operational stub to stakeholders for feedback.
Important From a DI perspective, POCOs, DTOs, and view models like
FeaturedProductsViewModelandProductViewModeldon't present interesting behavior to intercept, replace, or mock. They're simple data containers. Safely creating instances directly in code carries no coupling risk. These objects carry runtime data that flows through the system long after classes likeHomeControllerandProductServiceare instantiated.
At this stage, only the UI layer stub is implemented. Complete domain and data access layers remain pending. Beginning from the UI offers the advantage of having runnable, testable software immediately. In contrast, Mary's progress at a comparable stage required reaching a much later point before running the application.
To fulfill its obligations and perform meaningful work, HomeController needs a list of featured products with discounts from the domain layer. In Chapter 2, Mary encapsulated this logic in ProductService, and we'll follow a similar pattern.
The Index method on HomeController should retrieve the featured products list using a ProductService instance, transform items to ProductViewModel instances, and wrap them in FeaturedProductsViewModel. However, from HomeController's perspective, ProductService represents a volatile dependency—something not yet implemented while development continues. Introducing a seam becomes necessary for isolated testing, parallel development, or future replacement.
Based on analysis of Mary's implementation, volatile dependencies constitute a primary concern. Direct coupling to such dependencies creates rigidity. To prevent tight coupling, we introduce an interface and employ constructor injection. How instances are created and by whom becomes irrelevant to HomeController.
public class HomeController : Controller
{
private readonly IProductService productService;
public HomeController(IProductService productService)
{
if (productService == null)
throw new ArgumentNullException("productService");
this.productService = productService;
}
public ViewResult Index()
{
IEnumerable<DiscountedProduct> products =
this.productService.GetFeaturedProducts();
var vm = new FeaturedProductsViewModel(
from product in products
select new ProductViewModel(product));
return this.View(vm);
}
}
As established in Chapter 1, constructor injection statically defines required dependencies by specifying them as constructor parameters. This is precisely what HomeController accomplishes—it declares its dependencies in the public constructor.
When first encountering constructor injection, understanding its true benefits proves challenging. Doesn't this simply push dependency management responsibility onto other classes? Indeed it does, and that's the point. In n-tier applications, this responsibility pushes upward toward the composition root.
Composition Root
As discussed in Section 1.4.1, we aim to compose classes into applications similar to connecting electrical appliances. This modularity level is achieved by centralizing class creation in a single location. We call this location the composition root.
The composition root sits as close to the application entry point as possible. In most .NET Core application types, the entry point is the Main method. Within the composition root, you can choose to manually compose the application using Pure DI or delegate this to a DI container. We'll explore the composition root in detail in Chapter 4.
Adding a parameterized constructor to HomeController makes it impossible to instantiate without providing the required dependency—this is intentional. However, the application's main screen breaks because MVC doesn't know how to construct our HomeController.
In reality, HomeController instantiation isn't the UI layer's concern. This responsibility belongs to the composition root. We consider the UI layer complete for now, returning later to address HomeController creation.
Building an Independent Domain Model
The domain model is implemented as a standard C# class library containing POCOs and interfaces. POCOs model the domain, while interfaces provide abstractions serving as external entry points. These contracts define how the domain model interacts with the upcoming data access layer.
The HomeController from the previous section won't compile because IProductService hasn't been defined yet. We'll add a new domain layer project to the solution and reference it from the MVC project, though we'll defer detailed dependency analysis until Section 3.2 to provide complete information. The following interface shows the IProductService abstraction:
public interface IProductService
{
IEnumerable<DiscountedProduct> GetFeaturedProducts();
}
IProductService represents the core of our current domain layer, connecting the UI layer with the data access layer—the adhesive binding the application together.
The interface's sole member is GetFeaturedProducts, returning a collection of DiscountedProduct instances. Each DiscountedProduct contains a name and unit price. This simple POCO provides sufficient definition to compile the Visual Studio solution:
public class DiscountedProduct
{
public DiscountedProduct(string name, decimal unitPrice)
{
if (name == null) throw new ArgumentNullException("name");
this.Name = name;
this.UnitPrice = unitPrice;
}
public string Name { get; }
public decimal UnitPrice { get; }
}
Programming to interfaces rather than concrete classes forms the cornerstone of DI. This principle enables swapping one implementation for another. Before proceeding, we should recognize the role interfaces play in this discussion.
Important Programming to interfaces doesn't mean every class should implement one. Hiding POCOs, DTOs, and view models behind interfaces rarely makes sense since they contain no behavior requiring mocking, interception, or replacement. Because
DiscountedProduct,FeaturedProductsViewModel, andProductViewModelare (view) models, they don't implement interfaces. We'll examine whether to use interfaces versus abstract classes later.
Next, we'll implement ProductService. Its GetFeaturedProducts method should use an IProductRepository instance to retrieve featured products, apply discounts, and return DiscountedProduct instances. The repository pattern provides a common abstraction over data access, so we'll define IProductRepository within the domain model library.
public interface IProductRepository
{
IEnumerable<Product> GetFeaturedProducts();
}
IProductRepository acts as the data access layer's interface, returning raw entities from persistent storage. In contrast, IProductService applies business logic like discounting and transforms entities into narrower objects. A mature repository would contain additional product lookup and modification methods, but following the outside-in principle, we define only classes and members needed for current tasks. Adding functionality later is easier than removing it.
Entity
An entity is a domain-driven design term covering domain objects possessing long-term identity unrelated to specific object instances. While this might sound abstract and theoretical, entities represent objects existing independently of any particular .NET object instance. Any .NET object instance has a memory address (identity), but entities have identity persisting throughout the process lifetime.
We commonly use databases and primary keys to identify entities, ensuring they can be persisted and retrieved even across host restarts. The domain object
Productis an entity because products have longer lifespans than individual procedures, and we identify them using product IDs withinIProductRepository.
Since our goal involves inverting the dependency between domain and data access layers, IProductRepository is defined within the domain layer. In the next section, we'll create its implementation as part of the data access layer, with dependencies pointing toward the domain layer.
Note By having
ProductServicedepend onIProductRepository, we enable behavior replacement or interception. By placing this behavior in a separate library, we enable complete library replacement.
The Product class is implemented with minimal members:
public class Product
{
public string Name { get; set; }
public decimal UnitPrice { get; set; }
public bool IsFeatured { get; set; }
public DiscountedProduct ApplyDiscountFor(IUserContext user)
{
bool preferred = user.IsInRole(Role.PreferredCustomer);
decimal discount = preferred ? .95m : 1.00m;
return new DiscountedProduct(
name: this.Name,
unitPrice: this.UnitPrice * discount);
}
}
ProductService.GetFeaturedProducts should use IProductRepository to retrieve featured products, apply discounts, and return DiscountedProduct instances. This class corresponds to Mary's similarly named class but now functions as a pure domain model since it contains no hardcoded data access references. Like HomeController, we employ constructor injection to relinquish control over volatile dependencies:
public class ProductService : IProductService
{
private readonly IProductRepository repository;
private readonly IUserContext userContext;
public ProductService(IProductRepository repository, IUserContext userContext)
{
if (repository == null)
throw new ArgumentNullException("repository");
if (userContext == null)
throw new ArgumentNullException("userContext");
this.repository = repository;
this.userContext = userContext;
}
public IEnumerable<DiscountedProduct> GetFeaturedProducts()
{
return
from product in this.repository.GetFeaturedProducts()
select product.ApplyDiscountFor(this.userContext);
}
}
Beyond IProductRepository, the ProductService constructor also requires an IUserContext instance:
public interface IUserContext
{
bool IsInRole(Role role);
}
public enum Role { PreferredCustomer }
This differs from Mary's implementation, which passed a simple bool parameter to GetFeaturedProducts indicating whether the user was a preferred customer. Since determining preferred customer status belongs to the domain layer, explicitly modeling it as a dependency proves more correct. Furthermore, information about the user making the request is contextual—we shouldn't burden every controller with gathering this information. This would create duplication, introduce bugs, and potentially cause security vulnerabilities.
Rather than having the UI layer provide this information to the domain layer, we allow its retrieval as an implementation detail of ProductService. The IUserContext interface lets ProductService retrieve current user information without HomeController knowing which roles grant discounted pricing. HomeController also can't accidentally enable discounts by passing true instead of false. This reduces overall UI layer complexity.
Tip To minimize system complexity, runtime data describing contextual information is best hidden behind abstractions and injected into consumers requiring it. Contextual information represents metadata about the current request—information users shouldn't directly influence, like user identity (established during login) and the system's current time.
While the .NET Base Class Library (BCL) includes IPrincipal for user modeling, this interface is generic rather than tailored to our application's specific needs. Instead, we let the application define its own abstractions.
ProductService.GetFeaturedProducts passes the IUserContext dependency to Product.ApplyDiscountFor. This technique is called method injection. Method injection proves particularly useful when transient objects like entities need dependencies. We'll explore this pattern in detail in Chapter 4.
At this stage, the application cannot run. Three issues remain:
- No concrete
IProductRepositoryimplementation exists. In the next section, we'll implementSqlProductRepositoryreading featured products from a database. - No concrete
IUserContextimplementation exists. We'll address this aswell. - The MVC framework doesn't know which concrete types to use when constructing
HomeControllerwith itsIProductServiceabstraction parameter. Multiple solutions exist, but our preference involves developing a customMicrosoft.AspNetCore.Mvc.Controllers.IControllerActivator. This topic exceeds this chapter's scope and will be covered in Chapter 7. The custom factory will create concreteProductServiceinstances and supply them toHomeController's constructor.
In the domain layer, we use only domain-layer-defined types and stable .NET BCL dependencies. The domain layer concept manifests as POCOs representing the domain. At this stage, this represents a single concept: the product. The domain layer must communicate with external systems like databases—this need is modeled as abstractions (like repositories) requiring concrete implementations before the domain layer becomes useful.
We've successfully made the domain model compile. This means we created a domain model independent from the data access layer we still need to build. Before proceeding, let's examine some key points.
The Dependency Inversion Principle
Much of what we accomplish with DI relates to the Dependency Inversion Principle. This principle states that high-level modules in an application shouldn't depend on low-level modules; instead, both should depend on abstractions.
This is precisely what we did when defining IProductRepository. ProductService belongs to the higher-level domain layer module, while IProductRepository's implementation (which we'll call SqlProductRepository) belongs to the lower-level data access module. Rather than having ProductService depend on SqlProductRepository, we make both ProductService and SqlProductRepository depend on the IProductRepository abstraction. SqlProductRepository implements the abstraction, while ProductService uses it.
The relationship between the Dependency Inversion Principle and DI is: DIP specifies what we want to accomplish, while DI explains how to accomplish it. The principle doesn't describe how consumers obtain their dependencies. However, many developers miss another interesting aspect of DIP.
The principle prescribes loose coupling but also states that abstractions should belong to the modules using them. Here, "owned" means the consuming module controls the abstraction's definition and distribution rather than the implementing module. The consuming module should define abstractions in whichever way best serves its needs.
We've done this twice: IUserContext and IProductRepository. While their implementations belong to the UI and data access layers respectively, their designs best suit the domain layer.
This approach prevents high-level modules from depending on lower-level modules and simplifies them since abstractions are tailored to their specific requirements. This returns us to the BCL's IPrincipal interface. As described, IPrincipal is generic. The Dependency Inversion Principle instead guides us toward defining abstractions matching the application's special needs. This explains why we defined our own IUserContext rather than making the domain layer depend on IPrincipal. This does require creating an adapter implementation bridging our application-specific IUserContext to framework calls.
Does the IProductService interface in the domain layer violate the Dependency Inversion Principle? After all, IProductService is used by the UI layer but implemented by the domain layer. The answer is yes—this does violate the principle.
Strictly resolving this violation would require moving IProductService out of the domain layer. However, moving IProductService to the UI layer would make the domain layer depend on that layer. Since the domain layer is the application's core, we don't want it depending on anything else. Additionally, this dependency would prevent future UI replacement.
Solving this violation completely would require two additional projects—one for an isolated UI layer without the composition root, and another for the UI-layer-owned IProductService abstraction. For pragmatic reasons, we choose not to pursue this path in this example, accepting the violation. Hopefully, you understand our desire to avoid unnecessary complexity.
Interfaces or Abstract Classes?
Many object-oriented design guidelines promote interfaces as the primary abstraction mechanism, while .NET Framework design guidelines favor abstract classes over interfaces. Which should you use? Regarding DI, the safe answer is: it doesn't matter. What matters is programming to an abstraction.
Context matters elsewhere—in interfaces versus abstract classes, but not here. You'll notice we use the terms interchangeably, frequently using "abstraction" to encompass both. When writing applications, we typically prefer interfaces because:
- Abstract classes easily become base class abuses. Base classes readily transform into ever-growing god objects. Derived classes become tightly coupled to base classes, which becomes problematic when base classes contain volatile behavior. Interfaces force a "composition over inheritance" mindset.
- Concrete classes implement multiple interfaces, although in .NET, they can only derive from a single base class. Using interfaces provides greater flexibility.
- Interface definitions in C# are less verbose than abstract classes. With interfaces, we omit abstract and public keywords from members, making interfaces more concise.
However, when writing reusable libraries requiring backward compatibility, the topic becomes less clear. Under such circumstances, abstract classes may make more sense since non-abstract members can be added later, while adding members to interfaces represents a breaking change. This explains the .NET Framework design guidelines' preference for abstract classes.
Reusable Libraries
Reusable libraries in the .NET ecosystem typically distribute through NuGet. A key characteristic is unknown clients at compile time. This differs from projects reusable within the same Visual Studio solution. While your solution might contain a domain layer project reused across multiple projects, this doesn't make it a reusable library.
External libraries prove harder to change since thousands of consuming codebases might depend on them, none accessible to library designers. Such reusable libraries can't be tested against their consuming codebases.
Now we reach the data access layer, implementing IProductRepository.
Building a New Data Access Layer
Like Mary, we use Entity Framework Core for the data access layer, following her approach from Chapter 2 for creating entity models. The primary difference is CommerceContext is now an implementation detail of the data access layer rather than its entirety.
In this model, nothing outside the data access layer knows about or depends on Entity Framework. It can be swapped without upstream effects. With this in mind, we implement IProductRepository:
public class SqlProductRepository : IProductRepository
{
private readonly CommerceContext context;
public SqlProductRepository(CommerceContext context)
{
if (context == null) throw new ArgumentNullException("context");
this.context = context;
}
public IEnumerable<Product> GetFeaturedProducts()
{
return
from product in this.context.Products
where product.IsFeatured
select product;
}
}
In Mary's application, though the product entity existed in the data access layer, it also served as the domain object. This is no longer the case. The Product class now belongs to our domain layer. Our data access layer reuses that layer's Product class.
For simplicity, we chose reuse over defining separate persistence objects. This works because Entity Framework Core allows writing persistence-agnostic entities. Whether this is reasonable depends on domain object structure and complexity. If we later conclude the shared model imposes unwanted constraints, the data access layer could introduce internal persistence objects without touching the rest of the application. In that case, the data access layer would transform these internal persistence objects into domain objects.
In the previous chapter, we discussed how Mary's CommerceContext's implicit dependency on connection strings caused problems. Our new CommerceContext makes this dependency explicit:
public class CommerceContext : DbContext
{
private readonly string connectionString;
public CommerceContext(string connectionString)
{
if (string.IsNullOrWhiteSpace(connectionString))
throw new ArgumentException(
"connectionString should not be empty.",
"connectionString");
this.connectionString = connectionString;
}
public DbSet<Product> Products { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder builder)
{
builder.UseSqlServer(this.connectionString);
}
}
This nearly completes our e-commerce application reimplementation. The sole missing piece is an IUserContext implementation.
Implementing an ASP.NET Core-Specific IUserContext Adapter
The final missing concrete implementation is IUserContext. In web applications, information about the requesting user typically accompanies each request to the server via cookies or HTTP headers. How we retrieve the current user's identity depends heavily on the framework being used, meaning building an ASP.NET Core application requires a completely different implementation than, say, a Windows service.
The IUserContext implementation is framework-specific. We don't want the domain or data layers knowing anything about the application framework, as this would prevent using these layers in different contexts. The UI layer becomes the ideal location for implementing IUserContext.
The following shows a possible IUserContext implementation for ASP.NET Core:
public class AspNetUserContextAdapter : IUserContext
{
private static HttpContextAccessor Accessor = new HttpContextAccessor();
public bool IsInRole(Role role)
{
return Accessor.HttpContext.User.IsInRole(role.ToString());
}
}
AspNetUserContextAdapter requires HttpContextAccessor to function. HttpContextAccessor is an ASP.NET Core framework component enabling access to the current request's HttpContext, similar to how HttpContext.Current worked in "classic" ASP.NET. We use HttpContext to access current user request information.
AspNetUserContextAdapter adapts our application-specific IUserContext abstraction to the ASP.NET Core API. This class exemplifies the adapter design pattern discussed in Section 1.13.
Adapter Design Pattern
The adapter design pattern belongs to the structural patterns category, focusing on how classes and objects compose into larger structures. Other patterns in this category include composite, decorator, facade, and proxy. Like an electrical adapter, the adapter pattern transforms one interface into what clients expect, enabling incompatible interfaces to work together.
An adapter implementation is typically straightforward, though complex transformations aren't uncommon. The key insight is that this complexity remains hidden from clients.
Composing the Application in the Composition Root
With ProductService, SqlProductRepository, and AspNetUserContextAdapter available, we can configure ASP.NET Core MVC to construct HomeController instances supplied with ProductService, which itself is constructed using SqlProductRepository and AspNetUserContextAdapter. This produces the following object graph:
new HomeController(
new ProductService(
new SqlProductRepository(
new CommerceContext(connectionString)),
new AspNetUserContextAdapter()));
Definition In object-oriented applications, groups of objects form networks through their relationships—either direct references to other objects or through a series of intermediate references. These object groups are called object graphs.
In Chapter 7, we'll discuss embedding this object graph structure into the ASP.NET Core framework in detail. With everything properly connected, browsing to the application's homepage displays the completed interface.
Analyzing the Loosely Coupled Implementation
The previous section contained substantial detail. If you lost sight of the big picture, that's understandable. This section provides broader explanation of what's happening.
Understanding Component Interactions
Classes in each layer interact either directly or through abstractions. They operate across module boundaries, making their interactions difficult to visualize. The dependency diagram illustrates how different components interact, providing a more detailed view than earlier schematics.
When the application starts, code in Startup creates a new custom controller activator using the application's connection string from configuration. When page requests arrive, the application invokes Create on this activator.
The activator provides the stored connection string to a new CommerceContext instance (not shown in the diagram). It injects this CommerceContext into a new SqlProductRepository instance. In turn, this SqlProductRepository instance and the AspNetUserContextAdapter instance (not shown) inject together into a new ProductService instance. Similarly, ProductService injects into a new HomeController instance, which the Create method returns.
The ASP.NET Core MVC framework then invokes the Index method on the HomeController instance, causing it to call GetFeaturedProducts on the ProductService instance. This calls GetFeaturedProducts on the SqlProductRepository instance. Finally, a ViewResult with a populated FeaturedProductsViewModel returns, and MVC locates and renders the appropriate view.
Analyzing the New Dependency Graph
In Section 2.2, you learned how dependency graphs help analyze and understand the flexibility an architecture provides. Did DI change the application's dependency graph?
The dependency graph certainly changed. The domain model has no dependencies and functions as an independent module. The data access layer now has dependencies—where Mary's application had none.
The most critical observation: the domain layer has no dependencies. This enables much better answers to questions about composability:
- Can we replace the web-based UI with a WPF UI? This was previously possible, and the new design still supports it. Neither the domain model library nor data access library depends on the web-based UI, so we can easily place something else in its position.
- Can we replace the relational data access layer with one using Azure Table Service? In the next chapter, we'll describe how the application locates and instantiates the correct
IProductRepository. For now, know that the data access layer loads via late binding, with type names defined in application configuration settings. As long as anIProductRepositoryimplementation is available, the current data access layer can be discarded and replaced.
Regarding DI Containers
A DI container is a software library providing DI functionality and automating tasks related to object composition, interception, and lifecycle management. DI containers are also called Inversion of Control (IoC) containers. We've only lightly touched on DI containers so far—this is intentional. As mentioned in Chapter 1, DI containers are useful but optional tools. We've postponed detailed DI container discussion until Part 4 because we believe teaching DI composition principles, patterns, code smells, and anti-patterns is more important. We build applications with and without DI containers, and you should be able to do the same. However, using a DI container without understanding Part 2 and Part 3 would be counterproductive. After learning principles and practices, using a DI container primarily involves learning its API. At that point, understanding what a DI container is and how it helps becomes important.
When this book's first edition released, we used DI containers in all built applications. Though we knew DI could be applied without containers, we thought this impractical. Our thinking has evolved, which is why we now emphasize patterns and techniques underlying DI.
While solving application infrastructure doesn't add business value, using a general-purpose library sometimes makes sense—this is no different from implementing logging or data access. Aggregating application data is best solved by general-purpose logging libraries. Composing object graphs works similarly. In Part 4, we'll discuss when to use DI containers and when to avoid them.
Don't expect DI containers to magically transform tightly coupled code into loosely coupled code. DI containers can make your composition root more maintainable, but the application must first be designed with DI patterns and techniques for it to become maintainable. Using a DI container neither guarantees nor requires correct DI.
The example e-commerce application demonstrates only limited complexity: a single repository in a read-only scenario. We've kept the application as simple and small as possible to gently introduce core concepts. Since one of DI's primary purposes is managing complexity, we need a more complex application to fully understand its capabilities. Throughout this book, we'll expand the example e-commerce application to comprehensively demonstrate DI aspects.
Does DI Make Me Lose the Big Picture?
Developers new to DI commonly complain about losing sight of application structure—it's unclear who's calling whom. While using DI is absolutely correct, we've removed this knowledge from individual classes. Listing 3.13 demonstrates we don't have to lose this information entirely. That listing shows Pure DI. With Pure DI, the composition root typically contains this information coherently. Better yet, it shows the complete object graph rather than just a class's immediate dependencies, which tight coupling obscures.
Moving from Pure DI to a DI container might cost you this overview since DI containers use reflection at runtime to create object graphs, compared to programming language specification at compile time. However, when applications are well-designed, we find this loss ceases being an issue. We experience that as application maintainability improves, the navigation between classes and their dependencies decreases.
Nevertheless, this difference between Pure DI and DI containers warrants consideration because it might influence your choice between them. Section 12.3 details when to use DI containers versus Pure DI.
This chapter concludes Part 1 of this book. Part 1's purpose was positioning DI on the map and generally introducing DI. In this chapter, you've seen constructor injection examples. We also introduced method injection and composition root as DI-related patterns. In the next chapter, we'll examine these and other design patterns more deeply.
Key Takeaways
- Refactoring existing applications toward view model usage improves maintainability, but significant rewrites are riskier and more expensive.
- View models simplify views since incoming data is specifically designed for them. Because views are difficult to test, making them leaner is beneficial. This also aids UI designers working on the application.
- Limiting volatile dependencies in the domain layer yields greater decoupling, reusability, and testability.
- The outside-in approach during application construction aids faster prototyping, shortening feedback cycles.
- Achieving high modularity requires applying constructor injection and building object graphs in a composition root positioned near the application entry point.
- Programming to interfaces is DI's cornerstone. It enables dependency replacement, mocking, and interception without changing consumers. When implementations and abstractions reside in different assemblies, entire libraries become swappable.
- Programming to interfaces doesn't mean all classes should implement one. Transient objects like entities, view models, and DTOs typically don't contain behavior requiring mocking, interception, decoration, or replacement.
- For DI purposes, whether using interfaces or abstract classes doesn't matter. From a general development perspective, we typically prefer interfaces over abstract classes.
- A reusable library is one where clients remain unknown at compile time. Reusable libraries typically ship via NuGet. Libraries with callers only within the same Visual Studio solution aren't considered reusable.
- DI relates closely to the Dependency Inversion Principle. This principle means programming to interfaces, with layers controlling the interfaces they use.
- DI containers improve composition root maintainability but don't magically transform tight coupling into loose coupling. Applications must be designed with DI patterns and techniques for maintainability.