What If? Driven Development
aftenposten.substack.com
aftenposten.substack.com
99% of software projects don't have a reliable definition of "correct". They don't have a watertight ontology that you can infer types from. Requirements continuously change. Your "Whys" quickly end with "because so-and-so said so", and will change tomorrow. "What if?" Driven Development presumes that there is a finite and known set of states that you can design around.
Most software development (outside of extremely well-studied domains, like financial exchanges and aerospace) occurs in the absence of well-defined specifications. That's the fundamental reason why correctness-oriented paradigms and languages aren't popular and never will be. And that's okay.
I've gotten on board the TLA+ train. Just the act of formally specifying what you do know makes everything you don't painfully obvious. Formal models also make dealing with that constant change easier, because The Rules live in one concrete place, in a formal modeling language, and free from the ambiguities that requirements expressed in English prose are subject to. It's not a perfect solution, but it can help cut through the noise.
A bad specification is worse than no specification; this was one of the lessons of OOP-mania (a rigid ontology works great, until it doesn't, and then it's a disaster). An ostensibly correct "specification" inferred by a lone engineer could become Wrong tomorrow after stakeholder input.
That's why I would recommend against these sort of correctness-oriented paradigms unless there is widespread buy-in and understanding from your stakeholders, company culture, etc.
Of course there is merit to using tools like TLA+ to model distributed systems and assist development, but that shouldn't be conflated with having a well-defined specification from the get-go.
The "5 whys" always seemed to stop just short of formally identifying the root cause of conflicting management techniques, inexperienced decision makers, poor planning and people not understanding the technology they were making decisions and deadlines around.
I saw this first hand a couple years back, except... we always seemed to stop one step short of the "so and so said so" because... "we don't want to blame people". So often it would stop at me, instead of going one more level up beyond me to the people who gave me impossible/contradictory things to do.
So, to get past the "no blame" thing, I got "we're not 'blaming' you, but perhaps you need more training on X" and that sort of thing. I've been doing this professionally longer than most of the rest of the team have been adults (or in some cases, alive)... no, I don't need more training on X. The system didn't fail because I hit a problem with X. The system failed because 3 people set too many deadlines concurrently, and when it was pointed out to everyone (individually, then in a group setting), no one would budge and I get told "just make it happen". Laws of physics, etc.
But... let's make sure we don't "blame" people! Just... it's OK if we blame you, we just won't call it 'blame'. We're just "identifying".
Once you build a good team, the source of incorrectness shifts to the vagueness of the requirements and domain complexity. And indeed, attempting to solve it (be it with types or with tests) is pointless.
In essence, if your team finds a lot of benefits in types, you have a mediocre team. And mediocrity is almost unavoidable at scale, that's why big companies heavily invest into those correctness procedures (unit tests, types, various forms of bureaucracy, etc).
Which is all great and wonderful - until things change, and requests can suddenly come in without an ID - meaning that you must assign them an ID in your response. Not so easy to model using only the type system!
And then later as session support is added, there are now two IDs, but one of them must be omitted if the session is not already active in the system. So now you must either make your request aware of the session system so it can check, or you must move the validation out of the request. Your type system is no longer helping you avoid erroneous conditions, and your lack of tests leaves you exposed.
It's all great when the system is small and easy to model...
But this is where type systems shine!
You create a RequestWithoutId and let the boundary accept it (per your scenario). Hit compile and see what fails, in this case, calling the next method into the system will fail, because it expected a different type. Now you can choose to keep plumbing RequestWithoutId through the system - guided only by the type-checker, or you can write a RequestWithoutId->RequestWithId implementation. If your system functions correctly for-all RequestWithId, and you can make a conversion for-all RequestWithoutIds, then your system will function correctly for-all RequestWithoutIds.
But can you turn a RequestWithoutId into a RequestWithId? Inspect the type of Id. If it's a UUID, it's most likely safe to generate in-place, and you're gold. If it's a String or an Int or an Any, then it's no longer mechanical and you probably need to start thinking.
Realistically, nobody wants to review all of that. And you will get phantom code of RequestWithZ that isn't actually valid anymore, but nobody remembers it and there is nothing about the type that makes that clear. In fact, you assume that it has a type, it must be needed.
I suspect you will push for groups of the fields that are relevant for the request. Such that it is RequestWithGroupA and such, but, isn't that what "Request" is supposed to be already? The group of data sent? So now you start down a taxonomy of Requests and you build an impressive machine that can deal with this.
Which is even more amusing, as that is called a program. And often we accept some invalid possibilities for highly theoretical reasons that help keep the programs easy to read for the people using them.
It is the opposite. My IDE will show "no usages of RequestWithZ". I will not get any such help if it was encoded as Request.get("z").
Now, you are right that you can contrive a situation where you can choke off all parts that trim this down. And then you can use the type system as a tool to help delete dead code. When it works, it is a great tool. Just like the example in the original article. Please don't take my point as a complete teardown of the idea. Just don't oversell it, either.
Once you start naming your types "RequestWithoutId" you have committed to using language alien to everyone but engineers (might even be alien to engineers).
There are tremendous benefits in using "ubiquitous language" (in DDD terms), and type-obsession means you'll never get there. Business and engineering goals will never converge. Engineers will live in their own little world, procrastinating in their type puzzles and feeling productive doing so.
Look up the concept of "make illegal states unrepresentable."
EDIT: RequestWithoutId is not the best example of this, but the point remains
An original request without an ID still has some kind of business relationship, even if only because it really did exist at one point in the past. So there's _some_ proper name out there, even if it's something like InternalRequest or InformalRequest or ObsoleteRequest or BeforeTheMergerRequest.
Do that munging at the boundary? Your boundary is gonna eventually be a heap of messy compatibility code to normalize requests. PLUS you would need to build some sort of sidecar to pass forward any necessary information about which sort of boundary request it was, once the business logic somewhere deep in the system starts depending on more than just the post-normalization form. If instead you want to reflect that in the type system through the whole system you need some sort of crazy-complex type hierarchy complete with a bunch of conditional validation rules or lots of permutations of types... or Maybes all over the place that just shift the burden back to the business logic code, to know which ones are REALLY needed when.
Cause your business logic has no limits on its complexity, but your type system probably does.
That's the theory behind highly mature CMMI levels. 5 means ongoing process analysis.
That said, I hate CMMI (and I took the course, and drank the Kool-Aid). It's hunting mice with a ten-gauge, for almost every project, excepting really large, multidisciplinary ones. I have worked on those (that were badly in need of process maturity).
Most projects that I've worked on; even fairly ambitious shipping products, would have benefitted from improved process maturity, but CMMI 4/5 or ISO9001 were way too burdensome.
Agile, I think, has tried to improve process, but it has been turned into something that I don't think was originally envisioned.
I suspect that NASA is CMMI Level 6.
6) Regular maintenance is often more expensive than repairing the car on failure, or buying a new car, and when maintenance predicta that a part is going to fail, the solution is still to replace it
Likewise, in the ID parsing example, there is a quick jump to "It should ignore values past &, Because & is the field separator" without asking why a field separator gets into this parsing function at all. Is this function expected to handle strings that contain such a separator? (All code operates on certain assumptions, so it is futile to expect a parsing function to handle "all" cases -- there simply isn't a useful definition of "all" that could be the basis for that.)
https://twitter.com/nivertech/status/1413392990211657729
It looks like Maybe/Option is just a Booleans-in-disguise. As I'm in favor of banning Booleans (and if-statements), so it makes sense to ban Maybe/Option-s also.
This is a unique take. Care to expand?
Realistically, it's no different, checking if something is null or None. Yeah, some languages have a lot of sugar around optional thanks to rich type systems.
Imagine the following pseudo rust code:
if let Some(x) = x {}
is not that different from
if x != null {}
Or any other type of boolean check.
I think you have to be around optional long enough to understand the benefits.
That is correct. However, what languages with null lack is a way to say "See this String here? This is actually a String - not the absence of a String".
It is nonsense to encode both A and lack-of-A as A.
My language has maybe<T>, and I have automatic "re-wrapping" (not sure if there is a better term). where
For example, with
record R { int x; }
maybe<R> r;
I can access r.x yet the type is maybe<int>.It also has nice ergonomics with bind, map and such. And the fact that a None is not even comparable to an 'a.
1. Booleans are primitive types without rich domain-specific information or context. When using true/false, it can be more beneficial to employ more descriptive options such as AnsweredYes/AnsweredNo, PositiveResult/NegativeResult, LightsOn/LightsOff, or UserOnline/UserOffline. Mixing and matching booleans of different domain types can lead to errors that the compiler may not catch. For instance:
bool isLightsOn = true;
bool isUserOnline = false;
bool isAdmin = true;
if(isLightsOn && !isUserOnline && !isAdmin) {
...
}
compare with Elixir: case {lights_status, connection_status, user_role} do
{:lights_on, :user_offline, :unprivileged_role} -> ...
...
end
The example above is basically a Decision Table [1].While booleans can still be used in system code or libraries, it is advisable to avoid them in application code.
2. Preemptive pluralization [2] makes it easier to refactor code. Going from two options to three is simpler than transitioning from one option to two. Consider the following examples:
bool isLightsOn;
or type LightsStatus = Bool
vs type LightsStatus = LightsOn | LightsOff
Suppose there is a new requirement to support dimmed lights. In the former case, significant code refactoring is necessary. In the latter case, adding a new option to the sum type is straightforward, and the compiler assists in handling the changes: type LightsStatus = LightsOn | LightsOff | LightsDimmed
Similarly, let's examine another scenario: boolean isOnline;
or type ConnectionStatus = Bool
vstype ConnectionStatus = Online | Offline
The latter option can easily evolve into:
type ConnectionStatus = Online | Offline | BadConnectivity
and later: type ConnectionStatus =
| OnlineMobile
| OnlineFastInternet
| Offline
| BadConnectivity
| IntermittentConnection
| ConnectedToMeshnet
3. Booleans encourage the overuse of conditionals and branching, including nested branching. This can increase the cyclomatic complexity [3] of the code. It is important to manage cyclomatic complexity to keep the codebase maintainable and easier to reason about.Also see "Get rid of those boolean function parameters" [4].
4. Booleans can indicate whether something occurred, but they lack both the necessary context and the timestamp indicating when the event took place. However, by transforming booleans into nullable or optional timestamps, one can achieve a rudimentary implementation of Domain Events or Event Sourcing.
user_connected_at: nullable(Timestamp)
lights_turned_on_at: nullable(Timestamp)
record_deleted_at: nullable(Timestamp)
NOTE: I'm not advocating for the pattern above, but it's widely used because of the limitations inherent in booleans.--
References:
[1] https://en.wikipedia.org/wiki/Decision_table
[2] https://www.swyx.io/preemptive-pluralization
[3] https://en.wikipedia.org/wiki/Cyclomatic_complexity
[4] https://mortoray.com/get-rid-of-those-boolean-function-param...
For 2 reasons:
1. the 2-case, so it will be harder to refactor once you add additional cases
2. they're missing domain/context/intent (Why value is missing?)
So, Maybe/Option is an improvement over null/nil and boolean flags, and ADTs/FSMs are improvement over Maybe-s.
> I'm in favor of banning Booleans (and if-statements)
I would love to hear your reasoning on this. I think that either I'm misunderstanding you, or one of us is crazy ))> instead of accepting a boolean argument, write a new function
Which can be expanded to
> instead of an if-statement, write a new function
You can't remove branching from software (otherwise, just create a jpeg), but you can structure your software in such a way that it appears not to branch.
I think it's weird, but I worked with someone like that and learned a few interesting things (for example, I usually do write a new function instead of adding an optional/boolean argument -- unless it makes things worse for the caller because the argument comes from outside the context/domain). But with all due respect to people like this, it's bananas to do it all the time.
The problem is that it gets interpreted as “I’ve been asked xxx but what if what they really want is yyy”. And this leads to missed requirements, premature optimization, and all other evils.
1. Sure, never add code and complexity for features that were never requested. Don't implement "anticipated requirements"
2. Sometimes, clients or stakeholders need to be reminded of a bigger picture to make clear that the design decisions they suggest would cause hard-to-maintain code, security problems or bad UX, proposing to modify or clarify the requirements.
3. I'll try to add some (wildly different) examples to 2:
- This form needs no server-side validation as long as it can only be submitted with a valid login.
- This form does not need a save button. Let's just save on input / field change (not regarding context and field types, or integrity constraints, or UX in edge cases, or complexity)
- We already have this similar-looking UI element, so we can just reuse and adapt it
- Can't we just let the customer define validation rules in an excel sheet using a very specific syntax that we define
- Please use this combobox/multiselect design and implement it perfectly. It's beautiful. (Design has no indication of selected values while closed)
- No user will ever click this button so fast, let's ignore that case
There are some bogus examples in there where most developers would seek dialogue to improve on the requirements, or which wouldn't occur given a healthy PM and development culture.
Or would they?