The Non-Dogmatic, Vertical-Slice Architecture
In my experience working with various kinds of software applications, I have seen quite a bit of large codebases that are just difficult to work with. What do I mean by difficult? For example:
- Huge service classes, e.g., several hundreds of lines and 10+ dependencies.
- Incidental complexity, brought about by lots of layers
- High coupling, low cohesion, across different classes. A single functionality is “spread around” the application, and in order to understand it, you need to jump to a lot of different places.
- When you change one thing, you have a series of cascading changes.
- The code is not built defensively. Correctness relies on the developers' experience with the codebase, or in general, on developers' best intentions.
In this post, I'd like to expose some software architecture principles I've been following for some time when I develop new applications or new features.
If you're interested in seeing some Java code, I've created a companion Github repository for this article.
Principles
Don’t be dogmatic
Don’t be locked into a single, layered, architecture for the whole application. Instead, realize that your application serves your business, and each business action might require a slightly different approach.
Don’t assume that, at the beginning of your development, you can find the one layered architecture which will be a great fit for your entire application. It just doesn’t work that way. Too many applications start with the best of intentions by defining THE architecture they will use and then over time, they simply morph into big balls of mud as this architecture is misunderstood and gets abused.
Instead, start simple. A simple business action might start off with a transaction script. After that, you can add more classes and more layers IF the business action requires more steps to be fulfilled.
Start simple, then refactor. But always try to keep it simple, don't add incidental complexity for the sake of it. Don't be dogmatic.
Focus on use cases
An application serves your business. It exists so that, ultimately, actors (i.e., people or other systems) can view and mutate data.
Therefore, your application should be focused on your business use cases (i.e., the actions that each actor can perform).
For example, if we are building a blogging application, we might have use cases such as:
- create a new draft blog post
- update the details of a blog post
- change the state from draft to published
- view a summary of all blog posts
- view the details of one blog post
- view the edit history of a blog post
- attach a file to a blog post
Remember: as a software engineer, you are generally not paid to create layers. You are paid to create features. Layers and architectures emerge over time to help you create features and maintain existing features over time, not the other way around.
So, focus on your features first, and let the architecture evolve over time. Let the architecture serve your features, not the other way around. Eventually, you might find that some of your features are best implemented using a layered architecture (e.g. hexagonal, clean, etc.) That's great! It just shouldn't be your starting point.
This requires experience, so if you are a less experienced engineer or team, try to find more experienced practitioners to guide you through this phase.
Vertical Slices
Create a separate vertical slice for each use case. Don’t be dogmatic, so don’t assume that a vertical slice must look the same as other slices. Instead, let each vertical slice start simple, then add complexity whenever needed.
If two or more slices need to perform the same business steps (e.g., check validation rules) then move this shared responsibility somewhere else. Give this share responsibility its own place, make it a first-class citizen in your application, and make it fully testable.
The vertical slice architecture is well documented on the web. For example:
- https://www.jimmybogard.com/vertical-slice-architecture/
- https://www.milanjovanovic.tech/blog/vertical-slice-architecture
- https://codeopinion.com/is-vertical-slice-architecture-better-than-clean-architecture-or-ports-and-adapters/
- https://medium.com/@andrew.macconnell/exploring-software-architecture-vertical-slice-789fa0a09be6
- https://www.baeldung.com/java-vertical-slice-architecture
- https://www.javacodegeeks.com/vertical-slice-architecture.html
- https://symphony.is/about-us/blog/simplifying-development-with-vertical-slices
Use packages to achieve vertical slices
So how would an application look like, when it adopts the vertical slice architecture?
The package organization could look like:
com.example.blogapp
+- domain
+- post
+- Post.java
PostService.java
+- persistence
+- post
+- PostJpa.java
PostRepository.java
PostJpaMapper.java
+- usecases
+- createpost
+- CreateBlogController.java
CreateBlogHandler.java
CreateBlogException.java
CreateBlogRequestDto.java
CreateBlogResponseDto.java
CreateBlogMapper.java
+- publishpost
+- PublishPostController.java
PublishPostHandler.java
PublishPostRequestDto.java
PublishPostResponseDto.java
PublishPostMapper.java
Use package-private classes
When you decompose an application into vertical slices, you tend to increase cohesion. This means that related classes can coexist in the same Java package. These classes can all be package private.
Don't mark every class as public, just because your IDE does that by default.
Only classes that need to be shared across multiple vertical slices should be marked as public. Those classes become your internal API. Each public class carries a responsibility with it, i.e., the responsibility of exposing a service to other parts of your application. It's an important responsibility, so use it judiciously and sparingly.
Follow the single responsibility principle
Each class should have a single business responsibility (note the emphasis on business, I’ll get back to that in a second). Ideally, this translates into one or few public methods.
As an exercise in single responsibility principle, I like to add a small comment at the top of a class. Can I describe, in a sentence or two, what the class does? If yes, then the class has a single responsibility.
But beware, the responsibility should be about the business, it shouldn’t be a technical responsibility.
What do I mean by that? Well, here’s an example. Suppose you have a huge controller class which groups together several APIs. You could say that the responsibility of this class is to provide HTTP operations for your entity (e.g. a blog post). That sounds like a single responsibility, but it is a single technical responsibility, which revolves around a transport layer (HTTP). From a business perspective, instead, I argue that controller has several responsibilities:
- create a new blog post
- get a list of all blog posts
- get a single blog post
- update the blog post
- change the state of the blog post to “published”
Think about how you’re going to test this controller. The complexity of the test class for this controller explodes. A hypothetical BlogPostControllerTest class will end up containing hundreds of tests, mostly unrelated to one another, because the controller itself contains several methods that are unrelated to one another.
Think about how many dependencies you need to inject into this controller. And if you say “the only dependency I need to inject is the service”, then you’re just pushing the problem one layer down.
Embrace CQRS
If you slice your application into vertical slices and you follow the single responsibility principle, you basically get CQRS for free.
You will have use-cases that mutate the state of the system (e.g., create an entity, change the state, etc.). These are the commands.
And, you will have other use-cases that retrieve and return a view of the state (e.g., get a summary of an entity, or get a summarized list of all entities, or get a complex view which connects several entities together). These are the queries.
Each command and each query is implemented differently. Commands MUST ensure data consistency, while queries don’t care so much about data consistency, they are more focused on data projections.
Data consistency and data projections are two distinct concerns, and they don’t need to share much logic at all.
Adopt a rich domain model in commands
Your application must ensure data consistency. It must protect itself from invalid states. A rich domain model can help with that.
With a rich domain model, your command use cases don’t simply go to the database and update the data directly. Instead, commands must use domain method calls in order to perform mutations.
The responsibility of a rich domain model is to ensure data consistency for mutations. It does that by:
- providing operations that use the ubiquitous language
- not depending on anything else, and therefore being easy to test
Don’t do this:
class PublishBlogHandler {
void assign(long postId, String email) {
PostJpa postJpa = caseRepository.getById(caseId);
// Data is mutated outside of the domain model. Can it be assigned? Is that a valid email? These responsibilities are now pushed in this handler, instead of being given to the domain model.
caseJpa.setAssignee(email);
}
}
Instead, do this:
class CaseAssignHandler {
void assign(long caseId, String email) {
Case case = caseMapper.fromJpa(caseRepository.getById(caseId));
case.assignTo(email); // rich domain model, performs validations, etc.
CaseJpa caseJpa = caseMapper.toJpa(case);
caseRepository.save(caseJpa);
}
}
Note that, for queries, there is no need to go through the domain model, because for reads, there is no need to protect the application from invalid mutations.
The domain model has no dependencies
As mentioned above, the domain model’s responsibility is to ensure data consistency for mutations. As such, the domain model MUST have no dependencies. Several architectures (e.g. Clean, Hexagonal) place the domain model at the centre of the application to reinforce this concept.
Having no dependencies ensures that the domain model:
- has a single responsibility, i.e., mutate the data in a way that is consistent
- is easy to test and reason about.
Conclusion
By adopting these principles, I've seen applications flourish. I've heard developers express pleasure when working with codebases that are sliced vertically.
On the other hand, I've seen several applications crush under the complexity of several rigid layers. Rigid layers are very easy to apply and reason about, and at the same time they are very easy to misunderstand and abuse.
These principles promote a simple, feature-focused approach to writing software applications. Writing an application is not about creating the right layers, but it's about serving your business. Never forget that.