Authored by Casey Watson (AzureCAT). Edited by RoAnn Corbisier. Reviewed by Jonathan Gardner (AzureCAT), Michiel van Otegem (AzureCAT), and Shawn Weisfeld (One Commercial Partner).
This is the second article in the Cloud SOLID series. If you haven't done so already, you may want to begin by reading the first article, Cloud SOLID Part I: Cloud Architecture and the Single Responsibility Principle.
What is SOLID?
First introduced in 2003 by Robert “Uncle Bob” Martin, SOLID principles are a set of prescriptive guidelines that, when followed, can help developers write code that is easy to comprehend, maintain, and test. SOLID is a mnemonic device that makes it easy to remember these five principles:
- Single responsibility principle
- Open/closed principle
- Liskov substitution principle
- Interface segregation principle
- Dependency inversion principle
While these widely-adopted principles have guided a generation of developers in creating higher quality code, they were introduced at a time when the information technology landscape was virtually unrecognizable from what it is today. In 2003, there was no cloud. There were no containers or microservices. Applications were composed of monolithic, tightly-coupled codebases that were deployed to bare metal servers in private, on-premises data centers. Scalability meant adding additional CPU or RAM to existing servers. Development and operations lived in two disconnected worlds resulting in painfully long release cycles.
Today’s enterprises are adopting the cloud at an unprecedented rate. Along with this shift comes new architectural patterns that favor smaller, more granular services over large, monolithic applications and blur the lines between development and operations. While SOLID principles are still an essential tool for many developers, how can we adapt them to a cloud-enabled world?
This is the second in a series of five articles that focuses on extending SOLID, specifically the open/closed principle, beyond code into the service layer and further into the cloud.
Contoso’s journey to the cloud
In the previous article, we joined Contoso Outdoor Living, a fictional outdoor equipment retailer, as they embarked on their journey from on-premises to the cloud. Contoso is experiencing unprecedented growth and recently expanded its footprint in the outdoor fitness market through the acquisition of Adventure Works Cycling, a popular local cycling retailer. Along the way, we met Mike, Contoso’s CIO, who has committed to delivering an updated e-commerce website in response to rising competition from big box online retailers. Mike promised Contoso’s board that the new site would launch in six months. We also met Sharon, Lead Application Architect at Contoso, who leads the team responsible for developing the updated site.
After reviewing all their options, Mike and Sharon agree that the choice is clear—they choose Microsoft Azure. Not only does Azure meet all their application requirements, but it provides the agility and scalability that they need to continuously deliver new features and keep up with often unpredictable customer demand.
Mike realizes that it won’t be easy for Sharon and her team to meet the aggressive launch deadline and encourages Sharon to work closely with the newly acquired Adventure Works developers. While Sharon is relieved to have the additional resources, she soon encounters an unexpected obstacle: while Contoso builds their applications on the .NET platform almost exclusively, the Adventure Works developers come from a MEAN (MongoDB, Express, AngularJS, and NodeJS) background. Sharon soon realizes that by adapting the single responsibility principle—the “S” in SOLID—she can create a scalable, heterogenous architecture by breaking down the application into microservices that can be developed independently across different languages and platforms. As the .NET developers begin building out the ASP.NET MVC front end, the MEAN developers leverage Azure Functions to create a highly flexible NodeJS API layer. The current application architecture is shown below.
Implementing the order confirmation email feature
Once a customer has placed their order, they should receive an email confirming that their order is being processed. This isn’t a very complicated feature, but Kevin, the developer working on it, has several options to consider when implementing it. Certainly, the easiest option would be to send the email directly from the Save Order function, but is that the right choice? In the previous article, we learned about the single responsibility principle that states each class and, by extension, service should have only one responsibility or reason to change. Modifying the Save Order function to send the order confirmation email violates the single responsibility principle because the function would then have two responsibilities: saving the order information to the database and sending the order confirmation email. Instead, Kevin creates another function that is responsible only for sending the email. After persisting the order information to the database, the Save Order function posts the new order information to the Send Order Confirmation Email function API that then sends the email as shown in the diagram below.
Integrating with the ERP system
After the order has been saved to the database, it must be dispatched to the warehouse for fulfillment. Order processing is managed by Contoso’s ERP system, which is responsible for all warehouse functions including fulfillment, inventory control, purchasing, and scheduling. Fortunately, the ERP system exposes a REST API that can be used to initiate the fulfillment process. Kevin creates another function, Fulfill Order, that posts newly placed order information to the ERP API for processing. This approach not only adheres to the single responsibility principle but creates an abstraction that isolates interaction with the external ERP system. If the ERP API changes in the future, only the Fulfill Order function will need to be modified. Like the Send Order Confirmation Email function, the Fulfill Order function exposes its own API that the Save Order function calls after order information has been saved to the database.
Once Kevin has completed his changes, he deploys the updated functions to the team’s development environment and verifies that the Fulfill Order function works as expected. Satisfied that the feature is complete, Kevin commits his changes to his local Git feature branch, pushes the commit to the team’s Visual Studio Team Services repository, then submits a pull request to merge his changes into the master branch. Moments later, Kevin is unpleasantly surprised when he receives an email notification from Team Services indicating that his pull request has been rejected because a unit test failed during the continuous integration build. Kevin runs the project’s test suite locally and confirms that, indeed, an order processing unit test is now failing. When Kevin updated the Save Order function, he inadvertently introduced a small bug that caused the unit test to fail. It’s a minor bug—Kevin accidentally declared a new variable with the same name as a variable declared earlier in the function. Fortunately, through comprehensive unit testing and continuous integration, the bug was caught before it made its way to production. Kevin promptly debugs the function, pushes the updated code to the team’s repository, submits a new pull request, and successfully merges his changes into the master branch.
The open/closed principle
“software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification”
—Bertrand Meyer: Object-Oriented Software Construction
When a developer makes a change to an existing codebase, there’s always a risk of introducing new bugs. This isn’t a criticism of the developer’s abilities but merely a reality of software development. As an application continues to grow, the possibility of inadvertently introducing unexpected behavior increases with even the most minor change. The open/closed principle attempts to mitigate this side effect by favoring extension of existing software entities over modification. This principle encourages developers to treat extensibility as a first-class citizen and isolate areas of probable change when writing code. When combined with judicious test coverage and continuous integration, the open/closed principle can significantly increase overall application stability and enable shorter release cycles by reducing the need for extensive regression testing.
While the open/closed principle is arguably one of the simplest, it’s also one of the most misunderstood. To really understand it, imagine that you are building a house. Would you use your bare hands to nail two boards together? Of course not! Hopefully, you would use a hammer. But what if you could make your hands more hammerlike? Sure, it would probably make you much more efficient at hammering nails but practically it wouldn’t make much sense. What if you later need to use a power drill or a screwdriver? Instead, you use different tools to extend your hands. Your hands adhere to the open/closed principle because they are open for extension but closed for modification. This extensibility is a source of incredible flexibility that allows you to do everything from building a house to sending an email to preparing dinner. In software development terms, the hammer, power drill, and screwdriver all implement a common interface that your hands define.
Let’s revisit Kevin and the recently updated Save Order function. Before Kevin modified it, the Save Order function was concerned only with saving new order information to the database. The original developer that created the function also created a series of unit tests to help protect the function from future breaking changes. Fortunately, those unit tests prevented a potentially dangerous bug from making its way to production, but Sharon realizes that this incident is part of a much larger pattern of quality issues that is only getting worse as the application continues to grow. While these issues are relatively easy to correct now, Sharon knows that they will become much more difficult and potentially costly to correct after the site has launched. At the root of this problem is an overall lack of extensibility that forces the development team to modify existing, tested code any time a new feature is added.
In 1988 when Bertrand Meyer first introduced the open/closed principle, “software entities” were generally defined in programming terms like classes, modules, and functions. As application architecture continues to evolve, so must the way that we think about extensibility. While it’s important that the development team apply the open/closed principle while writing code, Sharon takes a broader view and considers how the principle can be applied to the overall application architecture to improve extensibility, decrease coupling between services, and limit future code changes.
Redesigning for extensibility and performance
Before we update the design, let’s briefly consider another problem with the current design that probably isn’t as obvious. What happens when a customer finishes shopping and clicks the “Save Order” button on the site? The order information is saved to the database, the confirmation email is sent, and the order is dispatched to the ERP system before the site responds to the customer. When only one customer is placing an order, this entire process typically takes less than a few seconds. But, what happens when 100 customers place their orders at the same time? How about 1,000 customers? As traffic to the site increases, performance suffers causing customers to, at best, become annoyed and, at worst, take their business to one of Contoso’s competitors. It’s important that the order information is saved as soon as a customer places their order, but does the application need to send the confirmation email before the site responds? Does the order information need to be immediately posted to the ERP system before the customer can continue to use the site? Probably not.
Kevin and Sharon return to the whiteboard to discuss how they can apply the open/closed principle to fix the extensibility and performance problems. How can they continue to build new order processing features without further modifying the Save Order function? How can they make the Save Order function more extensible? What if, instead of invoking the Fulfill Order and Send Order Confirmation Email functions directly, the Save Order function could publish an event when a new order is placed that those functions could simply subscribe to? After all, why should the Save Order function care about what happens after the order has been saved to the database? After passing the dry erase marker back and forth for almost an hour, Kevin and Sharon come up with the updated order processing architecture shown in the diagram below.
Originally, the Save Order function called the Send Order Confirmation Email and Fulfill Order function APIs synchronously. Before responding to the web front end, the Save Order function waited for each API call to complete introducing the potentially harmful performance bottleneck mentioned earlier in this section. Instead, the Save Order function now publishes the Order Placed event containing JSON-formatted order information, then immediately responds to the web front end allowing the customer to continue using the site with minimal delay. The Send Order Confirmation Email and Fulfill Order functions subscribe to and handle the Order Placed event asynchronously at some point in the future.
At the heart of this design is Azure Service Bus, a fully-managed cloud messaging service that enables a variety of asynchronous, brokered communication patterns between message publishers and subscribers. Specifically, after the Save Order function has saved the order information to the database, it publishes a message—the Order Placed event—to a Service Bus topic. Each Service Bus subscription associated with that topic receives a copy of the message. Instead of HTTP triggers, the Send Order Confirmation Email and Fulfill Order functions have been reconfigured to use Service Bus subscription triggers that execute when a subscription receives a new message. The Order Placed event is automatically passed to the Send Order Confirmation Email and Fulfill Order functions as a parameter when they are invoked. This configuration enables flexible, durable, one-to-many communication between the Save Order function and downstream order processing services.
From an architectural perspective, the Save Order function has been effectively decoupled from the Send Order Confirmation Email and Fulfill Order functions. The Save Order function is no longer concerned about what happens after the order has been saved to the database but provides an extension point through the Order Placed event. Consider, for a moment, a future requirement that customers receive an SMS notification once their order has been confirmed. In the updated design, it would be trivial to add a Send Order Confirmation SMS function that, like the Send Order Confirmation and Fulfill Order functions, subscribes to the Order Placed event. The Save Order function does not need to be updated.
The updated Save Order function adheres to the open/closed principle because it is open for extension through the Order Placed event but closed for modification.
The updated design also improves application availability. To illustrate this point, consider what would happen if the ERP API were to suddenly become unavailable. Obviously, the application would no longer be able to dispatch new orders to the ERP system causing the Fulfill Order function to fail. In the original synchronous design, this would also cause the Save Order function to fail making it impossible for customers to place new orders. The updated event-driven design, however, uses Service Bus and asynchronous messaging to isolate the Fulfill Order function from the rest of the application. Even if the ERP API were unavailable, the Save Order function would still save the order information, publish the Order Placed event, and respond immediately allowing the customer to continue using the site. If later the Fulfill Order function failed, it would abandon the Order Placed event message, automatically returning it to the front of the Service Bus subscription to be retried. If the ERP API problem is transient, it’s likely the Fulfill Order function will succeed the next time it tries to process the event. If, after several attempts (10 by default), the Fulfill Order function is still unable to process the event, it will be automatically moved from the subscription to a special dead-letter queue (DLQ) for later review and processing. The development team can further improve availability through consistent use of the Circuit Breaker and Retry patterns.
Unlike the previous design, the Save Order function now defines the contract—the Order Placed event—that downstream subscribers are required to handle. This is an important point because it highlights the dependency inversion principle—the “D” in SOLID—that will be discussed in greater detail in a future article. In short, the Save Order function no longer depends on the APIs that the Send Order Confirmation Email and Fulfill Order functions define. Instead, the updated approach inverts that design by requiring downstream consumers to depend on the Order Placed event that the Save Order function defines.
Embracing change through architecture
The observant reader may notice that, while the Save Order function is now more extensible, the Send Order Confirmation Email and Fulfill Order functions are not. That’s alright, though, because today, there’s no requirement that depends on the confirmation email being sent or orders being fulfilled. But, in the real world, requirements change all the time. Contoso’s legal department could approach Mike, the CIO, tomorrow with a new requirement that all outgoing customer emails must be logged and archived. How would Contoso’s development team implement this requirement? Given the current architecture, this requirement would obviously involve modifying the Send Order Confirmation Email function. This hypothetical situation highlights a very real point—for the open/closed principle to be applied effectively, it must be applied proactively. The development team can’t predict the future, but by keeping extensibility front of mind, they can limit the impact of future changes and respond more rapidly to changing business requirements. Realizing this, Sharon again updates the architecture to make the Fulfill Order and Send Order Confirmation Email functions more extensible.
The updated architecture implements the same pattern that Kevin and Sharon applied earlier when extending the Save Order function—the Send Order Confirmation Email and Fulfill Order functions now publish events to their own dedicated Service Bus topics. Given the updated architecture, implementing the email archival requirement mentioned earlier would be trivial. Instead of modifying the Send Order Confirmation Email function, the development team could simply create a new function, Archive Email, that subscribes to the Confirmation Email Sent event.
The future of event-driven architecture
What if the development team could focus solely on application logic and not worry at all about messaging infrastructure? That is the promise of Azure Event Grid. In public preview at the time of this writing, Event Grid simplifies the development of event-driven applications by providing intelligent routing of events from any source to any destination. Using Event Grid, Contoso’s application architecture could be easily extended to start a Logic App when a new order is placed or invoke a webhook when an order confirmation email is sent.
It’s important to weigh extensibility against complexity. The Send Order Confirmation Email and Fulfill Order functions both publish events that we know, at least for now, will not be subscribed to. The popular YAGNI (“you aren’t gonna need it”) software development principle teaches us that functionality should not be implemented until it’s required. Left unchecked, unnecessary complexity can make codebases brittle, less maintainable, and more difficult to test. But in this case, the additional complexity is minimal. Azure Functions bindings helps further reduce complexity by providing a declarative way to access external data and services. Using the Service Bus topic output binding, events can be published to a topic using only a single NodeJS statement.
It’s also important to consider cost when designing for extensibility, especially when building for the cloud. In the past, architects and developers were seldom concerned with the cost of running the applications they were building. In the age of the cloud, however, the design decisions that architects make daily can directly affect the cost of running an application. Choosing one service over another could mean a difference of tens of thousands of dollars over an application’s lifetime. Like most Azure services, Service Bus pricing is based on usage. With this design, the cost impact is negligible—in the Standard tier, which is required when using Service Bus topics, the first 12.5 million Service Bus operations are included in the base $10/month price. Compare this to the cost of not planning for extensibility up front. How much would it cost later, after the application has already been released, to implement a new feature in terms of development effort, regression testing, and potential downtime as existing components are updated?
Earlier in this article, we defined the open/closed principle as “software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.” We also joined Kevin and Sharon as they applied the open/closed principle to make the order processing architecture more extensible. The changes that they have made make it easier and safer for the development team to implement new features and respond more rapidly to changing business requirements. As mentioned in the previous article, each of the functions that make up the application’s back end, including the Save Order, Send Order Confirmation Email, and Fulfill Order functions, are essentially their own independent services. When we consider this definition, and examine Kevin and Sharon’s approach to application-level extensibility, we can augment the open/closed principle and say that services should be open for extension, but closed for modification.
In Robert Martin’s 2009 blog post “Getting a SOLID start,” he states that “The SOLID principles are not rules. They are not laws. They are not perfect truths. These are statements on the order of ‘An apple a day keeps the doctor away.’ This is a good principle, it is good advice, but it’s not a pure truth, nor is it a rule.”
The open/closed principle states that software entities should be “closed for modification,” but in the real world the need to modify existing code is an inescapable reality. Consider for a moment a future requirement that order information be stored in Cosmos DB instead of Azure SQL Database. This change would obviously require modifying the Save Order function. The goal of the open/closed principle is not to prevent changes completely but to limit them by emphasizing the importance of extensibility.