Layered architecture gets a lot of flack.
Even though it’s still the most prevalent architecture, we view it as an anti-pattern. It’s old, not scaleable, and anti-SOLID. It encourages (shudder) monoliths!
The point is that even though it may not be an object-oriented nirvana, layered architecture is still a useful pattern. And if done right, it paves the way towards more advanced designs and architecture.
So let’s talk about layers.
What Are Layers?
This is the easy part. Layers are logical separations in your code. Components are grouped in horizontal layers. Each layer has a specific function.
They are not tiers. And they do not require that all layers are physically separated. Additionally, layers don’t concern themselves with how your files are organized. (Though you probably should.)
Layers separate different responsibilities of your application.
How Does That Help Me?
There are some non-trivial benefits to organizing your logical structure into layers.
- Your code will be easier to understand. Since this is one of the most widely used architectures, everyone has run into it and can follow it. The language used for most layers can be understood without much thought or explanation.
- The pattern is easy to follow when writing new features. There isn’t a lot of mental gymnastics you have to do in order to understand how everything is tied together.
- Testing is easier, as each layer is encapsulated and modular. Any calls outside the current layer should be using interfaces, which makes mocking in tests simple as well.
- Your application is easy to extend. Whether it’s adding additional components or modifying existing ones, there’s an easy pattern to follow.
See below a picture of the NDepend Dependency Graph used to visualize its own layered code.
So What’s With All the Hate?
There are disadvantages to the layered architecture approach. And some aspects of it have been deemed not architecturally pure for domain-driven design.
However, there are disadvantages to most approaches. The key is to understand both the advantages and disadvantages. Pick what architectural patterns work best for your particular problem.
Here are some factors you want to keep in mind.
- The layered architecture is very database-centric. As mentioned before, since everything flows down, it’s often the database that’s the last layer. Critics of this architecture point out that your application isn’t about storing data; it’s about solving a business problem. However, when so many applications are simple CRUD apps, maybe the database is more than just a secondary player.
- Scaleability can be difficult with a layered architecture. This is tied to the fact that many layered applications tend to take on monolithic properties. If you need to scale your app, you have to scale the whole app! However, that doesn’t mean your layered application has to be a monolith. Once it becomes large enough, it’s time to split it out—just like you would with any other architecture.
- A layered application is harder to evolve, as changes in requirements will often touch all layers.
- A layered architecture is deployed as a whole. That’s even if it’s modular and separated into good components and namespaces. But that might not be a bad thing. Unless you have separate teams working on different parts of the application, deploying all at once isn’t the worst thing you can do.
How Do I Keep Them SOLID?
So you’ve decided to use a layered architecture. What are some things you can do to keep your designs in line with SOLID principles?
Make Sure to Adhere to the Single Responsibility Principle
With the layered architecture, each layer has a specific responsibility. Now, what this doesn’t mean is that you have one service that takes care of all your business responsibilities for every function ever. As you may have noticed, each layer is also divided into components. These components also adhere to the single responsibility principle.
Components not only pertain to one feature’s responsibility; they also only have to deal with one facet of that responsibility. They deal with the presentation of your shipping charges or the business logic of calculating your shipping charges. Not both.
And components are made up of either subcomponents or classes. So what do these also need to adhere to? Single responsibilities!
Check Your Use of the Open-Closed Principle
The open-closed principle states that our objects should be open for extension but closed for modification. There isn’t anything inherent about layered architecture that would violate the open-closed principle. In fact, defining the components within layers can provide clear boundaries of where you can extend functionality.
Verify Your Hierarchy Follows the Liskov Substitution Principle
With Liskov’s substitution principle, classes should be replaceable with instances of their subtypes without altering the functionality. Now, as others here have pointed out, this one is broken quite often!
Fortunately, again, this is not one that’s too affected by layered architecture. You can break this principle just as easily with other architectural approaches.
Consider Your Clients by Using the Interface Segregation Principle
So what’s next on our SOLID path? The interface segregation principle states that
Clients should not be forced to depend upon interfaces that they do not use.
To ensure you’re not breaking this principle, keep your components small and interfaces to those components focused on their responsibility. Each layer in your architecture should be loosely coupled with the layers underneath.
When done right, only the adjacent layer will need to change when an interface contract changes.
And Step Lightly Around the Dependency Inversion Principle
Here things get a little tricky. First, what does the dependency inversion principle (DIP) require?
A. High level modules should not depend upon low level modules. Both should depend upon abstractions.
B. Abstractions should not depend upon details. Details should depend upon abstractions.
When looking at requirement A, it’s pretty simple. In its base form, dependency inversion is about relying on abstractions and not concrete implementations. That sounds easy enough—just use interfaces!
So what’s the catch?
Let’s look further. DIP also has a friend named “inversion of control” (IOC).
IOC occurs when the runtime and compile time of dependencies are opposite each other. Additionally, that point of inversion is a logical boundary between two different layers.
This is where we run into problems with the layered architecture. Since the data access layer is at the base of everything, it implies that it should own the data access interface. However, since the data access layer holds the details of data access, it shouldn’t actually own the interface. And if we’re following true DIP and IOC, we would end up with more of an onion or hexagontal architecture.
What Else Can I Do?
At this point, you might be asking if there’s anything that will make it easier to work with layers. Let’s discuss some options.
Choose the Right Number of Layers
The layered architecture does not predefine the layers you need. As a developer or architect, you have to decide what’s best for your application.
However, typically the answer is not “All the layers!” It’s three to six layers.
And remember that every additional layer you add makes the code more complicated and difficult to maintain.
Create the Proper Components
We can’t really get DIP from a layered architecture. So what can we do? We can make sure our components are properly broken down. We do that using namespaces.
Remember two important points.
First, don’t create mutual dependencies between layers and namespaces. No lower level namespaces should reference higher level namespaces. This leads to spaghetti code and tight coupling.
Second, avoid dependency cycles. You won’t be able to find these as easily. Dependency cycles occur when you have namespace A depend on namespace B. B then relies on namespace C. And then, oh, by the way, C relies on A. Using tools like NDepend will help find those cyclic dependencies and flush them out.
Is That All?
Yes, that’s it. Just remember that your architecture works well in some situations. It also doesn’t work well in others. And the simplest architecture may not win any awards for purest style. But it can be easy to maintain and enhance. You just have to understand the basics.
Write clean code. Check your dependencies. And use tools to find the trouble spots.