Dependency Inversion for Entities - software architecture
Problem to solve:
- We create a core software entity, for example a Product - this will become root or parent entity
- Then we create multiple "child" entities that depend on Product. For example Order and Warranty.
- We have business rules for the "parent" entity that depend on "child" entities. For example you cannot remove a Product if there are in-flight Orders; or if the Warranty period is not over for all the instances of that Product
- We want to avoid putting all those business rules in Product, because such "tight coupling" would make the software harder to maintain as the system grows.
Solution:
TLDR:
It helps to imagine the entities Product, Order and Warranty as defined in separate software modules, either in a modular monolith or in a microservice architecture. This will highlight the loose coupling we want to achieve. However, the actual implementation might reside in a single application or microservice.
While traditionally these constraints between entities are often defined as database constraints, a more loose coupled approach is to move those dependencies at business code level (think Java code). It helps to imagine a microservice architecture because separate databases forces you to give up on database level constraints.
- we keep the Product module completely unaware of the existence of the child entities (Order, Warranty).
- Product entity offers generic interfaces to register software hooks from child entities, for various cases (like Create, Read, Update, Delete) - don't need to be actual CRUD operations. Actually it is easier to register those hooks between modules in the same application/microservice.
- At software bootstrap of parent module, any child entity module can register hooks that restrict operations on the "parent" entity or receive information about changes in the parent company. For microservices, it should be done by configuration or orchestration.
- Order entity can register a simple boolean hook/callback on Product's Delete, that will check if there are any pending Orders in flight. By returning false, the Order entity/module can block the delete of the parent Product entity instance.
- Warranty entity can register a hook at Product's create/update to capture and cache the Warranty periods of the Product.
- Another Warranty hook, on Delete, can block the Product's delete based on very complex rules that ere expressed in Warranty's business logic.
- The business logic of Product does not need to be updated each time a new business logic is added concerning a "child" entity like Order, Warranty.
- We often need to provide the user with a reason for blocking a certain Product operation.
- Along with a boolean, a child's hooks can return a reason for blocking the operation, for example:
- a String with a message for the user that tried to do the blocked operation on Product, in the terminology of the "child" Order, Warranty, like "forbidden delete due to Order xxxx"
- an URL that permits the user to navigate to the "child" entity/entities that blocked the operation on parent
- the requirement is that the returned software object should be agnostic of the children's structure (think String, URL), while the containing text information can be expressed in the child's specific terminology but can also refer the Parent.
- The parent must not need to includ the children as build dependency to understand the answer.
The full story
1. Introduction
Tight Coupling
When we write software, we see that tight coupling makes the software harder to evolve and maintain. Tight coupling increases the risk for a new feature to break existing features. As the software grows, it becomes much harder to reason about all the implication that a change might have. The observable effect is that it takes more time for a similar feature to be implemented as the software grow. At a certain point, the software becomes unmaintainable and needs to be re-written.
Dependency Inversion
Dependency Inversion Principle (DIP) is a solution to avoid some tight couplings that a naive implementation might bring. To have an intuition what inversion means: A module is calling B module to do the job, however, at build time, B's module should depend on A's module.
Note that Dependency Inversion is way different than Dependency Injection (as in Spring). However, in some cases, the former can be sometimes implemented using the later.
Plugin architecture
Dependency Inversion is used, for example, to create a plugin architecture, where the core application (A) does not need to know about all the potential plugins (like B) - that can be develop later. Only the plugins (like B) need to know about an interface published by the core application (A) and implement it.
The difficulty for a plugin architecture is: you cannot call from A something that is defined in B without knowing B's interface. We can make A to not depend on B at build time, by creating an interface in A and having B to implement that interface and register its implementation at runtime. In this way, only B (and other plugins) depends on A. When a new plugin B is created, the core application A does not need to be recompiled to work with plugin B.
It is rather contraintuitive principle: when a "parent" module needs to call many "children" modules to do the job, it is better to have the children to depend on the parent.
Children will need to build against a generic interface located in the "parent" module or a separate modules that both "parent" and "children" depend.
2. Philosophic interlude
What is the logic of a "child" entity to depend on a "parent"? Except that it happens with humans also?
The reason in software is that, a "parent" entity Product should be able to build and exist independent of additional modules that might depend on Product. We should not pollute the Product's logic and build with all the children that can be added later on Product.
However, the "child" entities like Order or Warranty can only exist in relation with the parent entity "Product". An Order can only exist if it refers a certain Product that is ordered. While we can code Order against an abstract Product interface, the child still needs to refer a certain Product entity.
In a way, the implementation of the "Order" concept must refer some kind of "Product" concept, concrete or abstract. However, a "Product" can exist independent of child concepts like "Order", "Warranty".
But who's concern is the link between Product and Order/Warranty? While Product can exist without Order/Warranty, it is more natural to have that responsibility in the children's code - as Order need to refer a certain Product anyway.
When we only have Product, and we add a child Order, we are creating a third system based on the 2 modules Product and Order. For sure, we need a Product id for creating any Order. In the Order module we might have an Order management interface that is listing the Orders, and this must call specific functionalities from Product module (like display a referring Product).
However, any additional business rules are not inherently linked with Order either. We can imagine another integration where an Orders module display a "Product deleted" information when it is the case and still carry the job of managing Orders until they are delivered.
Therefore, these additional business rules are not inherently bound to any of the two modules: Product and Order. These module interaction business rules are actually part of the plumbing that united a Product module with and Order module.
While it might be convenient to store all those constraints in the "child" (Order), it should be possible to have a third "plumbing" module that is outside of parent/child (Product/Order) definitions. This might require the 2 modules to provide hooks registrations and alternative hook implementations that can be "plumbed" by the third module. Depending of the plumbing that is used, the result might implement different business rules.
3. General case
When be build non-trivial software, we often end up with multiple entity trees, where some entities like Product are roots and we have multiple sub-trees of depending modules that can have a hard dependency on a parent like Product or on a chain of entities. Normally, all circular dependencies must be avoided, we need to have a tree-like structure for each parent.
The "holly grail" of modularity is to be able to start with a single module having a Product entity and add Order or Warranty modules as additional modules that seamlessly plug to Product module. It should be possible, in theory, for the client to only buy the Product module and later buy one or both of the additional modules Order and Warranty.
The addition of a child module (like Order) should not require modification on the parent module (like Product). Any additional business rules added by the new Order module should be ideally contained in the child module (Order) or in higher level module. The additional constraint of Product's service logic is something brought by the additional module (Order), and it should not pollute the Product's implementation. While codding the "child module" might bring the need for a new type of hook in Product, this should be the exception.
The Product's abilities should be defines in terms of abstract ways it can interact with future "child" module. Actually, it does not even need to be a child-parent relationship, we can imagine modules that are collaborating on equal base. However, we will prefer a tree-like structure if we can help it, for the sake of our brain's sanity.
4. Possible implementations
Lately there is a reflux from microservices to modular monoliths, and for good reasons. It is easier to imagine those hooks in a modular monolith or modular microservice that contain all the related entities. Why would you break a hierarchy of entities in multiple microservices anyway? Such tight hierarchy cannot be easily broken into multiple bounded contexts - see TDD. Maybe each separate hierarchy is a bounded context - that is possible.
So, we have a module named Product, in project. Then we add a new module, named Order. This Order depends on Product or on a higher level interface that Product implements.
As the Product and Order are modules in a single application, it is easier to enforce that Product will not start without the constraint hooks that Order module is adding. The hook registration can be a single call to the hook registration API that Product exposes. We can also imagine a more dynamic bindings by using Spring listeners on Product that can avoid the hook API all-together.
Then a new module Warranty is added, that will register it's own constraint hooks on Product and maybe also Order. The constraint code is part of the Warranty module. However, based on application configuration, there can be alternative hooks that can be registered from Warranty into Product. We can register a strong constraint on product, or we can decide at business level that we want a looser link with Product.
We observe that, having only the hooks API in Product, the parent module Product must not be touched when adding depending modules. Normally, the Product's tests should only test the hook APIs in abstract.
While it is still possible for a child to register a hook that blocks Product's normal operation, this is a bug in the child's implementation, and must not touch Product to be fixed. The parent might add additional safeguards on hooks, like a timeout for hook to respond.
There is a steep learning curve to implement such hook system, so it only worth implementing it when you have many entities with complex intricated logic. However, in such cases, I predict such approach can bring high productivity gains on the medium and long term.
5. Conclusion
We can achieve looser coupling between depending entities, by making parent entities unaware of children entities. In order to implement constrains between parent entities and child entities, we can design a hook registration system.
The parent entity should not be aware of all the child entities that might depend on it. Not should the parent module depend on any parts of the children code. Only children will naturally depend on the parent entities.
To implement constraints, each child entity module should register hooks on the parent module, using an abstract API in parent. This hooks will be called from the parent to verify various constraints that are related to each child entity. The constraint is codded in the children's code, and is automatically called from parent via this abstract interface.
This approach should create software systems that are more easy to maintain and evolve. Such software should be less brittle - it should not easily break functionalities that are already tested in a parent entity, just because we added a new child entity.
Comments
Post a Comment
Comments?