This is the fifth post in a series that started with why I hate stored procedures. I've been beating up on stored procs for a month now — the testing problem, the ugly SQL distraction, the Mr. Miyagi prescription. But those posts were all about how you interact with the database. This one's about something bigger.
What if the database itself is the problem? What if we're just picking the wrong tool just because we've always done it that way?
"Referential Integrity Keeps Our Data Clean"
I've been getting this comment a lot since this series started. Variations of: "referential integrity ensures that our data is clean" and "the database is the last line of defense for data quality."
I want to take this seriously, because it's not an unfounded claim. There's something real in it. But it's not nearly as true as the people saying it seem to think.
Let's talk about what referential integrity actually gives you. A primary key definition gives you identity — each row can be uniquely identified. Unique constraints give you de-duplication — no two rows with the same natural key. Foreign keys protect against orphaned data — every OrderId in the OrderItems table points to a row that actually exists in the Orders table. NOT NULL gives you completeness — certain columns always have a value. The schema gives you type safety. (Well, more like "type-relative-confidence" but close enough.) Then there's transactions on top of all of that to make sure it's consistent.
That's all real. It's not nothing. I'm not going to pretend those constraints are worthless. They prevent a genuine entire category of bugs.
But here's the thing that's been rattling around in my head: structurally valid data doesn't necessarily mean semantically correct data.
Clean Structure vs. Clean Data
Your database data can have perfect referential integrity — every foreign key resolves, every unique constraint holds, every NOT NULL column has a value — and yet the data can be completely wrong from a business perspective.
An order with a negative total. A shipping address that doesn't match the customer's region. A user account with permissions that violate your business rules. An insurance policy with a coverage combination that doesn't make sense. Every foreign key resolves. Every constraint passes and the data is "clean."
Except it's garbage.
The constraints that referential integrity enforces are structural. They're about the shape of the data. Is this column populated? Does this key point somewhere valid? Is this value unique? These are warehouse-y, inventory management rules — "is the box on the right shelf?" They are not asking "is the right stuff in the box?"
The rules that make data semantically correct — actually right from the perspective of the humans who use it — are business rules. Validation logic. Domain-specific 'stuff' that can't be readily expressed in a schema definition. The schema can tell you "this column is an integer." It can't tell you "this integer represents a valid state transition given the current workflow context."
Put another way: referential integrity can guarantee that your foreign keys point to real rows. It can't guarantee that those rows make sense together. It can't guarantee that the rows captured and stored the actual, original, human intent.
Where Data Quality Actually Lives
So where do the business rules live? Where does semantic correctness get enforced?
In the domain model. In C#. (Or Java. Or TypeScript. Or Python. Whatever your application language is.)
Your domain model is where you express the actual meaning of your data. Not just its structure — its behavior. An Order object knows that its total must be positive. A Policy object knows which coverage combinations are valid. A User object knows what permissions are allowed for its role. Those rules can be complex. They can have conditional logic, temporal dependencies, workflow state. They need a real programming language to express them properly.
[BTW, I'm using "domain model" as a bit of a shorthand. I'm mostly focused on the layer where this stuff lives rather than any specific implementation style of the Domain Model Design Pattern.]
And — here's the part that loops back to the rest of this series — the stuff in this layer needs to be testable.
I spent two posts talking about how you can't unit test SQL and then how Mr. Miyagi from the movie Karate Kid informs my approach to software architecture. The architecture that I described in that post solves that by keeping the important logic in C# where it can be tested. The data quality argument reinforces this testing theme. The rules that actually determine whether your data is correct belong in a language that supports unit testing, refactoring, interfaces, and dependency injection. Not in DDL constraints. And definitely not in stored procedures.
(And now in my head I can hear the stored procedure defenders saying "that's why we put our business logic in stored procedures — to keep the validation close to the data." Which is where I gently remind you that T-SQL is a language designed for working with sets of data. It's not a general purpose programming language. It's not great at complex conditional logic. If you need row-by-row processing, you need cursors and that's a pain in the donkey. C# will be better at expressing, testing, and maintaining your business rules. Every time.)
So the "referential integrity keeps our data clean" crowd is putting their data quality faith in an odd place. They're trusting the warehouse filing system to ensure the contents of the boxes are correct. The filing system can't do that. It was never designed to do that...and therefore, doesn't.
You're Working for the Database
Ok. So we've established that the real data quality lives in the domain model, not in the schema. The schema handles structural validity. The domain model handles semantic correctness. Both are valuable. And they're different jobs.
But here's where it gets icky. Look at the coding tax you pay to bridge those two worlds — and then ask yourself who's the boss.
(Gratuitous Gen X TV reference? Yes. Relevant question? Also yes.)
Your domain model is a tree. An Order has LineItems. Each LineItem has a Product. The Order has a Customer who has Addresses. It's hierarchical. It's navigable. It's how your brain works and how your code works. order.Customer.Addresses[0].City. You walk the tree.
Your relational database stores boxes. Flat rows. All the same shape. Orders is one table. OrderItems is another. Customers is another. Addresses is another. They're connected by foreign keys — label numbers printed on each box that tell the warehouse which boxes are related. But the tree is gone. The hierarchy doesn't exist in the database. It's been chopped up, flattened, packed into boxes, and filed in a warehouse. And to get a domain model object structure back, you have to unpack the boxes and resurrect the tree.
Making that "chop up the tree" and "resurrect the tree" logic work and be tested is a lot of effort.
This is the object-relational impedance mismatch, and it's been frustrating developers for thirty-plus years. Your code thinks in trees. Your database stores boxes. And every single interaction between those two worlds requires a conversion. Chop the tree into boxes to save. Pull the boxes out and reassemble the tree to read. Every time. For everything.
The Architectural Overhead
And look at the infrastructure you need to manage those conversions:
An ORM (EF Core, NHibernate, whatever) to generate the SQL you don't want to write by hand.
Entity classes that are shaped like the database — flat, foreign-keyed, warehouse-shaped. These are NOT your domain model. They're box-shaped representations of your data. (This is what trips up so many EF Core developers. Your entities are not your domain objects. They serve different masters.)
Adapter classes that convert between entity shape and domain shape. Bidirectional. For every aggregate root. With merge logic for collections. With null handling. With edge cases. With tests.
Repository interfaces that accept and return domain objects — trees — while hiding the boxes behind the contract.
Repository implementations that wire up the ORM, coordinate with the adapters, manage the DbContext lifecycle, and handle SaveChanges.
Migration tooling to manage schema changes — changes that wouldn't exist if the storage shape matched the domain shape.
Join tables for many-to-many relationships that would just be arrays in a document.
Unit tests for all the adapters because every property mapping is a place to be wrong, and the shape conversion is where the bugs hide.
That's a LOT of infrastructure. And every line of it exists for one reason: trees and boxes are different shapes, and someone has to manage the translation.
You're working for the database. You're writing all of this code to dance the database's dance. And that dance has been on auto-renew for 50 years because for most of that time, there wasn't an alternative. Nobody ever opened the bill.
The Movie Domain: A Concrete Example
I'm building a sample app right now for a book on .NET architecture that I'm writing. (Shameless plug: stay tuned). The domain is movies. Actors, films, genres, cast information. Real data from Wikidata. About 1,800 films.
In the relational world, the relationship between movies and actors is a many-to-many. A movie has many actors. An actor appears in many movies. So you need a junction table: MovieActor, with MovieId, ActorId, and CharacterName as payload.
Simple, right? Standard relational modeling. Nothing weird here.
Except.
When that data gets into the domain model, I need two different views of the same relationship. From the Movie aggregate root, I need Movie.Cast — a list of CastMember objects that tell me the actor's name and the character they played. From the Actor aggregate root, I need Actor.Roles — a list of MovieRole objects that tell me the movie title, the character name, and the year.
Same underlying data. Same junction table. Stored once but then two completely different domain shapes.
So now I need a MovieActorToCastMemberAdapter that converts MovieActorEntity into CastMember. But wait — CastMember needs the actor's name, and the junction entity doesn't carry the actor's name. So the adapter needs an IActorResolver dependency — something that can look up the ActorEntity by ID — just to populate the ActorName property on the domain model. (I'm predicting that that'll be one of my performance problems I'll eventually find and need to solve, BTW.)
And I need merge logic in the MovieAdapter for the Model → Entity direction. When saving a movie, I have to find existing MovieActorEntity records by composite key, create new ones for cast members that were added, and remove ones that were deleted. That's matching, creating, and pruning — for every save operation — inside the adapter.
The test class for just the Movie side of this adapter is already over 300 lines. I'm testing EntityToModel, EntityToModel_WithActorsAndGenres, ModelToEntity, ModelToEntity_WithCast, ModelToEntity_WithCast_RemovesDeletedCastMembers. And I haven't even finished the Actor side. The ActorAdapter still has a // TODO: Eventually will need to impl Occupations and Movies comment staring at me.
The test cases just keep growing. Not because the tests are bad — they're finding real bugs and validating real functionality. The shape conversion is genuinely tricky and the adapter logic is where things tend to break. But the volume of test cases required to cover the conversion from one flat junction table into two different tree-shaped domain views is... a lot. And it's all ceremony. It's all tax. Every line of it exists because the database stores boxes and my code needs trees.
What If the Database Just Stored the Tree?
So I'm going through this exercise in order to develop a code sample for an upcoming .NET architecture book. But since I already wrote a book on Cosmos DB for .NET Developers, I keep thinking about how DIFFICULT all this is. So here's the question I can't stop asking myself:
What if I didn't have to do any of that chopping or reassembly?
What if a Movie document just had a cast array — actor name, character name, done? And an Actor document just had a roles array — movie title, character name, year, done? No junction table. No adapters. No IActorResolver. No merge logic. No 300-line test class for one direction of one relationship.
The same data, stored in two different shapes, natively, as documents. Sure there'd be some duplication and you'd have to make sure they're both kept in sync. But it's nothing that a Cosmos DB change feed service couldn't solve. Make a change? A trailing service picks up the event and behind the scenes makes the updates to keep the two aggregate roots in sync. An Azure Function. Done.
I'm not being theoretical here. (See: Azure Cosmos DB for .NET Developers). The irony of building this sample app for the architecture book is that the experience of writing all the relational adapter code is making the strongest possible argument for the Cosmos DB book.
It's SIMPLY AMAZING how much extra code exists solely because trees and boxes are different shapes.
This isn't me saying "throw away SQL Server." This isn't me saying "Cosmos DB solves everything." It very much does not. I'm saying: the relational model has a cost, and most of us have stopped noticing it because we've been paying it for so long. It's the subscription that auto-renewed every year for fifty years. The bill got so big that whole companies formed to sell you tools to manage the bill. And nobody opened the envelope.
The Tradeoffs Nobody Wants to Acknowledge
Every database makes tradeoffs. Understanding the tradeoffs is the whole game.
Relational databases are still the right answer for a bunch of things. Set-based operations — when you need to ask ad hoc questions across your data that you didn't anticipate when you designed the schema. Reporting and analytics. Transactional consistency across multiple entities in a single ACID transaction. If you need to query "show me all orders placed in Texas in Q3 where the total exceeded $500 and the customer has been active for more than two years" — a relational database is great at that. The flatness of the boxes makes them easy to slice and dice.
Document databases like Cosmos DB make different tradeoffs. They're great at storing and retrieving whole aggregates — trees — without the conversion tax. They scale horizontally instead of vertically (we'll talk about that in the next post). They're great at high-throughput, low-latency operations against well-partitioned data. But ad hoc cross-entity queries? Not as easy. Transactional consistency across documents? Different model. Reporting? You'll want something like Fabric mirroring on top.
The point isn't that one is better. The point is that we stopped asking the question. For most of my career (and I'm guessing most of yours), "which persistence tool?" wasn't even a conversation. It was SQL Server. Or Oracle. Or PostgreSQL. Pick your flavor of relational — but relational was the only option, and nobody questioned whether flattening every data structure into boxes was still the right approach.
The constraints that made relational the only rational choice have changed. Disk space is cheap. Compute is elastic. Cloud-native document databases exist and are mature and are genuinely good. But the thinking hasn't caught up to the constraints.
Legacy Code
There's a well-known definition of legacy code from Michael Feathers' book Working Effectively with Legacy Code: legacy code is code without tests.
I just spent four posts arguing that you can't unit test SQL. That database code can only be integration tested. That test friction kills testing cultures. That stored procedures are architecturally incompatible with CI/CD.
Make of that what you will.
I'm not saying relational databases are going away. I'm saying we owe it to ourselves to re-examine whether the tradeoffs that made sense 20 years ago still make sense today. The technology isn't the problem. The unexamined habits are.
We've all worked with legacy systems. You know what they feel like. You're working for the software instead of the software working for you. If that sounds familiar when you think about your relational data access layer — all those adapters, all those entity classes, all that mapping code, all those junction tables — maybe it's worth asking why.
Ok. And now's the part where you tell me I'm wrong. Here's my email address: info@benday.com
-Ben