The monolithic but modular architecture can be a great choice. Especially for most of the new projects because it helps quickly and cheaply verify the idea.
In one of my previous articles, you can read about modularising Laravel applications. Here I will discuss communication between modules.
When building an application using a monolithic architecture, we want to preserve its advantages, simultaneously avoiding common pitfalls.
The biggest disadvantage of a monolith is susceptibility to becoming the big ball of mud, full of tangled interdependencies, and deprived of clear boundaries between modules. We should also take into consideration the fact that we might want to divide our application into microservices in the future. Another important thing is that if the company grows, each module should be developed by a separate team, with minimal coordination overhead.
That’s why modules integration is such an important thing.
The most important rule is that modules cannot depend on other modules.
There is one exception to this rule — shared module, it can be called SharedKernel, Core, or whatever you want. Ideally, this module should not contain any business logic but only useful abstractions and technical implementations of processes like transactions, notifications, maybe some base exception classes, etc. The shared module is a candidate for becoming a package(composer, npm, maven, whatever you use) while dividing an application into microservices.
In my projects, I use a layer that I call “Integration” for intermodular communication. Integration is just a package/namespace inside which classes can depend on classes declared inside other modules. This is the only place when it is allowed. What is also important, this layer should be as thin as possible and do not contain complicated logic.
In my case, it is further divided into two parts — “Adapters” and “Listeners”.
Adapters adapt classes from other modules while implementing interfaces declared within their module. This enables us to write code that depends on interfaces declared within the module while receiving the adapter as an implementation in runtime. Adapters also speed up the development — if the class which our adapter will adapt is not ready yet to meet our new requirement, we simply declare an interface and start writing code that uses this interface. Implementation will be delivered later.
In my opinion, adapters should almost always act like queries (I mean methods that return value, not database queries), not commands. If you need to adapt something to tell another module to do something, probably your modules have the wrong boundaries.
Listeners as the name suggest, are reacting to the events declared in other modules. We need them because as the previous sentence says, events are declared in other modules, and therefore they can be referenced only inside the Integration layer. Intermodular events should contain only primitive data types, it is very important because we might want to listen for those events in other applications/contexts.
Listeners should be very thin classes, usually, they do only one thing — calls application service from their module with data extracted from the event. We can say that they act as a very simple bridge between modules.
You should also keep in mind that, if you need synchronous events to communicate your modules with each other, then something is wrong with their boundaries. Properly divided modules should not contain the data which has to change atomically.
As said before, clear separation between the modules is a must if we want to build a maintainable application, therefore usage of tools that can help achieve that level of separation is highly encouraged.
Deptract is a very popular tool, but I don’t like to use it because of its complicated, inconvenient configuration.
My tool of choice is PHP Architecture Tester (unfortunately not supports php 8 yet) . That package runs test against our code, performing static code analysis, what is very important, the tests are written in PHP and very intuitive to create. I encourage You to include architecture tests in Your CI/CD pipeline and treat them as a “fitness function” for the project architecture, but remember to put them in one of the later stages because they run a little bit slow.
Sometimes keeping dependencies clean seems to be a little bit time-consuming, but after all, working with tangled dependencies and spaghetti code consumes far more resources than creating some clean integrations.
Keeping dependencies under control makes maintenance and future development far easier especially when the application is growing fast.