The problem with invariants is that they change over time
surfingcomplexity.blog
surfingcomplexity.blog
For me, invariants are constructs of the design of an algorithm. A list doesn't change while iterating over it-by construction, not because someone else owns the reference and promises not to change it concurrently. This structure can only be instantiated via this function which ensures that that string field can always contains only numeric digits. Those are invariants, thing you enforce by design so you can rely on that later. Assumptions instead are things you do not enforce but rely on anyways.
Back to physics, an invariant would generally be something like conservation of energy or the speed of light being universal. Both of these things are only invariant in certain physical theories which enforce them by construction.
We can still talk about “implicit assumptions” and “explicit assumptions”, so the article still works after that replacement. The headline isn’t as punchy though.
There is a folk song written about Black flies:
https://www.youtube.com/watch?v=w2q8jEuH3nk
=3
* Option 1: Component A is violating an invariant of its output type. Whether that is invalid data (e.g. nullptr), internal inconsistency (e.g. an index that will be out of bounds for its array), or some (e.g. matrices with determinant one), the output generated by Component A is wrong in some way.
* Option 2: Component B is making an unjustified assumption about its input type. The implementation was only valid when the assumption held, but Component B's interface implied that it could accept a wider range of inputs.
The only difference between these two cases is the human-level description of what the type is "supposed" to hold. By describing it as an "invariant", the blame is placed at Component A. By describing it as an "assumption", the blame is placed at Component B.
If you or a colleague have modified the constructor NumericString to no longer enforce the invariant that it only includes digits (remember though that utf-8 has more than just the ascii digit characters, we aren't necessarily talking about ASCIINumericString) but can include hyphens, a reviewer should have alarms going off in their head: somewhere in the code surely some conversions to numbers is going to fail without further manipulation. That is the benefit of an invariant, it is a part of the code by construction and enforced there; the purpose of reviews and the purpose of encapsulation and strong typing is to narrow the boundary where the invariant must be enforced.
Assumption on the other hand have no narrow boundary. Memory size changes, what part of your code is no longer valid? It is potentially unbounded if your code was allowed to rely on something that was not enforced.
On the other hand, I have systems being worked on and still shipping 20 years old because of fortuitous decisions in the 90s. Most of those taken because of bad experiences with earlier systems.
For example, I'm a big fan of surrogate keys and UUIDs, in database design. Mostly because of experience where they weren't used which caused problems later. (By contrast, performance costs of using them become lower every year.)
Conversely deciding to build code libraries parallel to application development means long-term gains in performance and reliability.
It's easy to view all this in hindsight, -much- harder with foresight.
Today I try and see everything through 2 rules.
1) beware of "always" statements, especially especially in regard to client requirements. IME "always" is used as a synonym for "mostly" and "at the moment".
2) choose paths now that keep options open later. Will this system ever use distributed data? Probably not, but choose a primary key that leaves that option open.
Lastly, and this applies to data design, remember "nothing is universal". Using Social Security number as an ID is great, until they outsource. Phone number is always 8 digits? Yeah right. Nobody works night shift... you get the idea.
If in doubt, err on the side of "variant". You'll be right more often than not.
You would think this (using surrogate keys) would be such well worn wisdom by now that discussions about it wouldn't be a thing. But somehow new generations of developers, weaned on nosql and on not using the god-given gifts of databases like integrity constraints, seem to love bike shedding key design and arguing vociferously that natural keys can be used as primary (and foreign) keys.
But the default is still a number natural number sequence. As if it matters for 99% of all cases that stuff is "ordered" and "easily identifiable" by a natural number.
But then you want to merge something or make double use and suddenly you have a huge problem that it isn't unique anymore and you need more information to identify.
Guess what, an UUID does that job for you, across multiple databases and distributed systems, a UUID is still unique with 99.9999% probability.
The one counter-example every 10 years can be cared for manually.
And in any case the German tank problem means that you often can't use incrementing numbers as surrogate keys if they are ever exposed to the public e.g in urls.
I think the positive still outweighs the negative here: you can search your whole company's logs for a UUID and you won't get false positives like you would with serial integers.
We ended up creating our own integer table tied to the GUIDs and replaced all the GUIDs with integers in our reporting database. Our memory footprint dropped by 10X and we were able to report on years worth of data, rather than months.
How? A UUID is only 4x the size of a 32-bit integer.
Every material about relational data I've seen either just explains them, says they can be either natural or artificial, and leaves at that, or says there's some disagreement over what is better.
The unanimity exists only within field experts that apply the stuff. The theoreticians all disagree.
Even ISO country codes can be problematic, for example we currently use XK for Kosovo, but it might get an official code at some point.
Over the years just about every natural key I've seen ends up being either not-universal (-everyone- has a phone number right? Even that new-born baby?) Or not unique (phone numbers can be shared) or mutable (like people's names changing).
It's just so much easier to add a column and use a surrogate key. I prefer uuids because they are unique And immutable - properties I really appreciate in primary keys.
This is in contrast to my how my younger-self would instead focus on making code that "extensible" or "flexible" or "configurable". (With some overtones of impressing people, leaving a long-term mark upon the project, etc.)
Nope! Go for "delete-able."
If you design thinking "X years from now business-requirements or staff will have changed enough that this will need to be ripped out and rewritten, and I am not good enough at predicting the future to solve it cleverly", then it sets up a different expectation of how you invest your time.
One might focus on code where it's easy to trace dependencies, even if that means a bit more boilerplate and avoiding layers of indirection that aren't (yet/ever) needed. "Greppability" of code also becomes important, especially if your language/tools aren't very good at tracing usages and caller/callee relationships.
Often the problem is exactly this magic "decoupling framework", which ends up being exactly the problematic part that that one eventually wants to swap out.
Long ago somebody made a Thingy.find(...) method where you can pass all sorts of optional arguments to filter which Thingy comes back, like Thingy.find(primary_key=1) or Thingy.find(owner=123, active=true) etc. That complexity made it flexible for people writing caller-code, because they wouldn't need to alter the method to satisfy their future arbitrary needs, they can just pass whatever key-values they want to match.
However now some original invariants have changed, and now I need to go find all the places it's being used ("flexibly") and reconstruct the caller's intent, because in some scenarios the correct return value is going to be a list of multiple results instead of 0-or-1.
In contrast, the task would be easier if there was a named method that captured the intent for the 3-4 distinct use-cases.
Along with its complimentary statement:
Beware of "never", especially in regards to client requirements.
Been on too many projects where it turned out "never" meant once or twice per year once we'd gone into production...
Thus as you say we can very often immediately figure out when something is missing from the requirements, or when we feel things might be changing soon down the line.
We can also detect when the customer is requesting something suboptimal or similar. We've received a lot of positive feedback from customers due to us devs pushing back, helping them to improve their processes and workflow. But it also leads to less work for us, or more generic code which can be reused by more customers.
Our support team also has extensive domain knowledge, many have been hired from our customers. It makes our support excellent, often the customer's issue is a combination of how to do X and how to do X in our program, and for new devs this is a great resource to tap into.
Together we're punching well above our weight, dominating our niche with a quite small team. And domain knowledge has helped a lot in that.
Why I fully support surrogate keys and versioning, I do not support UUIDs as primary keys - they are trivial to generate, not so much to place in B-trees. It's very common to need/process recently added data together, UUID make that much harder as they inherent placement is all over the place w/o any locality.
UUIDs are ok/fine as external references, of course.
Not sure about this one. I’ve seen teams that are too noncommittal in their architecture decisions because “what if [insert unlikely thing that’d not actually be the end of the world] changes?” Then the project ends up only using surface level features of their chosen tooling because people want to keep too many options open and are afraid to commit to something where it matters.
Generally speaking there are lots of well-established best-practices, that are best in most contexts, but which fall over in other contexts. Knowing what to do where is part of the intangibleness that separates the great from the good from the competent.
So some things are good to defer. -if- this product works, we -may- need to scale, so let's choose a database, and design, that allows for multiple servers. Picking say Postgres over SQLite. Other times saying "this is just a proof of concept, do it quick in SQLite".
Is our API gonna be XML or JSON? Not sure, could go either way. Let's design so that part can be trivially replaced.
With data design especially, IMO, it's worth -planning- for success. Starting with uuid will end up fine for 99% of the tables which will have "not many records". When we identify that 1%, which are growing by millions of records a minute, we can adjust for that table as desired.
[On a side note, regarding clustering, it seems some people aren't aware that the primary key and clustered key don't have to be the same.]
Take a simple User type
type User {
name: string
email: string
}
This describes some obvious invariants - that a User will have a name and an email.But it's also describing some less obvious invariants, e.g. that a User will not have a phone number. This has implications if a phone number ever does get added to a User type, and your system has to deal with two different versions of Users.
(This isn't just an academic problem - the problem of trying to evolve fields over time is the reason why Protobufs got rid of the idea of a "required" field.)
And this is an example of a very simple implicit invariant. In practice, systems that interact with each other will give rise to emergent behaviour that one might think is always true, but is not guaranteed.
if this is the reason of the drop of the required field it doesn't seems smart to me.
They are conflating the definition of a scheme with the scheme evolution logic.
And there are multiple ways to deal with the latter (like adding a schema version, for instance)
But, the point is schema evolvability is a real problem, and it's often not one that a lot of engineers give a lot of thought to, even those who live in very strict statically-typed worlds.
Protobufs explicitly do not have a built-in version system (though of course you could add an optional field for it), presumably because it is better to inspect the data than build in assumptions that a certain version overs a certain invariant.
struct LoginUser {
name: UserName,
email: Email,
phone_number: Option<PhoneNumber>,
}
First the user type should reflect the function of the user. This is one that is allowed to log in (of course overly simplified). You can then also have a OnboardingUser, a ArchivedUser and so on. And if you want to transition one between those different types you need to ensure the fields are there. This is easy to test and clear.The other thing is to use types for fields that have hard verification requirements. You can still have them behave mostly like strings for reading, but you can ensure that an Email in a LoginUser never contains an unverified Email, by first verifying the email string in the OnboardingUser and only converting it into the Email type once it is guaranteed to be a valid email. Because you use a special type tacking on further things like storing a date when this verification happened last is easy, just put it into the type as well.
Lastly the phone number is an Optional type that wraps a PhoneNumber type. This way it can either be None or Some<PhoneNumber> (this uses the Rust typesystem, but the principle would work elsewhere), the phone number should be verified the same way, ideally relying on a well tested method that also fixes the formatting at the same time.
Now in Rust if some part of the system didn't handle a user that has a phoneNumber your code won't compile, because from the standpoint of the old code this would now be an entirely new, unknown type never seen before. Sure you could step through your code and explicitly not handle it on purpose because you want back to a running system fast, fail to add a TODO and then later forget about having to do it, but that would be entirely on you.
Now those hard typesystem constraints become even more valuable when dealing with external systems. Your type should represent all assumptions you are making about the data and test those on each import. Of course you then will have import failures if the import source changed, but we want that to fail if it changes. What we don't want is the thing silently chugging the input into the wrong pipe or invalid phone numbers to be stored because the source system decided to split them up into the prefix and the rest.
But it does also describe a lot of implicit invariants. The LoginUser you've defined has the implicit invariant that a LoginUser only has 3 fields. It is very possible for someone to write a function that relies on a LoginUser only ever having 3 fields, which then breaks if you ever extended LoginUser. This is similar to the example that the article mentions at the start, where a thing that someone assumed would always fit in one memory region no longer did, and that caused all kinds of problems.
Those kinds of implicit invariants are the ones that are tricky to make explicit, even in strongly-typed languages.
Rust does actually have a good way of dealing with this specific invariant. If you mark a struct with the `non_exhaustive` attribute[0], trying to construct it with a literal will fail outside of the package that defines it (even if all fields are marked as public), and trying to pattern match it outside the package that defines it is required to use the operator to ignore any missing fields.
Not trying to say that it's possible to encode every possible invariant in Rust, but I do find it useful to leverage all of the tools available, and I've found this one super useful (on both structs and enums) despite not being super well known from what I can tell.
[0]: https://doc.rust-lang.org/reference/attributes/type_system.h...
This isn't even a technical problem, it's a semantical problem. Trying to reuse the same type throughout the whole world is doomed to fail, no matter what techniques you use.
Getting attached to your classes by writing a single superclass of The User™ is a sure way of missing out on the benefits of the typesystem and create coupling where there should be none.
Knowing where to couple things and where to decouple things is one of those hard programming issues, but one that might be even more important than naming things.
If we are talking about someone other relying on your user to have 3 fields that is a collegue: it is a good idea to test your code against the whole codebase. Then you know ahead what will fail and can act accordingly.
That's not right. For one, the `User` type might be extensible (what language is this?). For another one can always add additional metadata about users elsewhere -- think of how you might do it in a relational database.
> (This isn't just an academic problem - the problem of trying to evolve fields over time is the reason why Protobufs got rid of the idea of a "required" field.)
Protobufs is the poster boy of bad reinvention. ASN.1 got all these things right over a lot of time, and instead of taking the best of it and leaving out the worst of it, everyone is constantly reinventing ASN.1 badly. And yes, ASN.1's evolution was "academic" in many ways, and its authors blazed trails. ASN.1 never had a "required" anything, but it did (and does) have an OPTIONAL keyword.
The reason I mention extensibility above is that ASN.1 has been through the whole academic study of extensibility over several decades:
- since the original encoding rules were
tag-length-value, in the beginning you
could just add fields as long as you
knew the recipient could handle them
(e.g., by ignoring unknown fields)
- when that proved unsatisfying they
added extensibility markers for
denoting "here go extensions" vs
"no extensions allowed", and "these
are version 2 extension fields",
and so on, and not just fields but
also things like INTEGER ranges,
enums, etc.
- they also put a lot of effort into
formalizing the use of "typed holes"
(things like {extension-type-ID,
<encoded-value-of-that-type>}),
and they did this pretty much before
any other schemes.
(The wikipedia page on comparing
serialization schemes[0] refers to
"typed holes" as "references", FYI.)
Extensibility is fairly well covered in many serialization formats now, like XML for example, but still fairly poorly covered in others.In SQL and relational systems in general one can always add additional fields via tables to JOIN on, and often by adding columns to existing tables.
Extensibility is a big deal, and serious systems will have had serious thought put into it.
[0] https://en.wikipedia.org/wiki/Comparison_of_data-serialization_formats> Implicit invariants are, by definition, impossible to enforce explicitly.
Yes, Hyrum's Law (and, at the limit, Rice's Theorem) and all that means that we won't ever be perfect, but just because an invariant is currently implicit doesn't mean it must remain implicit forever. We can identify classes of implicit invariants and make them explicit using things like type systems.
If I have blog where blogposts have maximum length of 4096 bytes. But why would there be such limit, is it creative decision? is there technical limitation? was it just some "good enough" number when blog was created?
I don't think type systems really can encode these reasons. You see the constraints but not the reasoning.
type BlogPostThatFitsInSMS = BlogPost<FixedString<150>>
No one would write this type in the real world (and most type systems can’t encode it), but you see lower-level sorts of reasoning all over the place in type systems.e.g.
The sum of the angles of triangles is 180 degrees in the context of euclidean geometry. However, if we project a triangle on a sphere, this no longer holds. So the sum of the angles is an invariant under euclidean geometry.
On the other hand, the value of PI is a constant because it stays the same regardless of the circumstances. That's why all the numbers themselves are constant as well - the number 5 is number 5 absolutely always.
So if you have a value that changes over time, it is definitely not a constant. It could be invariant, if you, e.g. specify that the value does not change as long as time does not change. Your value is now an invariant in the context of stopped time, but it can never be a constant if there is any context where it does change.
You mean "about 3" right?
What you are talking about here are assumptions, which are usually implicit and sometimes not even part of the thought process. One purpose of writing a design document is considering and stating the relevant assumptions.
Rust for example. Enforce the invariant everywhere. It's probably harder to build, but it is also easier to find out where and why it won't instead of have infinite memory safety problems everywhere.
And about implicit invariant? Probably a simple code change can fix it, a test would cover it, or an assertion. Or you need a whole new language to describe and test it safely(like rust) because the one you are using just can't. People are not even close to find out the answer of this problem, but the direction is to make it explicit.
I understand you cannot assert all invariants, but as far as I can see, that's your main tool against this problem.
In a way, "invariant" are the same as "tests sets": they are here to help to ensure that the product will have some specific properties. Both are used during the product development and checked. And both must be updated when the specs change
Sounds like a communication issue to me.
It’s a problem solving tool. I sometimes use the term to mean “I will assume this to be true in order to solve an intractable problem. If that succeeds, I need to find a way to guarantee this is actually invariant.”
I've seen mature teams get this wrong even for basic things. For example, they had a factor option, but all of their tests only used a factor of 1.0 -- applying the factor 0 or 2+ times didn't fail any tests, the factor was effectively completely untested despite "coverage".
My tests caught it because I was a dependent of their code and, frankly, trusted them much less than they trusted themselves. Rightly in this case, and it turned out, many others.
My philosophy was to test with exactly the configuration to be used in production and as much as possible [representative subsets of] the data to be used in production. Their philosophy was only to test contrived isolated cases which proved grossly inadequate with respect to production.
It seems like Matt did not fully understand the platform he was developing for and introduced a bug as his code did not satisfy the invariants.
It is really nice when invariants are checked either in types or in tests so Matt would have been alerted when he introduced the bug.
I don't like the discourse the article introduces. It must always be the one who writes the newest code who has the responsibility to adjust previous code. That also includes changing obselete modules (where invariants does no suffice anymore).
I think it's a shared responsibility, the person who wrote the old code needs to have written it in a way that makes it hard to inadvertently break, through testing or otherwise. The person who modifies the code needs to understand what to do if a test fails (modify the test? modify the code?) If we had to guarantee that our changes are never going to break any code downstream, no matter how poorly tested or engineered, we couldn't make changes at all.