Adapting to change - AWS Prescriptive Guidance

Adapting to change

Software systems tend to get complicated. One reason for this could be frequent changes to the business requirements and little time to adapt the software architecture accordingly. Another reason could be insufficient investment for setting up the software architecture at the beginning of the project to adapt to frequent changes. Whatever the reason, a software system could get complicated to the point where it is almost impossible to make a change. Therefore, it is important to build maintainable software architecture from the beginning of the project. Good software architecture can adapt to changes easily.

This section explains how to design maintainable applications by using hexagonal architecture that adapts easily to non-functional or business requirements.

Adapting to new non-functional requirements by using ports and adapters

As the core of the application, the domain model defines the actions that are required from the outside world to fulfill business requirements. These actions are defined through abstractions, which are called ports. These ports are implemented by separate adapters. Each adapter is responsible for an interaction with another system. For example, you might have one adapter for the database repository and another adapter for interacting with a third-party API. The domain is not aware of the adapter implementation, so it is easy to replace one adapter with another. For example, the application might switch from a SQL database to a NoSQL database. In this case, a new adapter has to be developed to implement the ports that are defined by the domain model. The domain has no dependencies on the database repository and uses abstractions to interact, so there would be no need to change anything in the domain model. Therefore, hexagonal architecture adapts to non-functional requirements with ease.

Adapting to new business requirements by using commands and command handlers

In classical layered architecture, the domain depends on the persistence layer. If you want to change the domain, you would also have to change the persistence layer. In comparison, in hexagonal architecture, the domain doesn’t depend on other modules in the software. The domain is the core of the application, and all other modules (ports and adapters) depend on the domain model. The domain uses the dependency inversion principle to communicate with the outside world through ports. The benefit of dependency inversion is that you can change the domain model freely without being afraid to break other parts of the code. Because the domain model reflects the business problem that you are trying to solve, updating the domain model to adapt to changing business requirements isn’t a problem.

When you develop software, separation of concerns is an important principle to follow. To achieve this separation, you can use a slightly modified command pattern. This is a behavioral design pattern in which all required information to complete an operation is encapsulated in a command object. These operations are then processed by command handlers. Command handlers are methods that receive a command, alter the state of the domain, and then return a response to the caller. You can use different clients, such as synchronous APIs or asynchronous queues, to run commands. We recommend that you use commands and command handlers for every operation on the domain. By following this approach, you can add new features by introducing new commands and command handlers, without changing your existing business logic. Thus, using a command pattern makes it easier to adapt to new business requirements.

Decoupling components by using the service façade or CQRS pattern

In hexagonal architecture, primary adapters are responsible for loosely coupling incoming read and write requests from clients to the domain. There are two ways to achieve this loose coupling: by using a service façade pattern or by using the command query responsibility segregation (CQRS) pattern.

The service façade pattern provides a front-facing interface to serve clients such as the presentation layer or a microservice. A service façade provides clients with several read and write operations. It’s responsible for transferring incoming requests to the domain and mapping the response received from the domain to clients. Using a service façade is easy for microservices that have a single responsibility with several operations. However, when using the service façade, it is harder to follow single responsibility and open-closed principles. The single responsibility principle states that each module should have responsibility over only a single functionality of the software. The open-closed principle states that code should be open for extension and closed for modification. As the service façade extends, all operations are collected in one interface, more dependencies are encapsulated into it, and more developers start modifying the same façade. Therefore, we recommend using a service façade only if it’s clear that the service would not extend a lot during development.

Another way to implement primary adapters in hexagonal architecture is to use the CQRS pattern, which separates read and write operations using queries and commands. As explained previously, commands are objects that contain all the information required to change the state of the domain. Commands are performed by command handler methods. Queries, on the other hand, do not alter the state of the system. Their only purpose is to return data to clients. In the CQRS pattern, commands and queries are implemented in separate modules. This is especially advantageous for projects that follow an event-driven architecture, because a command could be implemented as an event that is processed asynchronously, whereas a query can be run synchronously by using an API. A query can also use a different database that is optimized for it. The disadvantage of the CQRS pattern is that it takes more time to implement than a service façade. We recommend using the CQRS pattern for projects that you plan to scale and maintain in the long term. Commands and queries provide an effective mechanism for applying the single responsibility principle and developing loosely coupled software, especially in large-scale projects.

CQRS has great benefits in the long term, but requires an initial investment. For this reason, we recommend that you evaluate your project carefully before you decide to use the CQRS pattern. However, you can structure your application by using commands and command handlers right from the start without separating read/write operations. This will help you easily refactor your project for CQRS if you decide to adopt that approach later.

Organizational scaling

A combination of hexagonal architecture, domain-driven design, and (optionally) CQRS enables your organization to quickly scale your product. According to Conway’s Law, software architectures tend to evolve to reflect a company’s communication structures. This observation has historically had negative connotations, because big organizations often structure their teams based on technical expertise such as database, enterprise service bus, and so on. The problem with this approach is that product and feature development always involve crosscutting concerns, such as security and scalability, which require constant communication among teams. Structuring teams based on technical features creates unnecessary silos in the organization, which result in poor communications, lack of ownership, and losing sight of the big picture. Eventually, these organizational problems are reflected in the software architecture.

The Inverse Conway Maneuver, on the other hand, defines the organizational structure based on domains that promote the software architecture. For example, cross-functional teams are given responsibility for a specific set of bounded contexts, which are identified by using DDD and event storming. Those bounded contexts might reflect very specific features of the product. For example, the account team might be responsible for the payment context. Each new feature is assigned to a new team that has highly cohesive and loosely coupled responsibilities, so they can focus only on the delivery of that feature and reduce the time to market. Teams can be scaled according to the complexity of features, so complex features can be assigned to more engineers.