The Dependency Rule Violation Hiding in your Domain Model
By Green Tea Coding
Summary
Topics Covered
- Dogmatic DRY Creates Hidden Coupling in Domain Objects
- Semantic Violations Persist Even When Dependencies Point Inward
- DTOs Decouple Domain from Adapter Serialization Concerns
- Type-Safe Value Objects Prevent Invalid Domain State
- The Real Value of DTOs Is Preventing Future Mess
Full Transcript
The main model is clean. The layers are all nice and separate and all the dependency arrows point inward. Really
good for clean architecture.
But somehow, your domain model still knows about infrastructure concerns.
Well, how did this happen and why does it even matter? Well, let me introduce you to a project that I've been working on over the past few years. It is an application that talks to a real-time
system and amongst a lot of other stuff, what it does is it reads and evaluates sensor data.
Well, these sensors have to be configured and our initial approach was, let's say, rather naive. And that led to a lot of problems. So, let's dive in.
I'm a huge proponent of clean architecture and this project is no exception.
A quick recap. In clean architecture, we have four layers in which our code modules are separated. And dependency
should always flow inwards.
This is called the dependency rule.
It helps us write maintainable and testable software, which doesn't crumble as soon as some infrastructure needs to be adapted. If you have never heard
be adapted. If you have never heard about clean architecture, you can just pause here, watch my video on it and come back as an enlightened being. Well,
anyways, let's get back to this video's topic. The important parts of the domain
topic. The important parts of the domain layer are quite simple.
A sensor entity which provides the current sensor value and the sensor config, which is a value object that represents the configuration of a sensor. In short, the physical sensor
sensor. In short, the physical sensor will give you a voltage reading, for example, between 0 and 10 volts. This
config determines which actual value, maybe bar for a pressure sensor, the 0 and the 10 volts correspond to, respectively. I have omitted the sensor
respectively. I have omitted the sensor hardware and its connection to the system here because it has no relevance for my example. Just imagine that the sensor entity will get updated voltage
values in regular intervals.
So, what we're going to talk about today is this innocent-looking sensor config.
But first, let's sketch out the other relevant parts of the system.
The application layer contains a sensor manager, which hosts the sensor objects in memory. On system startup, it
in memory. On system startup, it initializes them based on some persistent configurations. Of course, it
persistent configurations. Of course, it doesn't directly implement a persistence mechanism as this is the responsibility of an adapter in the adapters layer.
Instead, we define an interface called I sensor config repository, which is a port that the sensor manager can depend on.
The manager also provides a public method for setting the configuration of a certain sensor.
To finalize the sketch, let's take a look at the adapters layer.
We have two modules here, the persistence and the client adapters.
The persistence adapters are quite straightforward. The JSON sensor config
straightforward. The JSON sensor config repository implements the interface defined in the application layer and uses JSON files to store and retrieve the sensor configs. The files contain
exactly the information defined by the sensor config struct. This is because we directly implemented the serialization and deserialization on the sensor config domain object.
On the client side, we have a basic HTTP endpoint to set a sensor config.
The request must contain the required information, which is directly deserialized into, again, a sensor config object.
What we always thought was neat about the solution is that the serialization and deserialization was just a single line annotation on the sensor config struct.
We used a framework that provides automatically implemented serialization to and deserialization from JSON.
Because both our persistence and the client requests happen to be JSON, this seemed like a really smart move. Because
remember, don't repeat yourself. That is
what they always teach us.
Well, now in hindsight, I'm actually quite surprised that the solution stood for a bit more than a year without giving us any major problems. And if it wasn't for a change on the front end, the code would probably still be in
place today.
But it is not because what followed was a series of changes that exposed the real problem in this naive and seemingly dry approach.
First, we added a bit of convenience on our front end. The user now had the option to view pressure values in various units, bar, millibar, PSI, etc. It was therefore just an obvious next
step that the front end would also be able to send the request for setting a sensor config with those various units.
And therefore, we added a string that defines the unit which the lower and upper limit of the request are in. Of
course, the front end could have made this conversion itself, but we decided that all conversions should be done on the back end for consistency reasons.
Only a few days later, it appeared to us that we definitely require backwards compatibility for our stored sensor configs.
Reading an older file should not crash the system, but instead lead to some sane defaults for newly introduced fields, which the file was missing. And
therefore, we decided that adding a version to the stored files was a good idea.
And of course, those two new fields, the version and the unit, ended up in the sensor config struct.
It was required because we directly implemented the serialization on it and thus coupled the domain object to the serialized representations.
So, let us assess the situation.
The domain now knows about adapter concerns, even though the dependency arrows still all point inwards. I consider this a violation of the dependency rule, at
least on a semantic level. The domain
has no use for those two new fields and quite frankly shouldn't care about them.
Of course, you could make the point that annotating the domain struct for JSON serialization was the actual violation of the dependency rule.
And honestly, I would totally agree on that.
Next up, we have the version field now also appearing in the request and the unit being stored in the JSON file, even though both are unnecessary.
The front end has no idea about the persistence version, so we always send a minus one here.
And the persistence on the other hand didn't care to store the limits in any other unit than bar, so the unit string was hardcoded here.
But there is one more thing that's maybe not immediately obvious. In this
implementation, the domain cannot enforce any invariants on a sensor config because the framework would directly deserialize the domain object from JSON on both the client and the
persistence side.
Well, this isn't specific is a very delicate issue because if the lower limit and the upper limit are set to the same value, then the code that converts from voltage to pressure would divide by zero.
And this is not something you want to see anywhere. Therefore, we added a lot
see anywhere. Therefore, we added a lot of redundant checks. So,
clearly we need a better approach than that.
Of course, the solution to this is proper decoupling.
For starters, we took a closer look at the client side.
The request needs to have the unit, but not the version. So, we created a new struct called sensor config request, which contains exactly that.
The sensor HTTP API now expects to receive requests in that format.
We did the same on the persistence side.
Sensor config record is a dedicated struct used for converting to and from JSON for reading and storing.
These structs purely required for transferring serializable data are called data transfer objects or DTOs for short.
And in fact, using them at the edges of your application is a very common and robust pattern. So, introducing those
robust pattern. So, introducing those DTOs seems quite reasonable.
But now, we've got to think about a new problem.
The application layer doesn't know about the new sensor config formats in the adapters. And by all means, according to
adapters. And by all means, according to the dependency rule, it shouldn't care.
It still requires that loading, storing, and setting a sensor config works on the domain version of the sensor config struct.
The task of mapping the new adapter layer configs to the domain layer must therefore be accomplished by the adapters themselves, so that the interaction with the application layer can stay clean of the
newly added config structs. This is a bit more work, surely, but it opens up the final improvement of this new approach.
The domain is now truly self-contained as it should be.
No serialization annotation needed anymore. And this allowed us to use
anymore. And this allowed us to use non-serializable, but type-safe value objects for the lower and upper limits instead of doubles.
We decided on a pressure struct here, which internally holds the value in bar, but only allows creation through factory methods and access via unit aware getters. This way, no other double could
getters. This way, no other double could accidentally be stored in one of those limits and there is no ambiguity about the unit anymore. Actually, a huge win for readability and safety.
So, let's sum up the advantages of this new approach.
First and most obvious, the introduction of DTOs for the sensor config decoupled the adapter from the domain concerns.
This allows the domain to use type-safe fields, but that is not all.
Because there is no deserialization into the domain sensor config object anymore, all the factory methods can be routed through a single private constructor, which can enforce invariants as needed.
So, overall, for the low cost of maintaining a few DTOs and implementing some mapping logic, we could remove this hidden violation of the dependency rule and truly decouple
the core from the adapters layer.
So, there's one last thing I need to mention before I can let you go.
If your DTO today has the exact same fields as your domain object, it is still worth having. That's because
requirements might and oftentimes will change. And then you're really glad that
change. And then you're really glad that this boundary was already set in place.
So, the actual value, the real value of a DTO, is not the problem that it solves today, but the mess that it prevents in the future. And now, if you would like
the future. And now, if you would like to go deeper on where exactly the mapping between DTOs and domain objects should happen, well, that's up next.
Link in the description once the video is out. I'll see you then.
is out. I'll see you then.
[music] [music]
Loading video analysis...