Working on different projects I often see the same problems with application design.
TL;DR: The common mistake is when different application architecture layers are mixed together.
- Stiffness: system is very hard to change. Modification in one class may cause changes in many other classes (snowball effect).
- Instability: change in one part breaks the system in different part, not directly related to the part to the changed component.
- Hard to reuse code: it is very difficult to extract and reuse code
- Viscosity: It is hard for developer to add design-preserving code to a system. It is much easier to add a hack than it is to add code that fits into the program’s design.
- High complexity: project includes infrastructure which does not bring obvious benefits.
- Uncertainty: source code is hard to read and understand.
The root cause of this situation is not following SOLID principles.
Layered architecture can describe relatively simple applications having very limited communication points. In layered architecture following layers are defined:
- Presentation Layer: user interface or views. Let’s focus on web-services providing only API and leave out of scope.
- Service/API Layer: (or Controller layer) is a highest layer of application architecture providing external API or consumed by Presentation layer.
- Business Layer: business-logic specific data models and services.
- Integration Layer or DataAccess Layer: persistence, logging, communication/messaging and other services which are required to support a particular business layer.
More complex applications with many integration points are better described by hexagonal architecture.
Hexagonal architecture separates internal (domain and application) and external (ports and adapters) parts of the system.
- External parts: Ports defines protocols (interfaces) and Adapters (see GoF pattern) are implementation
- Internal parts:
- Domain is the central layer which contains all the business logic and business logic constraints. The domain layer responds in a technology independent way to whatever is being done in your architecture.
- Application layer sits in between the domain and the framework and allows for communication between the two layers. Despite the name, it is NOT the actual application, but rather it applies the commands that the framework receives and sends them to the domain.
Identifying Bad Application Design
There is a common pattern in application architecture which indicates bad design like litmus paper.
In badly designed application all those layers/parts are mixed together. An exception from this rule is when application is really, really tiny, single-purpose micro-service or utility. In this case multiple layers will only complicate things.
In layered architecture you may easily identify it by inspecting external API (service) and persistence model classes. If persistence and API models have shared classes or classes on different layers have common ancestor – that it indicates that Single Object Responsibility Principle is violated. Random change in one adapter model will likely cause unexpected change in external API, break API clients and/or expose sensitive data.
In hexagonal design the indicator is a dependency between domain and framework components, when domain depends on the framework, or dependency between different ports and adapters, when one adapter depends on another.
Fixing badly designed application
Good news that in many cases it is possible to improve your application design without rewriting everything from scratch. Rewriting is always a business risk: people almost always underestimate efforts due to lack of knowledge of existing system. Also, it delays implementation of new features unless new version is ready, or team performs double work to implement same features in «old» and «new» versions.
If you inherited application from other developers, who are may not be there anymore, you will definitely don’t know something about requirements, functionality and reasons, why one or another technical decision was made.
If current system is functional and meets business and performance requirements in general, then it could be improved. Move step by step, don’t change many things at once. Deploy often (daily!), monitor business and performance metrics, it and be ready to rollback. «Canary releases» could be good deployment strategy if you don’t have a luxury to be able to break things on production.
Step 1: Covering functionality with tests
Badly designed applications are fragile. That is why it is important to have comprehensive regression tests before you start improving things.
Try to make as many functional tests as possible treating tour system as black box.
It may seem heresy, but do not invest much time in unit tests. You will most likely redesign internal components soon, and unit tests will be thrown away. Of course, you may cover with unit tests those classes or components that are stable and supposed to stay. But prefer higher-level tests to lower-level. Yes, I know, it violates «test pyramid». But we’re not writing something new but preparing for a big refactoring now.
Step 2: Separating API layer
There is rule from ancient greeks and romans to help simplifying things: διαίρει καὶ βασίλευε / «dīvide et imperā» or «divide and conquer». Yes, it seems that software engineering is more about philosophy than about engineering :)
In hexagonal model domain classes should not depend on communication framework(s).
Just by separating external and internal models you will get a lot of confidence and flexibility. You will be able to change business logic without affecting external clients and vice versa. Also, minor changes in user interface or API format will not cause changes in business logic or in how data is stored or is transferred any more.
In case of web service, the best way is to reverse-engineer an API contract and convert service layer to contract-first. In case of REST API, you can use swagger/OpenAPI or RAML to define API specification + code generators like OpenAPI generator or RAML tools. If your application uses different external protocol, then you may find something specific. For high-traffic services Google Protobuf is a good solution.
Depending on your case, just separating API will bring a lot of benefits itself.
Step 3. Separating integration layers
In many cases application needs separate business persistent/integration layers.
When your service consumes other external service, then business and persistence models should not be shared with API client models. This allows you to handle communication protocols changes independently.
Also, having persistence layer separated helps to tune performance.
Every application should have owner and receive regular attention. Keeping application in good shape is an ongoing process. It requires investing some efforts on eliminating technical debt and evolving system architecture on-time. Applying SOLID principles eventually helps to keep things simple and maintainable.