An independent and easily testable architecture is referred by Robert Martins in his blog post as The Clean Architecture. It's a summary of general architectures that results in clearly separated concerns. He focuses on talking about categories of components:
- Framework and drivers
- Interface adapter
- Application business rules
- Enterprise business rules
It's a good post about what an enterprise architecture should look like and what rules should not be broken when you create a growing system.
I will get more concrete and present you with some examples. The funny thing is that test ability will give you all these aspects. I will explain why and what signs that indicate some critical sections in your architecture. Keep in mind architecture is all about trade-offs. You always decide the pro and cons.
Our application's main task is to fulfill defined use cases. They require persistent data and we already want to be thinking about performance. Our application should be stateless to be scaleable.
Data access layer
So let's start from scratch. There will be some kind of database access. Many use-cases will require access to the database. One approach would be to create Gateway classes for every use-case. Another approach is to think in terms of entities and create entity-related gateways.
The upcoming class names a part of a player league scenario. I won't give you a complete overview. You will understand everything when you read this article. Classnames will be self-explaining and tests will do the rest. The sources will be available on GitHub.
Imagine you have a Player entity, that contains information about a player. It has attributes like username, gameCount or points. There will be an interface called PlayerGateway, that contains player related CRUD methods.
If there are entities that are very complex, you should add more categories of player gateways (sponsored players, mentor players, ...). It is worth considering entity based scalability. Will you get problems when it becomes more complex? If things are clear and you have a decent buffer for adding more complexity then you will always have a good feeling when you think about new features.
An concrete implementation is the HibernatePlayerGateway. Here you place the hibernate criteria implementation in this concrete implementation of the PlayerGateway. You should also add tests to make sure that your gateway works (HibernatePlayerGatewayTest).
It's about time to talk about testing. You probably ask yourself why you should test your data access layer. There are many reasons but let's talk about the key aspects.
The most important thing is to make sure that your code works. So there could be a driver update or one of your teammates could add some restrictions to your mapping file. You just want to make sure that your code works. So you define your requirements and test if everything works okay.
Another very important point is being able to develop your application as fast as possible. So imagine some kind of UI that communicates with your backend. For example having your single page application send requests to your back-end controller. This controller will convert the given data to a backend compatible format. After processing the data your component will send a list of entities back that will be processed in your frontend application. If there are no tests then you need to:
- Start your full-stack application
- Open the browser and navigate to the position that sends the targeted request to your backend
- If everything is okay then you will see the correct output in your single page application
There are so many things that can interrupt your process. And it's very slow. You need to think about restarting your backend or you need a byte code injection tool. Yes, there are tools that will fit your needs. But the point is the sum of the existing risks that something can interrupt your developing process and the slow nature of manual testing will slow you down. If something doesn't work then you have no possibility to test your logic partially. It will require a complete working stack and algorithm to see the positive result.
So you have a HibernatePlayerGateway and you have tests for it. You require some kind of InMemoryDatabase. A good practice for hibernate is to use another hibernate.cfg.xml that targets your in memory database. In production mode you will use another xml that contains your real database settings.
Business logic layer
The basic approach is to separate the business logic in two categories. The first is entity related behavior. These kind of logic are requirements that are global and does not change very often. An example is the the calculateAveragePointsPerGame method of the Player entity and it's tested within the PlayerTest.
The second category is use case related behavior. It is more complex than entity behavior. Both types are use case related but we will separate these types by their complexity. A class based use case use gateways to retrieve entities and contains use case specific logic. In our example the PlayerLeagueUseCase does contain more complex behavior and it is tested in the PlayerLeagueUseCaseTest. The differences compared to entity based logic are:
- Comprehensive entity dependencies
- There can be non entity dependencies
- It changes more often
So what's the gain if we separate these logic? We easily could put everything in an entity or in use cases. If we would put everything in entities then the class would contain a lot of logic. Every class that uses the entity would have to change if the entity changes. This could be very annoying. Also your target is to get an entity, access their main values and use them to fulfill your requirement. There should be no noise. In many cases the relevant class would just require a small piece of information that comes with this entity. It will result in a big messy class that will slow you down.
The second approach is to put everything in use-case classes. Here entities would just contain their attributes. If you require some additional logic you can use a use case or calculate the required value yourself. At this point we should think practically. In the beginning this approach is fine. But best practice has told me that there is very often required information about entities that will be defined once and after which will be used as often as real attributes. So these candidates could just move from use case logic to entity-related logic if there are no other dependencies.
So the result has practical roots and is used as a best practice which feels good if you are familiar with it.
Again I need to explain something about these tests. To profit from these tests we also need to mock our dependencies. We implement PlayerGateway in the InMemoryPlayerGateway to use it in the PlayerLeagueUseCaseTest. So why do we need to do that? Isn't it like doing things two times? Indeed it feels like it but let's talk about the possibilities and what we gain if we are successful.
- We could create a real instance of the implementation of PlayerGateway: HibernatePlayerGateway.
- We could use tools like JMockit
- We could create a custom implementation of PlayerGateway.
Each solution has its pros and cons. Typically it's subjective decision based on feelings rather than on pragmatic reason. There are scenarios in which you expend as little effort as possible to gain the most profit. Experience has told me that the third solution, creating an extra implementation for your tests, is the most arduous one in the beginning but you will profit from it as soon as your class becomes more complex. The biggest advantage is that you have no real dependencies. You can focus on creating your required behavior.
The second one is the fastest one. You just pick your real interface, use the mocking tool and define what you want to get on a specific call on your dependency class. You can also spy on method calls and check if the method is called up with parameter A and B. But the effort will increase the more complex your class becomes. Imagine your class depends on different methods of the dependency. One method sorts your result by ascending, one sorts by descending order, and another retrieves an unsorted list. You can use TDD and test every feature you need in your class. Here you have many mocks and if these data need to change you need to modify all your mocks. At this point you would profit from a fake implementation or indeed from using a real implementation. Short term, it is very easy and promising but you will become slower over time.
The first solution is the best trade-off both scenarios. Here you can just use your real implementation. But what if there are dependencies such as a database or other classes? Then this game starts from the beginning. You have to choose how to set the dependencies.
Request processing layer
The use-case related behavior is also well tested and can be extended as fast as in the beginning. The last key part is the way how we serve data. An simple example is the PlayerController. It does not a work as a web-server and this example won't work for now. I hope your get the key parts. You can test your controller classes but they should not contain any real logic. Their job is to delegate method calls and their payload. If they work then you will get a response. Partially working behavior is not possible at this stage.
If you have any acceptance test suites then these classes are also tested by comprehensive frontend / backend tests. If you have a service-orientated backend then you can fire requests to your system and expect a specific output or your e2e tests will create backend calls. At this point the complete stack will be tested. Your controller will undoubtedly cause these tests to fail if something is wrong.
It's worth considering whether you can find the cause if one test fails. If you just have e2e tests then you have no idea what the root cause is. If you have tests for your entities, data access and use-cases and they are working properly then you won't need much time to find an error in your controller layer.
The essence here is that you get a feeling about your healthiness of your application when you write tests. If you keep in mind that you don't want to slow down, then you will increase your development speed over time. Your code quality will be good and you will always know what your system is about, how it's progressing and any problems you have. If you make use of best practices like the clean architecture, then you can avoid problems that appeared in other projects. Anti-Patterns are avoided and you can profit from a SOLID structure that is extendable, maintainable and readable.
The GitHub examples don't mean reflect a low on how to create applications. They are merely simple examples of how to separate your logic in traditional use-case based software.