In this chapter
There’s a big difference between cooking at home and working in a professional kitchen. At home, you can take all the time you want to prepare your dish, but in a commercial kitchen, efficiency is key. Mise en place is an important aspect of this. This is more than in-advance preparation of ingredients; it’s about having all the required equipment set up, including your pots, pans, chopping boards, tasting spoons, and anything that’s an essential part of your workspace.
The ergonomics and layout of the kitchen is also a major factor in the efficiency of a kitchen. A badly laid out kitchen can cause pinch points, high levels of disruption, and context switching for staff. Features like dedicated stations with associated specialized equipment help to minimize the movement of staff, avoid (unnecessary) multitasking, and encourage concentration on the task at hand. When this is done well, it helps to improve the efficiency of the kitchen as a whole.
In software development, the code base is our kitchen. Teams work together for years in the same kitchen, and the right architecture is essential to be efficient and consistent, keeping code repetition to a minimum. Your “guests” depend on your successful kitchen strategy.
One of the key architectural strategies you can use to improve your software ergonomics is Aspect-Oriented Programming (AOP). This can come in the form of equipment (tools) or a solid layout (software design). AOP is strongly related to Interception. To fully appreciate the potential of Interception, you must study the concept of AOP and software design principles like SOLID.
This chapter starts with an introduction to AOP. Because one of the most effective ways to apply AOP is through well-known design patterns and object-oriented principles, this chapter continues with a recap of the five SOLID principles, which were discussed in previous chapters throughout the book.
A common misconception is that AOP requires tooling. In this chapter, we’ll demonstrate that this isn’t the case: We’ll show how you can use SOLID software design as a driver of AOP and an enabler of an efficient, consistent, and maintainable code base. In the next chapter, we’ll discuss two well-known forms of AOP that require special tooling. Both forms, however, exhibit considerable disadvantages over the purely design-driven form of AOP discussed in this chapter.
If you’re already familiar with SOLID and the basics of AOP, you can jump directly into section 10.3, which contains the meat of this chapter. Otherwise, you can continue with our introduction to Aspect-Oriented Programming.
AOP was invented at the Xerox Palo Alto Research Center (PARC) in 1997, where Xerox engineers designed AspectJ, an AOP extension to the Java language. AOP is a paradigm that focuses around the notion of applying Cross-Cutting Concerns effectively and maintainably. It’s a fairly abstract concept that comes with its own set of jargon, most of which isn’t pertinent to this discussion.
The auditing and Circuit Breaker examples in sections 9.1.2 and 9.2.1 showed only a few representative methods, because all methods were implemented in the same way. We didn’t want to add several pages of nearly identical code to our discussion because it would’ve detracted from the point we were making.
The following listing shows the CircuitBreakerProductRepositoryDecorator
’s Delete
method again.
Listing 10.1 Delete
method of CircuitBreakerProductRepositoryDecorator
public void Delete(Product product)
{
this.breaker.Guard();
try
{
this.decoratee.Delete(product);
this.breaker.Succeed();
}
catch (Exception ex)
{
this.breaker.Trip(ex);
throw;
}
}
Listing 10.2 shows how similar the methods of CircuitBreakerProductRepositoryDecorator
are. This listing only shows the Insert
method, but we’re confident that you can extrapolate how the rest of the implementation would look.
Listing 10.2 Violating the DRY principle by duplicating Circuit Breaker logic
public void Insert(Product product)
{
this.breaker.Guard();
try
{
this.decoratee.Insert(product); ①
this.breaker.Succeed();
}
catch (Exception ex)
{
this.breaker.Trip(ex);
throw;
}
}
The purpose of this listing is to illustrate the repetitive nature of Decorators used as aspects in our current design. The only difference between the Delete
and Insert
methods is that they each invoke their own corresponding method on the decorated Repository.
Even though we’ve successfully delegated the Circuit Breaker implementation to a separate class via the ICircuitBreaker
interface, this plumbing code violates the DRY principle. It tends to be reasonably unchanging, but it’s still a liability. Every time you want to add a new member to a type you decorate, or when you want to apply a Circuit Breaker to a new Abstraction, you must apply the same plumbing code. This repetitiveness can become a problem if you want to maintain such an application.
Sticking with our auditing example from chapter 9, we’ve already established that you don’t want to put the auditing code inside the SqlProductRepository
implementation, because that would violate the Single Responsibility Principle (SRP). But neither do you want to have dozens of auditing Decorators for each Repository Abstraction in the system. This would also cause severe code duplication and, likely, sweeping changes, which is an Open/Closed Principle (OCP) violation. Instead, you want to declaratively state that you want to apply the auditing aspect to a certain set of methods of all Repository Abstractions in the system and implement this auditing aspect once.
You’ll find tools, frameworks, and architectural styles that enable AOP. In this chapter, we’ll discuss the most ideal form of AOP. The next chapter will discuss dynamic Interception and compile-time weaving as tool-based forms of AOP. These are the three major methods of AOP.1 Table 10.1 lists the methods we’ll discuss, with a few of the major advantages and disadvantages of each.
Method | Description | Advantages | Disadvantages |
SOLID | Applies aspects using Decorators around reusable Abstractions defined for groups of classes based on their behavior. |
|
|
Dynamic Interception | Causes the runtime generation of Decorators based on the application’s Abstractions. These Decorators are injected with tool-specific aspects, called Interceptors. |
|
|
Compile-time weaving | Aspects are added to an application in a post-compilation process. The most common form is IL weaving, where an external tool reads the compiled assembly, modifies it by applying the aspects, and replaces the original assembly with the modified one. |
|
|
As stated previously, we’ll get back to dynamic Interception and compile-time weaving in the next chapter. But before we dive into using SOLID as a driver for AOP, let’s start with a short recap of the SOLID principles.
You may have noticed a denser-than-usual usage of terms such as Single Responsibility Principle, Open/Closed Principle, and Liskov Substitution Principle in chapter 9 and in the previous section. Together with the Interface Segregation Principle (ISP) and Dependency Inversion Principle (DIP), they make up the SOLID acronym. We’ve discussed all five of them independently throughout the course of this book, but this section provides a short summary to refresh your mind, because understanding those principles is important for the remainder of this chapter.
All these patterns and principles are recognized as valuable guidance for writing clean code. The general purpose of this section is to relate this established guidance to DI, emphasizing that DI is only a means to an end. We, therefore, use DI as an enabler of maintainable code.
None of the principles encapsulated by SOLID represent absolutes. They’re guidelines that can help you write clean code. To us, they represent goals that help us decide which direction we should take our applications. We’re always happy when we succeed; but sometimes we don’t.
The following sections go through the SOLID principles and summarize what we’ve already explained about them throughout the course of this book. Each section is a brief overview — we omit examples in those sections. We’ll return to this in section 10.3, where we walk through a realistic example that shows why a violation of the SOLID principles can become problematic from a maintainability perspective. For now, we’ll recap the five SOLID principles.
In section 2.1.3, we described how the SRP states that every class should have a single reason to change. Violating this principle causes classes to become more complex and harder to test and maintain.
More often than not, however, it can be challenging to see whether a class has multiple reasons to change. What can help in this respect is looking at the SRP from the perspective of cohesion. Cohesion is defined as the functional relatedness of the elements of a class or module. The lower the amount of relatedness, the lower the cohesion; and the lower the cohesion, the greater the possibility a class violates the SRP. In section 10.3, we’ll discuss cohesion with a concrete example.
It can be difficult to stick to, but if you practice DI, one of the many benefits of Constructor Injection is that it becomes more obvious when you violate the SRP. In the auditing example in section 9.1.2, you were able to adhere to the SRP by separating responsibilities into separate types: SqlUserRepository
deals only with storing and retrieving product data, whereas AuditingUserRepositoryDecorator
concentrates on persisting the audit trail in the database. The AuditingUserRepositoryDecorator
class’s single responsibility is to coordinate the actions of IUserRepository
and IAuditTrailAppender
.
As we discussed in section 4.4.2, the OCP prescribes an application design that prevents you from having to make sweeping changes throughout the code base; or, in the vocabulary of the OCP, a class should be open for extension, but closed for modification. A developer should be able to extend the functionality of a system without needing to modify the source code of any existing classes.
Because they both try to prevent sweeping changes, there’s a strong relationship between the OCP principle and the Don’t Repeat Yourself (DRY) principle. OCP, however, focuses on code, whereas DRY focuses on knowledge.
You can make a class extensible in many ways, including virtual methods, injection of Strategies, and the application of Decorators.3 But no matter the details, DI makes this possible by enabling you to compose objects.
3 For more on Strategies, see Erich Gamma et al., Design Patterns: Elements of Reusable Object-Oriented Software (Addison-Wesley, 1994), 315.
In section 8.1.1, we described that all consumers of Dependencies should observe the LSP when they invoke their Dependencies ,because every Dependency should behave as defined by its Abstraction. This allows you to replace the originally intended implementation with another implementation of the same Abstraction, without worrying about breaking a consumer. Because a Decorator implements the same Abstraction as the class it wraps, you can replace the original with a Decorator, but only if that Decorator adheres to the contract given by its Abstraction.
This was exactly what we did in listing 9.3 when we substituted the original SqlUserRepository
with AuditingUserRepositoryDecorator
. You could do this without changing the code of the consuming ProductService
, because any implementation should adhere to the LSP. ProductService
requires an instance of IUserRepository
and, as long as it talks exclusively to that interface, any implementation will do.
The LSP is a foundation of DI. When consumers don’t observe it, there’s little advantage in injecting Dependencies, because you can’t replace them at will, and you’ll lose many (if not all) benefits of DI.
In section 6.2.1, you learned that the ISP promotes the use of fine-grained Abstractions, rather than wide Abstractions. Any time a consumer depends on an Abstraction where some of its members are unused, the ISP is violated.
The ISP can, at first, seem to be distantly related to DI, but that’s probably because we ignored this principle for most of this book. That’ll change in section 10.3, where you’ll learn that the ISP is crucial when it comes to effectively applying Aspect-Oriented Programming.
When we discussed the DIP in section 3.1.2, you learned that much of what we’re trying to accomplish with DI is related to the DIP. The principle states that you should program against Abstractions, and that the consuming layer should be in control of the shape of a consumed Abstraction. The consumer should be able to define the Abstraction in a way that benefits itself the most. If you find yourself adding members to an interface to satisfy the needs of other, specific implementations — including potential future implementations — then you’re almost certainly violating the DIP.
Design patterns (such as Decorator) and guidelines (such as SOLID principles) have been around for many years and are generally regarded as beneficial. In these sections, we provide an indication of how they relate to DI.
The SOLID principles have been relevant throughout the book’s chapters. But it’s when we start talking about Interception and how it relates to Decorators that the benefits of adhering to the SOLID principles stands out. Some are subtler than others, but adding behavior (such as auditing) by using a Decorator is a clear application of both the OCP and the SRP, the latter allowing us to create implementations with specifically defined scopes.
In the previous sections, we took a short detour through common patterns and principles to understand the relationship DI has with other established guidelines. Armed with this knowledge, let’s now turn our attention back to the goal of the chapter, which is to write clean and maintainable code in the face of inconsistent or changing requirements, as well as the need to address Cross-Cutting Concerns.
In section 10.1, you learned that the primary aim of AOP is to keep your Cross-Cutting Concerns DRY. As we discussed in section 10.2, there’s a strong relationship between the OCP and the DRY principle. They both strive for the same objective, which is to minimize repetition and prevent sweeping changes.
From that perspective, the code repetition that you witnessed with AuditingUserRepositoryDecorator
, CircuitBreakerProductRepositoryDecorator
, and SecureProductRepositoryDecorator
in chapter 9 (listings 9.2, 9.4, and 9.7) are a strong indication that we were violating the OCP. AOP seeks to address this by separating out extensible behavior (aspects) into separate components that can easily be applied to a variety of implementations.
A common misconception, however, is that AOP requires tooling. AOP tool vendors are all to eager to keep this fallacy alive. Our preferred approach is to practice AOP by design, which means you apply patterns and principles first, before reverting to specialized AOP tooling like dynamic Interception libraries.
In this section, we’ll do just that. We’ll look at AOP from a design perspective by taking a close look at the IProductService
Abstraction we introduced in chapter 3. We’ll analyze which SOLID principles we’re violating and why such violations are problematic. After that, we’ll address these violations step by step with the goal of making the application more maintainable, preventing the need to make sweeping changes in the future. Be prepared for some mental discomfort — and even cognitive dissonance — as we defy your beliefs on how to design software. Buckle up, and get ready for the ride.
Let’s dive right in by looking at the IProductService
Abstraction that you built in chapter 3 as part of the sample e-commerce application’s domain layer. The following listing shows this interface as originally defined in listing 3.5.
Listing 10.3 The IProductService
interface of chapter 3
public interface IProductService
{
IEnumerable<DiscountedProduct> GetFeaturedProducts();
}
When looking at an application’s design from the perspective of SOLID principles in general, and the OCP in particular, it’s important to take into consideration how the application has changed over time, and from there predict future changes. With this in mind, you can determine whether the application is closed for modification to the changes that are most likely to happen in the future.
Note The more experience you have in the application’s domain and with software development, in general, the more likely you are to make good predictions about future changes. That’s why it’s typically difficult to get the design right immediately when starting a project.
It’s important to note that even with a SOLID design, there can come a time where a change becomes sweeping. Being 100% closed for modification is neither possible nor desirable. Besides, conforming to the OCP is expensive. It takes considerable effort to find and design the appropriate Abstractions, although too many Abstractions can have a negative impact on the complexity of the application. Your job is to balance the risks and the costs and come up with a global optimum.
Because you should be looking at how the application evolves, evaluating IProductService
at a single point in time isn’t that helpful. Fortunately, Mary Rowan (our developer from chapter 2), has been working on her e-commerce application for some time now, and a number of features have been implemented since we last looked over her shoulder. The next listing shows how Mary has progressed.
Listing 10.4 The evolved IProductService
interface
public interface IProductService
{
IEnumerable<DiscountedProduct> GetFeaturedProducts();
void DeleteProduct(Guid productId); ①
Product GetProductById(Guid productId); ①
void InsertProduct(Product product); ①
void UpdateProduct(Product product); ①
Paged<Product> SearchProducts( ①
int pageIndex, int pageSize, ①
Guid? manufacturerId, string searchText); ①
void UpdateProductReviewTotals( ①
Guid productId, ProductReview[] reviews); ①
void AdjustInventory( ①
Guid productId, bool decrease, int quantity); ①
void UpdateHasTierPricesProperty(Product product); ①
void UpdateHasDiscountsApplied( ①
Guid productId, string discountDescription); ①
}
New features added by Mary during the course of the last few chapters. As we’ll discuss shortly, this small code snippet exhibits three SOLID violations.
As you can see, quite a few new features have been added to the application. Some are typical CRUD operations, such as UpdateProduct
, whereas others address more-complex use cases, such as UpdateHasTierPricesProperty
. Still others are for retrieving data, such as SearchProducts
and GetProductById
.
Note Don’t worry if the functionality of these new methods isn’t clear to you; the details of this interface and what each method does aren’t that relevant to this discussion.
Although Mary started off with good intentions when she defined the first version of IProductService
in listing 10.3, the fact that this interface needs to be updated every time a new product-related feature is implemented is a clear indication that something’s wrong.
If you extrapolate this to make a prediction, can you expect this interface to be updated again soon? The answer to that question is a clear “Yes!” As a matter of fact, Mary already has several features in her backlog, concerning cross-sellings, product pictures, and product reviews that would all cause changes to IProductService
.4
4 Have you ever seen lists like Other Customers Bought or Related Items in web shops? Those are cross-sellings. Cross-selling is the action or practice of selling an additional product or service to a customer.
What this teaches us is that, in this particular application, new features concerning products are added on a regular basis. Because this is an e-commerce application, this isn’t a world-shattering observation. But because this is both a central part of the code base and under frequent change, the need to improve the design arises. Let’s analyze the current design with SOLID principles in mind.
Concerning the five SOLID principles discussed in section 10.2, Mary’s design violates three out of five SOLID principles, namely, the ISP, SRP, and OCP. We’ll start with the first one: IProductService
violates the ISP.
IProductService
violates the ISPThere’s one obvious violation — IProductService
violates the ISP. As explained in section 10.2.4, the ISP prescribes the use of fine-grained Abstractions over wide Abstractions. From the perspective of the ISP, IProductService
is rather wide. With listing 10.4 in mind, it’s easy to believe that there’ll be no single consumer of IProductService
that’ll use all its methods. Most consumers would typically use one method or a few at most. But how is this violation a problem?
A part of the code base where wide interfaces directly cause trouble is during testing. HomeController
’s unit tests, for instance, will define an IProductService
Test Double implementation, but such a Test Double is required to implement all its members, even though HomeController
itself only uses one method.5 Even if you could create a reusable Test Double, you typically still want to assert that unrelated methods of IProductService
aren’t called by HomeController
. The following listing shows a Mock IProductService
implementation that asserts unexpected methods aren’t called.
5 Listing 3.4 shows how HomeController
’s Index
method solely calls GetFeaturedProducts()
on IProductService
.
Listing 10.5 A reusable Mock IProductService
base class
public abstract class MockProductService : IProductService
{
public virtual void DeleteProduct(Guid productId)
{
Assert.True(false, "Should not be called."); ①
}
public virtual Product GetProductById(Guid id)
{
Assert.True(false, "Should not be called."); ①
return null;
}
public virtual void InsertProduct(Product product)
{
Assert.True(false, "Should not be called."); ①
}
... ②
}
All method implementations fail.
List of methods goes on. You’ll need to implement all 10 methods.
All methods are implemented to fail by calling Assert.True
using a value of false
. The Assert.True
method is part of the xUnit testing framework.6 By passing false
, the assertion fails, and the currently running test also fails.
6 Calling Assert.True
with a false
argument is a bit confusing, but xUnit lacks a convenient Assert.Fail
method.
To preserve precious trees, listing 10.5 only shows a few of MockProductService
’s methods, but we think you get the picture. You wouldn’t have to implement this big list of failing methods if the interface was specific to HomeController
’s needs; in that case, HomeController
is expected to call all its Dependency’s methods, and you wouldn’t have to do this check.
IProductService
violates the SRPBecause the ISP is the conceptual underpinning of the SRP, an ISP violation typically indicates an SRP violation in its implementations, as is the case here. SRP violations can sometimes be hard to detect, and you might argue that a ProductService
implementation has one responsibility, namely, handling product-related use cases.
The concept of product-related use cases, however, is extremely vague and broad. Rather, you want classes that have only one reason to change. ProductService
definitely has multiple reasons to change. For instance, any of the following reasons causes ProductService
to change:
Not only does ProductService
have many reasons to change, its methods are most likely not cohesive. A simple way to spot low cohesion is to check how easy it is to move some of the class’s functionality to a new class. The easier this is, the lower the relatedness of the two parts, and the more likely SRP is violated.
Perhaps UpdateHasTierPricesProperty
and UpdateHasDiscountsApplied
share the same Dependencies, but that’d be about it; they aren’t cohesive. As a result, the class will likely be complex, which can cause maintainability problems. ProductService
should, therefore, be split into multiple classes. But that raises this question: how many classes and which methods should be grouped together, if any? Before we get into that, let’s first inspect how the design around IProductService
violates the OCP.
IProductService
violates the OCPTo test whether the code violates the OCP, you first have to determine what kind of changes to this part of the application you can expect. After that, you can ask the question, “Does this design cause sweeping changes when expected changes are made?”
You can expect two quite likely changes to happen during the course of the lifetime of the e-commerce application. First, new features will need to be added (Mary already has them on her backlog). Second, Mary likely also needs to apply Cross-Cutting Concerns. With these expected changes, the obvious answer to the question is, “Yes, the current design does cause sweeping changes.” Sweeping changes happen both when adding new features and when adding new aspects.
When a new product-related feature is added, the change ripples through all IProductService
implementations, which will be the main ProductService
implementation, and also all Decorators and Test Doubles. When a new Cross-Cutting Concern is added, there’ll likely be rippling changes to the system too, because, besides adding a new Decorator for IProductService
, you’ll also be adding Decorators for ICustomerService
, IOrderService
, and all other I...Service
Abstractions. Because each Abstraction potentially contains dozens of methods, the aspect’s code would be repeated many times, as we discussed in section 10.1.
Note The amount of changes you need to make to the existing Decorators grows proportionally with the amount of features in the system. This makes adding new aspects and features more expensive over time, up to the point that adding features becomes too costly.
In table 9.1, we summed up a wide range of possible aspects you might need to implement. At the start of a project, you might not know which ones you’ll need. But even though you might not know exactly which Cross-Cutting Concerns you may need to add, it’d be a fairly well-educated guess to assume that you do need to add some during the course of the project, as Mary does.
IProductService
From the previous analysis, you can conclude that, together with its implementations, listing 10.4 violates three out of five SOLID principles. Although from the perspective of AOP, you might be tempted to use either dynamic Interception (section 11.1) or compile-time weaving tools (section 11.2) to apply aspects, we argue that this only solves part of the problem; namely, how to effectively apply Cross-Cutting Concerns in a maintainable fashion. The use of tools doesn’t fix the underlying design issues that still cause maintainability problems in the long run.
Tip Although you shouldn’t reject tool-based methods of AOP immediately, your first instinct should be to improve the application’s design. Only when that doesn’t solve the maintainability issues should you resort to the use of tooling.
As we’ll discuss in sections 11.1.2 and 11.2.2, both methods of AOP have their own particular sets of disadvantages. But let’s take a look at whether we can get to a more SOLID and maintainable design with Mary’s app.
In this section, we’ll improve the application’s design step by step by doing the following:
One of the problems with Mary’s current design is that the majority of aspects applied to IProductService
are only required by a subset of its methods. Although an aspect such as security typically applies to all features, aspects such as auditing, validation, and fault tolerance will usually only be required around the parts of the application that change state. An aspect such as caching, on the other hand, may only make sense for methods that read data without changing state. You can simplify the creation of Decorators by splitting IProductService
into a read-only and write-only interface, as shown in figure 10.1.
Note We use the term query for operations that only read state but don’t change the state of the system, and command for operations that change the state of the system but don’t produce any results. This terminology stems from the Command-Query Separation (CQS) principle. Mary already applied CQS with IProductService
on the method level, but by splitting the interface, she now propagates CQS to the interface level.
Command-Query Separation (CQS) was coined by Bertrand Meyer in Object-Oriented Software Construction (ISE Inc., 1988). CQS has become an influential object-oriented principle that promotes the idea that each method should either
Meyer called the value-producing methods queries and the state-changing methods commands. The idea behind this separation is that methods become easier to reason about when they’re either a query or a command, but not both.
The advantage of this split is that the new interfaces are finer-grained than before. This reduces the risk of you having to depend on methods that you don’t need. When you create a Decorator that applies a transaction to the executed code, for instance, only IProductCommandServices
will need to be decorated, which eliminates the need to implement the IProductQueryServices
’s methods. It also makes the implementations smaller and simpler to reason about.
Although this split is an improvement over the original IProductService
interface, this new design still causes sweeping changes. As before, implementing a new product-related feature causes a change to many classes in the application. Although you reduced the likelihood of a class being changed by half, a change still causes about the same amount of classes to be touched. This brings us to the second step.
Because splitting the wide interface pushes us in the right direction, let’s take this a step further. We’ll focus our attention on IProductCommandServices
and ignore IProductQueryServices
.
Let’s try something radical here. Let’s break up IProductCommandServices
into multiple one-membered interfaces. Figure 10.2 shows how the ProductCommandServices
implementation is segregated into seven classes, each with their own one-membered interface.
In figure 10.2 , you moved each method of the IProductCommandServices
interface into a separate interface and gave each interface its own class. Listing 10.6 shows a few of those interface definitions.
Listing 10.6 The big interface segregated into one-membered interfaces
public interface IAdjustInventoryService
{
void AdjustInventory(Guid productId, bool decrease, int quantity);
}
public interface IUpdateProductReviewTotalsService
{
void UpdateProductReviewTotals(Guid productId, ProductReview[] reviews);
}
public interface IUpdateHasDiscountsAppliedService
{
void UpdateHasDiscountsApplied(Guid productId, string description);
}
... ①
The other four interfaces are omitted for brevity.
This might scare the living daylights out of you, but it might not be as bad as it seems. Here are some compelling advantages to this change:
Even though this new design conforms to the ISP and the SRP, it still causes sweeping changes when it comes to creating Decorators. Here’s how:
IProductCommandServices
interface split into seven, one-membered interfaces, there’ll be seven Decorator implementations per aspect. With 10 aspects, for instance, this means 70 Decorators.This new design causes each class in the application to be focused around one particular use case, which is great from the perspective of the SRP and the ISP. But, because these classes have no commonality to which you can apply aspects, you’re forced to create many Decorators with almost identical implementations. It’d be nice if you were able to define a single interface for all command operations in the code base. That would greatly reduce the code duplication around aspects and the number of Decorator classes to one Decorator per aspect.
When you look at listing 10.6, it might be hard to see how these interfaces have any similarity. They all return void
, but all have a differently named method, and each method has a different set of parameters. There’s no commonality to extract from that — or is there?
What if you extract the method parameters of each command method into a Parameter Object? Most refactoring tools allow such refactoring with a few simple keystrokes.
Definition A Parameter Object is a group of parameters that naturally go together.7
7 Martin Fowler et al., Refactoring: Improving the Design of Existing Code (Addison-Wesley, 1999), 285.
The next listing shows the result of this refactoring.
Listing 10.7 Wrapping method parameters in a Parameter Object
public interface IAdjustInventoryService
{
void Execute(AdjustInventory command); ①
}
public class AdjustInventory ②
{ ②
public Guid ProductId { get; set; } ②
public bool Decrease { get; set; } ②
public int Quantity { get; set; } ②
} ②
public interface IUpdateProductReviewTotalsService
{
void Execute(UpdateProductReviewTotals command); ③
}
public class UpdateProductReviewTotals ③
{ ③
public Guid ProductId { get; set; } ③
public ProductReview[] Reviews { get; set; } ③
} ③
Instead of accepting a list of parameters, IAdjustInventoryService now accepts one single parameter of the new AdjustInventory Parameter Object. That class groups all the method’s parameters. Its method is renamed to a more generic name, Execute.
AdjustInventory contains IAdjustInventoryService’s grouped method parameters. It’s a Parameter Object; it contains no behavior.
Same refactoring applied to this interface. It now accepts UpdateProductReviewTotals as its sole parameter.
The two method parameters of IUpdateProductReviewTotalsService are now grouped together in the new UpdateProductReviewTotals Parameter Object.
It’s important to note that even though both AdjustInventory
and UpdateProductReviewTotals
Parameter Objects are concrete objects, they’re still part of their Abstraction. As we mentioned in section 3.1.1, because they’re mere data objects without behavior, hiding their values behind an Abstraction would be rather useless. If you moved the implementations into a different assembly, the Parameter Objects would stay in the same assembly as their Abstraction. Also, these extracted Parameter Objects become the definition of a command operation. We therefore typically refer to these objects themselves as commands.
Tip It’s perfectly fine for command Parameter Objects to have a single parameter — or even no parameters at all.
Both the InsertProduct
and UpdateHasTierPricesProperty
commands will have a single parameter of type Product
. Inserting a product, however, is something completely different than updating a product’s HasTierPrices
property. Again, the command type itself becomes the definition of a command operation.
With these refactorings, you effectively changed the code from 1 interface and implementation with 7 methods, to 7 interfaces and 14 classes. At this point, you might think we’re certifiably nuts and perhaps you’re ready to toss this book out the window. This might be the mental discomfort we warned about at the beginning of this section. Bear with us, because increasing the number of classes in your system might not be as bad as it might seem at first, and this refactoring will get us somewhere. Promise.
Important Although this refactoring increases the number of files in the project, assuming that each class and interface gets its own file in the project, you didn’t change the executable code. Every method still contains the same amount of code as before. You gave each use case its own data object, and each class now handles a single use case.
With the previous refactoring, a pattern emerges:
Execute
.void
.You can now extract a common interface from this pattern. Here’s how:
public interface ICommandService ①
{
void Execute(object command);
}
One interface to rule them all!
If you implement the command services using this new ICommandService
interface, it results in the code in listing 10.8. Note that this new interface definition can likely be used to replace other I...Service
Abstractions too.
Listing 10.8 AdjustInventoryService
implementing ICommandService
public class AdjustInventoryService : ICommandService ①
{
readonly IInventoryRepository repository;
public AdjustInventoryService( ②
IInventoryRepository repository)
{
this.repository = repository;
}
public void Execute(object cmd)
{
var command = (AdjustInventory)cmd; ③
Guid id = command.ProductId; ④
bool decrease = command.Decrease; ④
int quantity = command.Quantity; ④
... ④
}
}
Implements ICommandService instead of IAdjustInventoryService
Uses Constructor Injection to inject the class’s Dependencies
Execute accepts a value of type object, but because you know AdjustInventoryService gets supplied with an AdjustInventory command message, you perform a cast.
Accesses the command’s parameters and executes the appropriate code. This is the code that was originally placed in the AdjustInventory method of the ProductService class.
Figure 10.3 shows how the number of interfaces are reduced from seven back to one. Now, however, you extract the method parameters into a Parameter Object per service.
As we stated previously, the Parameter Objects are part of the Abstraction. Collapsing all interfaces into one single interface makes this even more apparent. The Parameter Object has become the definition of a use case — it has become the contract. Consumers can get this ICommandService
injected into their constructor and call its Execute
method by supplying the appropriate Parameter Object.
Listing 10.9 InventoryController
depending on ICommandService
public class InventoryController : Controller
{
private readonly ICommandService service;
public InventoryController(ICommandService service) ①
{
this.service = service;
}
[HttpPost]
public ActionResult AdjustInventory(
AdjustInventoryViewModel viewModel)
{
if (!this.ModelState.IsValid)
{
return this.View(viewModel);
}
AdjustInventory command = viewModel.Command; ②
this.service.Execute(command); ③
return this.RedirectToAction("Index");
}
}
Injects ICommandService into the MVC controller class
AdjustInventoryViewModel wraps the AdjustInventory command as a property.
In case the posted data is valid, passes on the command to the ICommandService for execution
The AdjustInventoryViewModel
wraps the AdjustInventory
command as a property. This is convenient, because AdjustInventory
is part of the Abstraction and only contains data specific to the use case. AdjustInventory
will be model-bound by the MVC framework, together with its surrounding AdjustInventoryViewModel
, when the user posts back the request.
Note If you noticed that listing 10.9 violates the LSP, we applaud you. We’ll get to that violation in a moment.
ICommandService
to implement Cross-Cutting ConcernsHaving a single interface for all your command service calls in the code base provides a huge advantage. Because all the application’s state-changing use cases now implement this single interface, you can now create a single Decorator per aspect and wrap it around each and every implementation. To prove this point, the following listing shows the implementation of a transaction aspect as a Decorator for the ICommandService
.
Listing 10.10 Implementing a transaction aspect based on ICommandService
public class TransactionCommandServiceDecorator
: ICommandService
{
private readonly ICommandService decoratee;
public TransactionCommandServiceDecorator(
ICommandService decoratee)
{
this.decoratee = decoratee;
}
public void Execute(object command)
{
using (var scope = new TransactionScope())
{
this.decoratee.Execute(command);
scope.Complete();
}
}
}
Because this Decorator is like what you saw many times in chapter 9, we think it needs little explaining, except perhaps the TransactionScope
class.
The System.Transactions.TransactionScope
class of the System.Transactions.dll lets you wrap any arbitrary piece of code in a transaction. Any DbTransaction
created during the lifetime of that scope is automatically enlisted in the same transaction. This is a powerful concept that makes it possible to apply transactions to multiple pieces of code that belong to the same business operation, without having to pass along transactions through the call stack.
Compared to the full .NET framework, .NET Core doesn’t support distributed transactions, because this requires the Microsoft Distributed Transaction Coordinator (MSDTC) service, which has no equivalent on platforms other than Windows. This is an advantage, because we feel that, in general, distributed transactions should be prevented anyway. With .NET Core, however, you can still use TransactionScope
to enlist operations in a transaction to a single data source.
Using this new Decorator, you can now compose an InventoryController
by injecting a new AdjustInventoryService
that gets Intercepted by a Transaction-CommandServiceDecorator
:
ICommandService service =
new TransactionCommandServiceDecorator(
new AdjustInventoryService(repository));
new InventoryController(service);
This design effectively prevents sweeping changes both when new features are added and when new Cross-Cutting Concerns need to be applied. This design is now truly closed for modification because
ICommandService
implementation. No existing classes need to be changed.Important Even though you moved from the situation of listing 10.4, where you had 2 types (the IProductService
interface and its implementation), into the situation shown in figure 10.3, where you have 15 types (1 interface, 7 Parameter Objects, and 7 service implementations), the maintainability of the application improved dramatically because sweeping changes will be rare. This leads to the important realization that the number of classes in itself is a bad metric for measuring maintainability.
Some developers argue against having this many classes in their system, because they feel it complicates navigating through the project. This, however, only happens when you don’t structure your project properly. In this example, all product-related operations can be placed in a namespace called MyApp.Services.Products
, effectively grouping those operations together, similar to what Mary’s IProductService
did. Instead of having the grouping at the class level, you now have it at the project level, which is a great benefit, because the project structure immediately shows you the application’s behavior.
Now that you’ve fixed the previously analyzed SOLID violations, you might think that we’re done with our refactoring. But, unfortunately, these changes accidentally introduced a new SOLID violation. Let’s look at that next.
As mentioned, the definition of ICommandService
accidentally introduced a new SOLID violation, namely, the LSP. The InventoryController
of listing 10.9 exhibits this violation.
As we discussed in section 10.2.3, the LSP says that you must be able to substitute an Abstraction for an arbitrary implementation of that same Abstraction without changing the correctness of the client. According to the LSP, because the AdjustInventoryService
implements the ICommandService
, you should be able to substitute it for a different implementation without breaking the InventoryController
. The following listing shows an altered object composition for InventoryController
.
Listing 10.11 Substituting AdjustInventoryService
ICommandService service =
new TransactionCommandServiceDecorator(
new UpdateProductReviewTotalsService( ①
repository));
new InventoryController(service);
Instead of injecting an AdjustInventoryService, you inject an UpdateProductReviewTotalsService. This compiles, but completely breaks InventoryController — an LSP violation.
The following shows the Execute
method for UpdateProductReviewTotalsService
:
public void Execute(object cmd)
{
var command = (UpdateProductReviewTotals)cmd; ①
...
}
This cast fails when Execute is supplied with a command of type AdjustInventory.
InventoryController
gets an ICommandService
injected into its constructor. It passes on the AdjustInventory
command to that injected ICommandService
. Because the injected ICommandService
is an UpdateProductReviewTotalsService
, it’ll try to cast the incoming command to UpdateProductReviewTotals
. Because it’ll be unable to cast AdjustInventory
to UpdateProductReviewTotals
, however, the cast fails. This breaks InventoryController
and therefore violates the LSP.
Note DI Containers compose object graphs based on the type information retrieved from the type’s constructor arguments. Because their primary method for Object Composition is based on this, DI Containers are bad for handling ambiguous Abstractions. LSP violations, therefore, tend to complicate your Composition Root when using a DI Container. Or, put differently, the use of a DI Container makes LSP violations more obvious, just as Constructor Injection makes SRP violations more obvious.
Although one could argue that it’s up to the Composition Root to supply the correct implementation, the ICommandService
interface still causes ambiguity, and it prevents the compiler from verifying whether the composition of our object graph makes sense. LSP violations tend to make a system fragile. Furthermore, the untyped command
method argument that Execute
methods consume requires every ICommandService
implementation to contain a cast, which can be considered a code smell in its own right. Let’s fix this violation.
Here’s a rather elegant solution to this seemingly intractable design deadlock. All you have to do to fix this issue is redefine ICommandService
.
Listing 10.12 A generic ICommandService
implementation
public interface ICommandService<TCommand> ①
{
void Execute(TCommand command);
}
TCommand is the generic type argument. It specifies the type of command that an implementation will execute.
You might be confused as to how making the interface generic helps. To help clarify this, the next listing shows how you would implement ICommandService<TCommand>
.
Listing 10.13 AdjustInventoryService
implementing ICommandService<TCommand>
public class AdjustInventoryService
: ICommandService<AdjustInventory> ①
{
private readonly IInventoryRepository repository;
public AdjustInventoryService(
IInventoryRepository repository)
{
this.repository = repository;
}
public void Execute(AdjustInventory command) ②
{
var productId = command.ProductId; ③
③
... ③
}
}
Implements an ICommandService<TCommand>, indicating that this class handles AdjustInventory messages
Because the class implements ICommandService<AdjustInventory>, its Execute method now accepts an AdjustInventory instead of an object.
Because Execute now directly accepts an AdjustInventory, the parameter command can be used directly without any casts.
Important Do you remember how we defined an IEventHandler<TEvent>
Abstraction in listing 6.9? The signature of this new ICommandService<TCommand>
is identical to IEventHandler<TEvent>
’s, and that’s no coincidence. This is the kind of structure that’ll frequently emerge when you apply SOLID principles to your code base — that is, one-membered generic interfaces that accept and/or return messages based on their generic types.
Many frameworks and online reference architecture samples have different names for an interface similar to the previous examples. They might be named IHandler<T>
, ICommandHandler<T>
, IMessageHandler<T>
, or IHandleMessages<T>
. Some Abstractions are asynchronous and return a Task
, whereas others add a CancellationToken
as a method argument. Sometimes the method is called Handle
or HandleAsync
. Although named differently, the idea and the effect it has on the maintainability of your application, however, is the same.
Although the additional compile-time support in the implementation is certainly a nice plus, the main reason for the generic ICommandService<TCommand>
is to prevent violating the LSP in its clients. The following listing shows how injecting ICommandService<TCommand>
into the InventoryController
fixes the LSP.
Listing 10.14 InventoryController
depending on ICommandService<TCommand>
public class InventoryController : Controller
{
readonly ICommandService<AdjustInventory> service;
public InventoryController(
ICommandService<AdjustInventory> service) ①
{
this.service = service;
}
public ActionResult AdjustInventory(
AdjustInventoryViewModel viewModel)
{
...
AdjustInventory command = viewModel.Command;
this.service.Execute(command); ②
return this.RedirectToAction("Index");
}
}
Injects a specific ICommandService<AdjustInventory>, indicating that you want to execute AdjustInventory commands. This prevents the accidental injection of services that handle UpdateProductReviewTotals or any other command type.
The service parameter only accepts AdjustInventory as a command type. It becomes impossible to supply it with a different type of message — that wouldn’t compile.
Tip If your programming language doesn’t support generics, you might find the use of the non-generic ICommandService
interface mixed with the Mediator design pattern an acceptable workaround.8 In that case, you introduce an additional Abstraction, the Mediator, which accepts arbitrary commands and gets injected into consumers. The Mediator’s job is to dispatch a supplied command to the correct ICommandService
implementation.
8 Erich Gamma et al., Design Patterns, 273.
Changing the non-generic ICommandService
into the generic ICommandService<TCommand>
fixes our last SOLID violation. This would be a good time to reap the benefits of our new design.
Although there’s more to a generic one-membered Abstraction than just Cross-Cutting Concerns, the ability to apply aspects in a way that doesn’t cause sweeping changes is one of the greatest benefits of such a design. As with the non-generic ICommandService
interface, ICommandService<TCommand>
still allows the creation of a single Decorator per aspect. Listing 10.15 shows a rewrite of the transaction Decorator of listing 10.10 using the new generic ICommandService<TCommand>
Abstraction.
Listing 10.15 Implementing a generic transaction aspect
public class TransactionCommandServiceDecorator<TCommand>
: ICommandService<TCommand>
{
private readonly ICommandService<TCommand> decoratee;
public TransactionCommandServiceDecorator(
ICommandService<TCommand> decoratee)
{
this.decoratee = decoratee;
}
public void Execute(TCommand command)
{
using (var scope = new TransactionScope())
{
this.decoratee.Execute(command);
scope.Complete();
}
}
}
Using the ICommandService<TCommand>
interface and the TransactionCommandServiceDecorator<TCommand>
Decorator, your Composition Root becomes the following:
new InventoryController(
new TransactionCommandServiceDecorator<AdjustInventory>(
new AdjustInventoryService(repository)));
This brings us to the point where this one-membered generic Abstraction starts to steal the show. This is when you start adding more Cross-Cutting Concerns.
The examples of Cross-Cutting Concerns we discussed in section 9.2 all focused on applying aspects at the boundary of Repositories (such as in listings 9.4 and 9.7). In this section, however, we shift the focus one level up in the layered architecture, from the data access library’s repository to the domain library’s IProductService
.
This shift is deliberate, because you’ll find that Repositories aren’t the right granular level for applying many Cross-Cutting Concerns effectively. A single business action defined in the domain layer would potentially call multiple Repositories, or call the same Repository multiple times. If you were to apply, for instance, a transaction at the level of the repository, it’d still mean that the business operation could potentially run in dozens of transactions, which would endanger the correctness of the system.
A single business operation should typically run in a single transaction. This level of granularity holds not only for transactions, but other types of operations as well.
The domain library implements business operations, and it’s at this boundary that you typically want to apply many Cross-Cutting Concerns. The following lists some examples. It isn’t a comprehensive listing, but it’ll give you a sense of what you could apply on that level:
AuditingUserRepositoryDecorator
of listing 9.1, this presents a list of changes to individual Entities, and you lose the overall picture — that is, why the change happened. Reporting changes to individual Entities might be suited for CRUD-based applications, but if the application implements more-complex use cases that influence more than a single Entity, it becomes beneficial to pull auditing a level up and store information about the executed command. We’ll show an auditing example next.ICommandService<TCommand>
becomes an ideal boundary for plugging in performance monitoring.Validator
class.10 9 System.ComponentModel.DataAnnotations
is a framework-agnostic data validation library by Microsoft.
10 For an example of such a Decorator, see https://simpleinjector.org/aop#decoration.
The following sections take a look at how you can implement two of these aspects on top of ICommandService<TCommand>
.
Listings 9.1 and 9.2 defined an auditing Decorator for IUserRepository
, while reusing the IAuditTrailAppender
from listing 6.23. If you apply auditing on ICommandService<TCommand>
instead, you’re at the ideal level of granularity, because the command contains all interesting use case–specific data you might want to record. If you enrich this data and metadata with some contextual information, such as username and the current system time, you’re pretty much done. The next listing shows an auditing Decorator on top of ICommandService<TCommand>
.
Listing 10.16 Implementing a generic auditing aspect for business operations
public class AuditingCommandServiceDecorator<TCommand>
: ICommandService<TCommand>
{
private readonly IUserContext userContext;
private readonly ITimeProvider timeProvider;
private readonly CommerceContext context;
private readonly ICommandService<TCommand> decoratee;
public AuditingCommandServiceDecorator(
IUserContext userContext,
ITimeProvider timeProvider, ①
CommerceContext context,
ICommandService<TCommand> decoratee)
{
this.userContext = userContext;
this.timeProvider = timeProvider;
this.context = context;
this.decoratee = decoratee;
}
public void Execute(TCommand command)
{
this.decoratee.Execute(command);
this.AppendToAuditTrail(command);
}
private void AppendToAuditTrail(TCommand command)
{
var entry = new AuditEntry ②
{ ②
UserId = this.userContext.CurrentUser.Id, ②
TimeOfExecution = this.timeProvider.Now, ②
Operation = command.GetType().Name, ②
Data = Newtonsoft.Json.JsonConvert ②
.SerializeObject(command) ②
};
this.context.AuditEntries.Add(entry);
this.context.SaveChanges();
}
}
Recall that this is the ITimeProvider interface from listing 5.10.
Besides appending the user and the time of execution to the audit trail, the Decorator stores the name of the command and a serialized representation of its data too. This information is gathered using reflection and, in this case, you use the well-known JSON.NET serialization library (https://www.newtonsoft.com/json) that converts the command data to a readable JSON format.
Note This Decorator combines the auditing logic and the decorating logic. Whether this is good practice depends on the amount of logic inside the Decorator, whether you need this auditing logic to be reused by other classes, and in which module you locate the Decorator. Because you can now apply this Decorator around all business operations, we argue there’s little reason to share this logic with other classes. For that reason, we merged the two classes together. Because of the dependency on CommerceContext
, however, this Decorator should be placed in either the data access layer or the Composition Root.
When Mary runs the application using the AuditingCommandServiceDecorator<TCommand>
, the Decorator produces the information in the auditing table, shown in table 10.2.
User | Time | Operation | Data |
Mary | 2018-12-24 11:20 | AdjustInventory | { ProductId: "ae361...00bc", Decrease: false, Quantity: 2 } |
Mary | 2018-12-24 11:21 | UpdateHasTierPricesProperty | { Product: { Id: "ae361...00bc", Name: "Gruyère", UnitPrice: 48.50, IsFeatured: true } } |
Mary | 2018-12-24 11:25 | UpdateHasDiscountsApplied | { ProductId: "ae361...00bc", DiscountDescription: "Test" } |
Mary | 2018-12-24 15:11 | AdjustInventory | { ProductId: "5435...a845", Decrease: true, Quantity: 1 } |
Mary | 2018-12-24 15:12 | UpdateProductReviewTotals | { ProductId: "5435...a845", Reviews: [{ Rating: 5, Text: "nice!" }] } |
As stated previously, AuditingCommandServiceDecorator<TCommand>
uses reflection to get the name of the command and convert the command to a JSON format. Although JSON is human readable, you probably don’t want to show this to your end users. Still, this is a good format to use for backend auditing purposes. Using this information, you’ll be able to efficiently see what happened in your system, by whom, and at which point in time. It would even allow you to replay an operation if it failed for some reason or to use this information to perform a realistic stress test on the system. You could deserialize the information from this table back to commands and run them through the system.
As we described in section 6.3.2, domain events are another well-suited technique that can also be used for auditing. This auditing aspect, however, only records a user’s successful action. Although an auditor might not be interested in failures, we as developers certainly are. It isn’t hard to imagine how you’d use the same mechanism to record the same data and include a stack trace when the operation fails.
Important The application design went from an RPC-like method-calling model to a message-passing model. These messages can be serialized, queued, logged, and replayed — all abilities that are harder to achieve with a method-calling model, such as the initial IProductService
implementation from listing 10.4.
When you use an Abstraction that wraps around a business transaction, like ICommandService<TCommand>
does, the method parameters become an easily serializable package of data, as you saw in listing 10.16. A single Decorator, therefore, lets you apply logging across a wide range of methods in the application.
This might not solve all your logging needs, but when an application gets more complex, we’ve experienced that the Abstractions of a well-designed SOLID application allow the definition of a few Decorators that provide us with 98% of the logging needs of our applications. But there are other practices you need to apply to prevent having to log at too many places in the application:
11 See, for example, Jeff Atwood, “The Problem With Logging” 2008, https://blog.codinghorror.com/the-problem-with-logging/.
12 See, for example, Robert C. Martin, Clean Code (Prentice Hall, 2009).
Likewise, you can use this information for performance monitoring in the same way, where you store an additional timespan next to the time and the operation details. This easily allows you to monitor which operations become slower over time. Before showing you an example of the new Composition Root with AuditingCommandServiceDecorator<TCommand>
applied, we’ll first take a look at how you can use passive attributes to implement a security aspect.
During our discussion about Cross-Cutting Concerns in section 9.2, you implemented a SecureProductRepositoryDecorator
in listing 9.7. Because that Decorator was specific to IProductRepository
, it was clear what role the Decorator should grant access to. In the example, access to the write methods of IProductRepository
was restricted to the Administrator
role.
With this new generic model, a single Decorator is wrapped around all business operations, not just the product CRUD operations. Some operations also need to be executable by other roles, which makes the hard-coded Administrator
role unsuited for this generic model. You can implement such a security check on top of a generic Abstraction in many ways, but one compelling method is through the use of passive attributes.
Definition A passive attribute provides metadata rather than behavior. Passive attributes prevent the Control Freak anti-pattern, because aspect attributes that include behavior are often Volatile Dependencies.13
13 Mark Seemann, “Passive attributes” 2014, https://blog.ploeh.dk/2014/06/13/passive-attributes/.
When you stick to role-based security as an example of authorization, you can specify a PermittedRoleAttribute
.
Listing 10.17 A passive PermittedRoleAttribute
public class PermittedRoleAttribute : Attribute ①
{
public readonly Role Role;
public PermittedRoleAttribute(Role role) ②
{
this.Role = role;
}
}
public enum Role ③
{
PreferredCustomer,
Administrator,
InventoryManager
}
This passive attribute allows classes to be enriched with metadata about the permitted role.
Wraps the application’s Role enumeration that defines the application’s fixed set of roles
The Role enumeration containing the application’s known roles. You first saw this enum in section 3.1.2.
You can use this attribute to enrich commands with metadata about which role is allowed to execute an operation.
Listing 10.18 Enriching commands with security-related metadata
[PermittedRole(Role.InventoryManager)] ①
public class AdjustInventory
{
public Guid ProductId { get; set; }
public bool Decrease { get; set; }
public int Quantity { get; set; }
}
[PermittedRole(Role.Administrator)] ①
public class UpdateProductReviewTotals
{
public Guid ProductId { get; set; }
public ProductReview[] Reviews { get; set; }
}
Marks commands with the PermittedRoleAttribute while specifying the allowed role. In this case, AdjustInventory can be executed by users with the role InventoryManager, although only administrators are authorized to execute UpdateProductReviewTotals.
Important Notice how the permitted role in listing 10.18 becomes part of the definition of a command.
There’s a big difference between applying aspect attributes, as we’ll discuss in section 11.2, and a passive attribute, such as the PermittedRoleAttribute
. Compared to aspect attributes, passive attributes are decoupled from the aspect that use their values, which is one of the main problems with compile-time weaving, as you’ll see in chapter 11. The passive attribute doesn’t have a direct relationship with the aspect. This allows the metadata to be reused by multiple aspects, perhaps in different ways.
Tip Prefer creating a domain-specific attribute over reusing an attribute that’s tied to a specific framework. For instance, if you use the ASP.NET Core’s [Authorize]
attribute, that would drag along a dependency to Microsoft.AspNetCore.Authorization.dll, which wouldn’t be appropriate if you were to reuse the domain in, for example, a Windows service application.
Like you’ve seen previously, adding the security behavior is a matter of creating the Decorator and wrapping it around the real implementation. Listing 10.19 shows such a Decorator. It makes use of the PermittedRoleAttribute
that’s supplied to commands, as listing 10.18 showed.
Listing 10.19 SecureCommandServiceDecorator<TCommand>
public class SecureCommandServiceDecorator<TCommand>
: ICommandService<TCommand>
{
private static readonly Role PermittedRole = GetPermittedRole(); ⑧
private readonly IUserContext userContext;
private readonly ICommandService<TCommand> decoratee;
public SecureCommandServiceDecorator(
IUserContext userContext, ①
ICommandService<TCommand> decoratee)
{
this.decoratee = decoratee;
this.userContext = userContext;
}
public void Execute(TCommand command)
{
this.CheckAuthorization(); ②
this.decoratee.Execute(command);
}
private void CheckAuthorization()
{
if (!this.userContext.IsInRole(PermittedRole)) ④
{
throw new SecurityException();
}
}
private static Role GetPermittedRole()
{
var attribute = typeof(TCommand) ⑤
.GetCustomAttribute<PermittedRoleAttribute>();
if (attribute == null)
{
throw new InvalidOperationException( ⑥
"[PermittedRole] missing."); ⑥
}
return attribute.Role;
}
}
Gets the role permitted to execute this command
The Decorator depends on an IUserContext that allows it to check the current user’s role.
Before delegating the call to the decoratee, verifies whether the user is allowed to execute this operation
In case the user isn’t part of the specified role, throws an exception. This lets the operation fail fast. Logging of the exception can be done higher up the call stack.
Uses reflection to get the PermittedRoleAttribute specified on the command type
In case no attribute is defined on the command type, you could assume that every user is allowed to execute the command, but that would be a security risk. Instead, it throws an exception, forcing every command to have the attribute applied.
You can specify authorization on commands and other message types in many ways. Here are some ideas:
MyApp.Domain.Commands.Administrator
and let the Decorator analyze this namespace. This also gives you a nice intuitive project structure because commands are grouped by their permitted role.14 For inspiration on how to handle row-based security, see this online discussion: https://github.com/dotnetjunkie/solidservices/issues/4.
We could give you tons of examples of Decorators that can be wrapped around business transactions, but there’s a limit to the number of pages a book can have. Besides, at this point, we think you’re starting to get the picture about how to apply Decorators on top of ICommandService<TCommand>
. Let’s piece everything together inside the Composition Root.
In the previous sections, you declared three Decorators implementing security, transaction management, and auditing. You need to apply these Decorators around a real implementation in your Composition Root. Figure 10.4 shows how the Decorators are wrapped around a command service like a set of Russian nesting dolls.
If you apply all three previously defined Decorators to your Composition Root, you end up with the code shown next.
Listing 10.20 Decorating AdjustInventoryService
ICommandService<AdjustInventory> service =
new SecureCommandServiceDecorator<AdjustInventory>(
this.userContext,
new TransactionCommandServiceDecorator<AdjustInventory>(
new AuditingCommandServiceDecorator<AdjustInventory>(
this.userContext,
this.timeProvider,
context,
new AdjustInventoryService(repository))));
return new InventoryController(service);
Because the application is expected to get many ICommandService<TCommand>
implementations, most of the implementations would require the same decorators. Listing 10.20, therefore, would lead to lots of code repetition inside the Composition Root. This is something that’s easily fixed by extracting the repeated Decorator creation into its own method.
Listing 10.21 Extracting the composition of Decorators to a reusable method
private ICommandService<TCommand> Decorate<TCommand>(
ICommandService<TCommand> decoratee, CommerceContext context)
{
return
new SecureCommandServiceDecorator<TCommand>(
this.userContext,
new TransactionCommandServiceDecorator<TCommand>(
AuditingCommandServiceDecorator<TCommand>(
this.userContext,
this.timeProvider,
context,
decoratee)))); ①
}
Wraps he decoratee parameter in the list of Decorators
Extracting the Decorators into the Decorate
method allows the Composition Root to be completely DRY. The creation of AdjustInventoryService
is reduced to a simple one-liner:
var service = Decorate(new AdjustInventoryService(repository), context);
return new InventoryController(service);
Chapter 12 demonstrates how to Auto-Register ICommandService<TCommand>
implementations and apply Decorators using a DI Container. Because this almost brings us to the end of this section about using SOLID principles as a driver for AOP, let’s reflect for a moment on what we’ve achieved and how this relates to the bigger picture of application design.
In this chapter, you refactored the domain layer’s big IProductService
, which consisted of several command methods, into a single ICommandService<TCommand>
Abstraction, where each command got its own message and associated implementation for handling that message. This refactoring didn’t change any of the original application logic; you did, however, make the concept of commands explicit.
An important observation is that these domain commands are now exposed as a clear artifact in the system, and their handlers are marked with a single interface. This methodology is similar to what you implicitly practice when working with application frameworks such as ASP.NET Core MVC. MVC Controllers are typically defined by inheriting from the Controller
Abstraction; this allows MVC to find them using reflection, and it presents a common API for interacting with them. This practice is valuable at a larger scale in the application’s design, as you’ve seen with these commands where you gave their handlers a common API (a single Execute
method). This allowed aspects to be applied effectively and without code repetition.
Besides commands, there are other artifacts in the system that you might want to design in a similar fashion in order to be able to apply Cross-Cutting Concerns. A common artifact that deserves to be exposed more clearly is that of a query. At the start of section 10.3.3, after you split up IProductService
into a read and write interface, we focused your attention on IProductCommandServices
and ignored IProductQueryServices
. Queries deserve an Abstraction of their own. Due to space constraints, however, a discussion of this is outside the scope of this book.15
15 For a discussion of such an Abstraction, see Steven van Deursen, “Meanwhile... on the query side of my architecture” 2011, https://cuttingedge.it/blogs/steven/pivot/entry.php?id=92.
Our point, however, is that in many types of applications, it’s possible to determine a commonality between groups of related components as you did in this chapter. This might help with applying Cross-Cutting Concerns more effectively and also supplies you with an explicit and compiler-verified coding convention.
But the goal of this chapter wasn’t to state that the ICommandService<TCommand>
Abstraction is the way to design your applications. The important takeaway from this chapter should be that designing applications according to SOLID is the way to keep applications maintainable. As we demonstrated, this can, for the most part, be achieved without the use of specialized AOP tooling. This is important, because those tools come with their own sets of limitations and problems, which is something we’ll go into deeper in the next chapter. We have found, however, a certain set of design structures to be applicable to many line-of-business (LOB) applications — an ICommandService
-like Abstraction being one of them.
This doesn’t mean that it’s always easy to apply SOLID principles. On the contrary, it can be difficult. As stated previously, it takes time, and you’ll never be 100% SOLID. Your job as software developers is to find the sweet spot; applying DI and SOLID at the right moments will absolutely boost your chances of getting closer to that.
DI shines when it comes to applying recognized object-oriented principles such as SOLID. In particular, the loosely coupled nature of DI lets you use the Decorator pattern to follow the OCP as well as the SRP. This is valuable in a wide range of situations, because it enables you to keep your code clean and well organized, especially when it comes to addressing Cross-Cutting Concerns.
But let’s not beat around the bush. Writing maintainable software is hard, even when you try to apply the SOLID principles. Besides, you often work in projects that aren’t designed to stand the test of time. It might be unfeasible or dangerous to make big architectural changes. At those times, using AOP tooling might be your only viable option, even if it presents you with a temporary solution. Before you decide to use these tools, it’s important to understand how they work and what their weaknesses are, especially compared to the design philosophy described in this chapter. This will be the subject of the next chapter.
18.119.142.232