We’ve talked about what modularization is, and what its advantages are when used in a decoupled, cohesive way. It feels only reasonable that we should dig into the meat of what a modular architecture could look like, starting with your module boundaries.
I say “could” on purpose. Whilst I believe there are some anti-patterns to modular design, I feel you should be wary of anyone being too prescriptive on what modularization should look like. Yes, there are guidelines, and yes, there are characteristics like decoupling and cohesion that are proven to be valuable. But the reality is the design of a module, particularly the internal implementation details of a module, has a level of subjectivism about it.
So well, where to start about something so subjective? Well, I want to talk about arguably the most objective portion of this design – the boundaries. Getting your boundaries right from the beginning can help define the use cases of your module, aiming for that high cohesion, but also can help make sure you only expose what needs to be exposed, helping that loose coupling characteristic. Your internal implementation of those features can be a mess, but you get a large amount of the benefits that a modular architecture can bring if you have a well defined boundary around that module.
What are boundaries?
Boundaries are the public point in your module in which other things can interact with it, and often exist around the “outside” of your module. Its the external interface (or API) of your module that exposes specific functionality that needs exposing to other modules, and hides the implementation details from the consumer of that module.
For example, say your module adopts a Model-View-Controller
structure in its implementation, you could refactor to an Model-View-View-Model
structure without the consumer of your module knowing about the changes. Its worth noting it’s also important to have a high quality test suite in place when making those changes to ensure functionality isn’t lost or altered as that could impact the consumer of the module.
For example, lets say you have a Network
module. This module contains all the code you need to be able to make a request to a web API. What functionality of that module would you need to expose to the rest of your app? Whilst not comprehensive, this is a rough outline of what that boundary design needs to be able to provide in plain English:
Consumers of the module need to be able to:
- Create the
Network
module. - Send
get
requests. - Send
post
requests.
If we flip that into code, we get something that looks like the below:
protocol NetworkModule {
func createModule()
func get()
func post()
}
We don’t need to worry how to design the underlying code for that functionality yet. Just having the boundaries in place for what is expected of that module is a decent place to start, as it allows us to see what functionality we need to expose from the module.
So why start there?
An analogy
I absolutely love running an analogy into the ground so here goes.
When building a house, the designers of the house don’t start with thinking about where the couch will go inside the house. The designers start by thinking about the foundations of the house, the positioning of the house, how it might fit in with the neighbors either side, or how many rooms the house should have. If you get those properties of a house wrong, adjusting them after, whilst doable, can be a bit of a hassle, and can cause inconvenience for you and your neighbors.
It might be tempting to think about the couch, as that’s ultimately one of the things that will make the house function as a home. Deciding on a couch is also arguably easier than building and designing the house, which adds to the temptation to focus on it. But it’s not where a project should begin, and the foundations and boundaries of the house are much more important for the stability of the house and for the occupants to enjoy their time there. It also delays the decision of deciding on the couch as if you designed your house around a specific couch, replacing that couch could prove very costly. Imagine something has to change during the build of the house, and you end up with a bigger room. Suddenly, you’re wishing you sprung for the corner sofa rather than the 2 seater.
Moving back onto modules…
Stability
The external interface to your module is arguably the most important piece of the puzzle when it comes to module design, like the boundary of a house. It’s the place where consumers will interact with your module, so it also becomes the place where making changes can impact those consumers. The underlying implementations of those interfaces can change and your consumers will be none the wiser (assuming those changes don’t impact the core functionality of the interaction), but changing the interfaces that the consumers are using can have wider impacts. These interfaces should be the most stable part of the module, and require the least amount of changes in the lifetime of the module.
Defers premature decision making
It also allows us to defer premature decisions about how a system should work. A premature decision is anything that isn’t related to the business related use cases of the system. Using the example of our Network
library before, a common example of a premature decision might be deciding which networking library should be implemented rather than focusing on designing the interface around the yet-to-be-decided networking dependency (i.e URLSession
). Your yet-to-be-decided networking library finds itself in a similar position to the couch in the house analogy.
Software design that allows those dependencies and systems to be changed easily should be the end goal, so spending time on those dependencies at the start of designing a system can result in software designed around a specific piece of technology, which can create a world of hurt if that technology ever needs to change. Deferring those decisions to when they’re needed allows you to spend the time designing the interfaces you would like to interact with for whichever dependency you end up using, rather than the dependency deciding those interfaces for you.
Build your house for you, not for the couch that may only be there for a couple years.
Conclusion
I think the main takeaway from this is that building well defined module boundaries as the starting point when defining your module, enables you to build out your module feature set in a way that takes into consideration what you need to expose to other modules, and helps you push down the implementation detail of the module, deferring the decision making process of what that implementation might look like.