Sponsor: Do you build complex software systems? See how NServiceBus makes it easier to design, build, and manage software systems that use message queues to achieve loose coupling. Get started for free.
How can you prevent creating a big ball of mud? Understanding your boundaries and the coupling between them. How do you communicate between boundaries within a monolith? Coupling is the enemy! Define contracts of functionality that are exposed to other boundaries. This hides implementation. If you want to be coupled less, go even further with asynchronous messaging.
Check out my YouTube channel where I post all kinds of content that accompanies my posts including this video showing everything that is in this post.
Direction of Dependencies
A lot of people are using onion architecture or a layered approach to writing their software. This really is about the direction of dependencies and having the core domain logic not be coupled to on anything else.
In the diagram above, the very center is the domain, which on top of it is domain services. Meaning domain services depends on the domain. Above that are application services, which depend on domain services.
The dependencies point inward. The outermost shell will generally be UI, Integration Tests, etc.
While I do think this is an approach to limiting coupling, I actually don’t think it’s the most important thing. Boundaries are.
Coupling between Boundaries
If you’re developing a large system, boundaries are key. Boundaries allow you to split up a complex system allowing you to create more focused models. If you want more info on defining boundaries, check out my Context is King: Finding Service Boundaries series.
Once you’ve defined boundaries, you’re likely going to need to communicate between them.
If you’re creating a monolith, this will likely be done in-process via typical method or function calls. Generally, a boundary will expose an interface that other boundaries can consume.
In my Loosely Coupled Monolith series, I describe having each boundary contain a project for Contacts and Implementation (and tests).
Each Implementation project within a boundary will reference other Contact projects from other boundaries. An Implementation project from a boundary will never reference another implementation project from another boundary.
Contacts & Implementation
The contacts project will contain types that are contracts. This means interfaces, Data Transfer Objects (DTO), and delegates.
The implementation project will contain the actual implementation for any interfaces or delegates. It will also be what is creating any DTOs that may be used as return values from interface methods or delegates.
To illustrate this, in our Sales boundary, there is a Place Order command. We have a business rule that the must have the quantity on hand in the warehouse of all the products on an order.
The IWarehouseInventory is owned by the Shipping boundary and lives in the contracts project. The actual implementation is defined in the Shipping implementation project.
Probably the most underused language feature that I see are the usage of delegates. They represent a function. A lot of times, as a consumer, all you ant is a function.
If you have interfaces that have low cohesion, meaning the methods inside an interface don’t necessarily belong together, then maybe you should be exposing a delegate.
Delegates are contracts. They are as low of coupling in C# as interfaces. they do not define implementation.
Another way of exposing the need for a function is when testing your consumers. If you take a dependency on an interface and you create a fake implementation, you absolutely must know which methods of that interface your consumer needs.
From the example above, if our Handle method implementation changes and it uses a different method on the IWarehouseInventory, our test will likely fail. However, if we passed in a delegate, when creating tests, we know exactly what needs to be returned.
Here is our definition of our delegate in the Shipping boundaries Contracts project.
Now replaced in our implementation to simply use the delegate.
Once you write tests for PlaceOrderHandler, you will soon realize that in these types of situations, you really just want a function and not an interface.
The only way to remove coupling from interfaces and delegates is to move to asyncronomous messaging.
This means having boundaries be publishers and consumers of messages. These messages (DTOs) would live in the contract projects.
Big Ball of Mud
While I do think onion architecture and direction of dependencies are important, I do think separating a large system into smaller pieces ultimately allows you to turn a big ball of mud into smaller balls of mud. By creating smaller modules that limit their coupling can help prevent a big ball of mud. You may end up with smaller balls of mud, but that’s much easier to tackle and comprehend in a large system.