We have created this blog post for business stakeholders managing the development of a software system or product, those looking to find ways to reduce cost and time to market. This post will be useful for those whose technical background is a bit outdated or isn’t sufficient to make that judgment call. We will look at how to approach architecture planning so that the product is scalable and the money is spent wisely. Also, we will show the example of how CQRS can help in the implementation of client applications and whether microservices is indeed the panacea.
It is impossible to consider any software in isolation from the business context. Software products and systems are built to automate processes, improve customer experience, and assist the business to achieve KPIs. Software architecture is created with the business processes in mind, thus being a prerequisite for seamless product development, scaling, and optimization.
Cost
Good software architecture makes it cheaper to develop and maintain products due to the creation of reusable elements. Furthermore, it provides a sound basis for communication and straightforward decision making further down the line. Thus, it reduces negotiation times and boosts team effectiveness. In case architecting is ignored, making changes to the product in the later stages of the development process will be much more expensive.
Risk mitigation
The organization of the system, which business processes it takes part in and how it interacts with other systems and applications is determined by the architecture. Effective architecture identifies design risks and allows timely mitigation.
Competitive advantage
The effective architecture enables faster time to market. The ability to reduce time to market is vital in the environment of highly competitive markets. A business that is able to meet the needs and demands of the market ahead of others gains a competitive advantage.
Adaptable business-model
The highly competitive and dynamic environment also dictates that the business is adequately prepared when things change. The ability to transform the business model and introduce changes to the product is contingent on the well-thought-out architecture. The architecture roadmap should be aligned with the product roadmap and strategic plans for the business. Otherwise, strategic adaptability will be lost.
Technical solutions
Product architecture determines the technical solutions and standards. The choice of programming language, platform, framework, and tools determine the level of current and future cost. Technical solutions also affect the speed at which changes can be introduced to the product. This is critical in a situation when MVP isn’t coping with the influx of users.
Inevitably a question pertaining to architecture design arises — when should one start considering it, what style and patterns to use, and to what extent should the architecture be thought out?
We have come across different points of view. On the one side of the spectrum, there is “minimum viable product does not imply an architecture since it is put together as quickly as possible”, on the other — “microservices should be used from the outset so that scalability and flexibility are at the very core of the product”. These are two extremes, each potentially being quite costly. Below we will look at how to find a middle way.
Big Ball of Mud
A big ball of mud — a type of system with chaotic architecture — is not uncommon. Such systems take shape over time due to a lack of architecture design and planning. It all starts with a request by a business stakeholder: “we just need to get the first users to sign up, we’ll figure out later what to do with retention and how to improve the product.”
Iterative and incremental development is widely used. Generally, MVP is developed without thorough architecture design, and oftentimes even without paying much attention to nonfunctional requirements. This is followed by the series of iterations to incrementally add capabilities. Sometimes this results in technical debt and a system that can hardly function, which requires continuous fixing.
We know of many cases when an architectural focus was lacking. Every time that would lead to repercussions costing hefty amounts to correct. For example, a product crashing while not being able to cope with the large user load. Consequently, this leads to the team working long hours trying to put out the fires; the number of churned customers grows. The business fails to take off.
In other instances, the entire system may require re-coding. It takes more time and effort than all the preceding development work combined. Worst of all, the users have to learn how to use the system all over again, causing frustration and damaging loyalty on their part. As a result, jeopardizing future sales.
What is microservices
There are different approaches to architectural design. Microservices — one of the possible styles of architecture. Microservices are opposed to a monolithic architecture, in which different components are combined into a single indivisible unit.
Microservice architecture is created in small, independent, modular services. Each service is responsible for a specific task corresponding to a business capability. Services interact through the API (application program interface) — that is an intermediary that links all the elements together. Each API is programmed to fulfill a specific business goal. Using separate services allows you to use the most appropriate technical stack for each specific task.
What to pay attention to
Microservices is a rather popular approach to designing system architecture. Moreover, some clines insist on building architecture using microservices from the very outset, not knowing that this might be a very expensive approach. To reap the benefits, one should be able to design microservice in the right way. It isn’t enough to define a few independent modules and say that’s microservices.
When designing a microservice architecture, several concerns need to be addressed. For a start, how to trace a sequence of calls to different services that resulted in a mistake? Or how to identify which service is a bottleneck in the work of the entire system? Some of these concerns can be addressed with various degrees of success either by ready-made infrastructure solutions (for example, Elastic for logging) or custom infrastructure services.
Another thing to keep in mind is data integrity. Therefore, it is necessary to establish mechanisms for ensuring data integrity in advance. Unfortunately, this is often overlooked, which leads to unpleasant consequences in the form of data loss or desynchronization.
Moreover, in the early stages of the design process, segregating the system into independent services is a hard task at hand. The structure of the subject field is vague at this point and the exact functionality isn’t clear. Consequently, inappropriate segregation of services will lead to issues and additional costs when implementing required scenarios of work of the system. In some cases, the system simply will not function adequately.
Microservices may cost an arm and a leg
Depending on the scale of a system, the costs of deploying infrastructure for several services in several configurations, performance, and scalability checks may cost quite a hefty amount. Smaller projects for sure will cost less but these are expenses that are not necessarily justifiable.
More often than not, large investments in the architecture design at the early stages of product development are not justified. When working in a situation of uncertainty it’s hard to tell how many customers will get on board, whether the product will take off, or the business will close down.
A competent decomposition and description of the business capability holds a central place in the designing of a microservices architecture. Inadequate decomposition of the system into loosely coupled services may hinder revisions and make it impossible to trace and verify all system behavior scenarios. This will lead to failures in snowballing. To avoid this, a high level of understanding of how the system will work on all the levels, who and how will be using it is required. Therefore, it is critical to start thinking of and put a vision of the architecture at the very early stages of product design.
Example
Say, we build an e-commerce website. Good user experience is shaped by many things. Not the least by accurate order assembly by the Order Picker at the warehouse, convenient online payment and delivery options available to the customer. For that, a thought out the user interface for warehouse staff is required, as well as integration with payment systems and warehouse logistics system.
That is underpinned by a thorough understanding of at least the main business processes. Failure to capture business processes may result in omitting a big part of the requirements. In turn, this will result in an impractical system. Moreover, additional resources will be needed to refine the system. These are the expenses that otherwise could have been avoided.
The lack of funds is not uncommon in such situations since the money has been spent on features that were not relevant at the time. Without a thorough understanding of how the system services interact with one another, we may start building the wrong functionality at the wrong time.
An example of such functionality maybe a CRM system at a time when the customer does not require a unique functionality and a ready-made solution can be used. And only after having studied and understood the environment, can we justifiably decide on the choice of software architecture and decomposition of the system into loosely coupled services, etc.
So, where to start?
It is highly recommended to hold a one- or two-day workshop with a customer to not only define architectural solutions but also low-fidelity system design and main use scenarios.
In the case of startups, an extremely important step is to fill out a Business Model Canvas so that all the stakeholders understand the viability of the business idea. One of the artifacts of the workshop would be a document describing non-functional requirements for the system.
One of the outcomes of the workshop may be the decision to pull the plug on the project: it will help stakeholders see the shortcomings of the business idea without spending time and money on technical implementation. It may sound counterproductive, but even such an outcome can build a better understanding and trust between the client and the technical team.
Is the game worth a candle? Absolutely! It costs as little as 2 intensive days of work the team. If this step is left out, the majority of the mistakes in product development will cost considerably more to correct.
Over time, a business may change in response to external circumstances. For example, businesses all over the world are facing drastic changes right now. The world, as we know it, will never be the same again due to the pandemic of Covid-19. Businesses should always be ready to adapt and change their business models.
As mentioned above, this flexibility is underpinned by an effective architecture and by means of tried and tested practices, such as the CQRS pattern. For instance, using CQRS at the logical level at the beginning of the design process will enable us to physically locate the services as the business and product scale. This, in turn, will enable the reuse of the functionality by splitting it into independent components. At the early stages, it is not that important to have services physically segregate.
However, if you attempt to do that early on you may waste resources on monitoring and implementing distributed transactions and other dependencies that are not yet essential at the stage.
CQRS definition
CQRS (Command-Query Responsibility Segregation) is a popular and widely used software architecture design pattern.
It implies that all methods (i.e. certain sets of actions) in a program can be of two types: either requests that do not alter the state of the program or commands that alter the state of the program. In other words, queries can only read data, while commands update data.
What we did
In this project, we had a task to develop a system for a client managing a fleet of sea vessels. The software was to help our client to optimize and manage the automation of business processes. The client was very specific about the strict timelines and restricted budget so we had to find a way to deliver the system fast and in a cost-efficient manner. We opted for the approach of implementing CQRS at the level of logical architecture.
By observing the basic principles of SOLID, namely Dependency Inversion and Dependency Injection, and correct Inversion of Control Containers, all the commands and queries become easily manageable elements which can be reassembled elsewhere in the system.
The diagram depicts 3 parts of the system that can work similarly to the Model:
We noticed that the users, who use the Admin Office Desktop App in the office, more often read data from the vessels rather than make any data entries. And the users who are on board the vessels in the offline zone, on the contrary, more often submit data rather than read. Why does it matter?
It matters because whilst the architecture, as it is at this stage, is the foundation for further scalability of the system, it certainly has a greater potential to make further development, scalability, and maintenance of the system cheaper and trouble-free. If need be, it would be significantly easier to single out query contexts and build services with the DB optimized to enable reads, as well as to single out command contexts into separate services.
The following are a few examples of the main # C classes that you can use in your projects.
/// <summary>
/// Universal factory for creating queries.
/// To be implemented in Composition Root (web api project, website main prototype, etc)
/// </summary>
public interface IHandlersFactory
{
IQueryHandler<TQuery, TResult> CreateQueryHandler<TQuery, TResult>();
IAsyncQueryHandler<TQuery, TResult> CreateAsyncQueryHandler<TQuery,
TResult>();
ICommandHandler<TCommand> CreateCommandHandler<TCommand>();
IAsyncCommandHandler<TCommand> CreateAsyncCommandHandler<TCommand>();
}
/// <summary>
/// Basic interface for the command
/// </summary>
public interface ICommandHandler<TCommand>
{
void Execute(TCommand command);
}
/// <summary>
/// Basic interface for the query
/// </summary>
public interface IQueryHandler<TQuery, TResult>
{
TResult Execute(TQuery query);
}
/// <summary>
/// Factory Ninject to create standardised commands and queries
/// </summary>
public class NinjectFactory : IHandlersFactory
{
private readonly IResolutionRoot _resolutionRoot;
public NinjectFactory(IResolutionRoot resolutionRoot)
{
_resolutionRoot = resolutionRoot;
}
public IAsyncCommandHandler<TCommand> CreateAsyncCommandHandler<TCommand>()
{
return _resolutionRoot.Get<IAsyncCommandHandler<TCommand>>();
}
public IAsyncQueryHandler<TQuery, TResult> CreateAsyncQueryHandler<TQuery, TResult>()
{
return _resolutionRoot.Get<IAsyncQueryHandler<TQuery, TResult>>();
}
public ICommandHandler<TCommand> CreateCommandHandler<TCommand>()
{
return _resolutionRoot.Get<ICommandHandler<TCommand>>();
}
public IQueryHandler<TQuery, TResult> CreateQueryHandler<TQuery, TResult>()
{
return _resolutionRoot.Get<IQueryHandler<TQuery, TResult>>();
}
}
Examples of Binding of queries with Ninject
public override void Load()
{
// queries
Bind<IQueryHandler<GetCertificateByIdQuery, Certificate>>().To<GetCertificateByIdQueryHandler>();
Bind<IQueryHandler<GetCertificatesQuery, List<Certificate>>>().To<GetCertificatesQueryHandler>();
Bind<IQueryHandler<GetCertificateByShipQuery, List<Certificate>>>().To<GetCertificateByShipQueryHandler>();
………….
After the injection of IHandlerFactory into the class, you can use your commands and queries as follows.
Query example:
Ship ship = mHandlersFactory.CreateQueryHandler<GetShipByIdQuery, Ship>().Execute(new GetShipByIdQuery(id));
Command example:
mHandlersFactory.CreateCommandHandler<DeleteReportCommand>()
.Execute(new DeleteReportCommand(report));
By all means, all architectural decisions must be based on and developed according to the requirements and situation. No need to design the architecture for the entire product or system outright. The following questions will guide you through the decision making process.
It is also very important to allocate the Bounded Context in the system. This practice helps to depict the customer’s business structure, find a common language with a client, and manage the regression in the product. But we’ll cover this in the next article.
The basic architecture of the application was built in compliance with various principles and design patterns: MVVM, SOLID, CQRS, etc. This allowed reusing functionality in separate client applications. With that, the implementation did not take much time and was cost-effective.
By the time the development was to commence the team had already developed classes and the same level of understanding of the application architecture. In subsequent refinements, it fully paid off: about 40% of functionality could simply be flexibly reused. With this approach, the customer significantly reduced the cost of implementing functions by roughly 30–40%.
The Agile approach to development is mistakenly interpreted as “you don’t need to design anything in advance, just build, release and get the feedback. Should we need to improve the operational speed of the product or add new capabilities more rapidly, we’ll just throw microservices in.
At every corner, they saw how effective and versatile microservices are.”
Such a thoughtless approach, generally, results in a poor product and wasted cash. On the other hand, it is practically impossible to come up with a fully-fledged design right off the bat. A balanced approach is the best.
Firstly, having a vision for the product architecture provides a sound basis for current and future decisions. As a result, this will have a huge impact on the cost of the development as the product matures.
Secondly, documenting the adopted architectural decisions will allow for the understanding of certain decisions further down the line.
Thirdly, regular checks of the environment in which the product operates, allows to reduce the risks of failures and justifies the decisions regarding refinements or change.
Thus, giving some thought to what a product’s architecture will look like and using competent practices when writing code provides a solid foundation for scaling a product in the future.