|
| 1 | +--- |
| 2 | +title: "Making Impossible States Impossible: Type-Safe Domain Modeling with Functional Dependency Injection" |
| 3 | +date: 2025-08-18 |
| 4 | +description: "Learn how to use rich types, phantom types, and partial application to eliminate impossible states and implement Clean Architecture's dependency inversion in a functional way. Discover why the compiler is your best friend for building robust domain models." |
| 5 | +tags: |
| 6 | + [ |
| 7 | + "functional-programming", |
| 8 | + "clean-architecture", |
| 9 | + "types", |
| 10 | + "elm", |
| 11 | + "fsharp", |
| 12 | + "domain-modeling", |
| 13 | + "dependency-inversion", |
| 14 | + ] |
| 15 | +draft: true |
| 16 | +--- |
| 17 | + |
| 18 | +Most applications don't fail because algorithms are hard—they fail because our models allow states that make no sense in the domain. "User without email but verified", "order that's both shipped and cancelled", "sum < 0", "modal dialog both closed and active". These states should be impossible from the start. |
| 19 | + |
| 20 | +> Among the most time-consuming bugs to track down are the ones where we look at our application state and say "this shouldn't be possible." |
| 21 | +> |
| 22 | +> - Richard Feldman, elm-conf 2016 |
| 23 | +
|
| 24 | +This is where typed functional languages (Elm, Haskell, F#, etc.) truly shine. They give us tools to express domain rules directly in the type system itself. The result? The compiler refuses to build when we try to represent an illegal state. In short: **make impossible states impossible**. |
| 25 | + |
| 26 | +If "no runtime exceptions" sounds appealing, then "no impossible state at runtime" must be even better! 🥳 |
| 27 | + |
| 28 | +## Rich Types as Living Documentation |
| 29 | + |
| 30 | +An important observation from Scott Wlaschin's "Domain Modeling Made Functional" is that good domain types should be so clear that domain experts (without programming background) can read them and recognize familiar concepts and rules. In other words: well-chosen, rich types function as living documentation—and the compiler enforces them. |
| 31 | + |
| 32 | +This is simultaneously one of the strongest arguments for typed functional languages: they make it natural to express the domain precisely, get fast feedback during compilation, and collaborate more closely with domain experts about the right concepts. |
| 33 | + |
| 34 | +## Parse, Don't Validate |
| 35 | + |
| 36 | +Instead of "validating" data scattered throughout the code, we do one thing before bringing data into the domain layer: we parse raw data into rich, type-safe domain values. From there, the rest of the system works with values that are already guaranteed to be valid. This principle is excellently explained in Lexi Lambda's "Parse, don't validate" ([link](https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/)). |
| 37 | + |
| 38 | +Example in Elm—a non-empty string and an email: |
| 39 | + |
| 40 | +```elm |
| 41 | +module Domain exposing (NonEmptyString, Email, nonEmpty, email) |
| 42 | + |
| 43 | +type NonEmptyString |
| 44 | + = NonEmptyString String -- note: constructor not exposed |
| 45 | + |
| 46 | +nonEmpty : String -> Result String NonEmptyString |
| 47 | +nonEmpty s = |
| 48 | + if String.length s > 0 then |
| 49 | + Ok (NonEmptyString s) |
| 50 | + else |
| 51 | + Err "Cannot be empty" |
| 52 | + |
| 53 | +type Email |
| 54 | + = Email String |
| 55 | + |
| 56 | +email : String -> Result String Email |
| 57 | +email s = |
| 58 | + if String.contains "@" s then |
| 59 | + Ok (Email s) |
| 60 | + else |
| 61 | + Err "Invalid email" |
| 62 | +``` |
| 63 | + |
| 64 | +The point: After parsing, there are no "empty strings" or "invalid emails" in the domain. They can only exist as errors in boundary code, not in the rest of the system. And since values are immutable, they also can't be "corrupted" accidentally later in the program flow. |
| 65 | + |
| 66 | +## Sum Types: Single Source of Truth for State |
| 67 | + |
| 68 | +Instead of scattered booleans, represent possible state with an explicit union/sum type. This is the core of "Making impossible states impossible": |
| 69 | + |
| 70 | +```elm |
| 71 | +type Session |
| 72 | + = Anonymous |
| 73 | + | Authenticated User |
| 74 | + |
| 75 | +-- Impossible to have a "partially logged in" user |
| 76 | +``` |
| 77 | + |
| 78 | +Another example is asynchronous loading (avoid `isLoading`, `error`, `data` that can contradict each other): |
| 79 | + |
| 80 | +```elm |
| 81 | +type RemoteData error value |
| 82 | + = NotAsked |
| 83 | + | Loading |
| 84 | + | Success value |
| 85 | + | Failure error |
| 86 | +``` |
| 87 | + |
| 88 | +Here each state is mutually exclusive and complete—UI logic becomes both simpler and safer. |
| 89 | + |
| 90 | +## Functional Dependency Injection: Partial Application as Architecture |
| 91 | + |
| 92 | +Here's where Clean Architecture's Dependency Inversion Principle meets functional programming in a beautiful way. Instead of injecting heavy interfaces and objects, we can inject **functions** as dependencies. As Scott Wlaschin demonstrates in "Domain Modeling Made Functional", partial application becomes the functional equivalent of dependency injection. |
| 93 | + |
| 94 | +Consider this workflow step from the book: |
| 95 | + |
| 96 | +```fsharp |
| 97 | +type ValidateOrder = |
| 98 | + CheckProductCodeExists // dependency |
| 99 | + -> CheckAddressExists // dependency |
| 100 | + -> UnvalidatedOrder // input |
| 101 | + -> Result<ValidatedOrder, ValidationError> // output |
| 102 | +``` |
| 103 | + |
| 104 | +The key insight: dependencies come first in the parameter order, followed by input, then output. This allows us to use partial application to "inject" our dependencies: |
| 105 | + |
| 106 | +```fsharp |
| 107 | +// "Inject" dependencies via partial application |
| 108 | +let validateOrderStep = |
| 109 | + validateOrder |
| 110 | + checkProductCodeExists // injected dependency |
| 111 | + checkAddressExists // injected dependency |
| 112 | + // Returns: UnvalidatedOrder -> Result<ValidatedOrder, ValidationError> |
| 113 | +``` |
| 114 | + |
| 115 | +This is **dependency inversion without interfaces**! We've inverted the dependency (the function depends on abstractions, not concretions), and we can easily substitute different implementations for testing or different environments. |
| 116 | + |
| 117 | +### Why This Respects Clean Architecture |
| 118 | + |
| 119 | +This approach follows Uncle Bob's dependency inversion principle perfectly: |
| 120 | + |
| 121 | +1. **High-level modules don't depend on low-level modules**: Our `ValidateOrder` function doesn't know about specific database implementations |
| 122 | +2. **Both depend on abstractions**: The function signature `CheckProductCodeExists` is our abstraction |
| 123 | +3. **Abstractions don't depend on details**: The function type doesn't care how product codes are actually checked |
| 124 | + |
| 125 | +But unlike traditional OOP dependency injection, we avoid the complexity of IoC containers, interfaces, and object lifecycle management. The type system and partial application handle everything for us. |
| 126 | + |
| 127 | +## Phantom Types: Compile-Time Filtering |
| 128 | + |
| 129 | +Phantom types let us "color" values without runtime cost. They're used to separate elements that shouldn't be mixed. Here's an elegant example where "green cars" and "polluting cars" are distinguished using a type parameter: |
| 130 | + |
| 131 | +```elm |
| 132 | +type Car fuel |
| 133 | + = ElectricCar |
| 134 | + | HydrogenCar |
| 135 | + | DieselCar |
| 136 | + |
| 137 | +type Green = Green |
| 138 | +type Polluting = Polluting |
| 139 | + |
| 140 | +electricCar : Car fuel |
| 141 | +electricCar = ElectricCar |
| 142 | + |
| 143 | +dieselCar : Car Polluting |
| 144 | +dieselCar = DieselCar |
| 145 | + |
| 146 | +createGreenCarFactory : (data -> List (Car Green)) -> Factory |
| 147 | +createGreenCarFactory build = |
| 148 | + -- implementation irrelevant; signature forbids diesel |
| 149 | + Debug.todo "..." |
| 150 | +``` |
| 151 | + |
| 152 | +The key is that `electricCar` is polymorphic (`Car fuel`) and can therefore behave as "green" when required, while `dieselCar` is locked to `Polluting` and rejected by the compiler where "green" is expected. |
| 153 | + |
| 154 | +### Process Flow as State Machine with Phantom Types |
| 155 | + |
| 156 | +Phantom types also work well for modeling process flows where order must be correct, without creating a maze of intermediate types: |
| 157 | + |
| 158 | +```elm |
| 159 | +type Step step |
| 160 | + = Step Order |
| 161 | + |
| 162 | +type Start = Start |
| 163 | +type WithTotal = WithTotal |
| 164 | +type WithQuantity = WithQuantity |
| 165 | +type Done = Done |
| 166 | + |
| 167 | +start : Order -> Step Start |
| 168 | +setTotal : Int -> Step Start -> Step WithTotal |
| 169 | +adjustQuantityFromTotal : Step WithTotal -> Step Done |
| 170 | + |
| 171 | +setQuantity : Int -> Step Start -> Step WithQuantity |
| 172 | +adjustTotalFromQuantity : Step WithQuantity -> Step Done |
| 173 | + |
| 174 | +done : Step Done -> Order |
| 175 | + |
| 176 | +-- Two legal flows |
| 177 | +flowPrioritizingTotal : Int -> Order -> Order |
| 178 | +flowPrioritizingTotal total order = |
| 179 | + start order |
| 180 | + |> setTotal total |
| 181 | + |> adjustQuantityFromTotal |
| 182 | + |> done |
| 183 | + |
| 184 | +flowPrioritizingQuantity : Int -> Order -> Order |
| 185 | +flowPrioritizingQuantity quantity order = |
| 186 | + start order |
| 187 | + |> setQuantity quantity |
| 188 | + |> adjustTotalFromQuantity |
| 189 | + |> done |
| 190 | +``` |
| 191 | + |
| 192 | +The signatures prevent us from skipping steps or mixing order. This gives the advantage of a state machine—with compiler checking—without explosion of separate intermediate types. |
| 193 | + |
| 194 | +## Phantom Builder Pattern: Correct Order, Guaranteed |
| 195 | + |
| 196 | +Builders can also be type-secured so that necessary steps must be taken in the right order—before a "finished" object can be produced. |
| 197 | + |
| 198 | +```elm |
| 199 | +module Button exposing (Button, new, withDisabled, withOnClick, withText, withIcon, toHtml) |
| 200 | + |
| 201 | +type Button constraints msg |
| 202 | + = Button (List (Html.Attribute msg)) (List (Html msg)) |
| 203 | + |
| 204 | +-- Start state: we MUST choose an interaction (onClick OR disabled) |
| 205 | +new : Button { needsInteractivity : () } msg |
| 206 | +new = |
| 207 | + Button [] [] |
| 208 | + |
| 209 | +withDisabled : |
| 210 | + Button { c | needsInteractivity : () } msg |
| 211 | + -> Button { c | hasInteractivity : () } msg |
| 212 | +withDisabled (Button attrs children) = |
| 213 | + Button (Html.Attributes.disabled True :: attrs) children |
| 214 | + |
| 215 | +withOnClick : |
| 216 | + msg |
| 217 | + -> Button { c | needsInteractivity : () } msg |
| 218 | + -> Button { c | hasInteractivity : () } msg |
| 219 | +withOnClick message (Button attrs children) = |
| 220 | + Button (Html.Events.onClick message :: attrs) children |
| 221 | + |
| 222 | +withText : |
| 223 | + String |
| 224 | + -> Button c msg |
| 225 | + -> Button { c | hasTextOrIcon : () } msg |
| 226 | +withText str (Button attrs children) = |
| 227 | + Button attrs (Html.text str :: children) |
| 228 | + |
| 229 | +toHtml : |
| 230 | + Button { c | hasInteractivity : (), hasTextOrIcon : () } msg |
| 231 | + -> Html msg |
| 232 | +toHtml (Button attrs children) = |
| 233 | + Html.button (List.reverse attrs) (List.reverse children) |
| 234 | +``` |
| 235 | + |
| 236 | +The signatures do the work: `toHtml` cannot be called until we've satisfied both requirements. We can choose order freely, and we can add more "markings" later without changing existing users. |
| 237 | + |
| 238 | +## Package by Component: Organizing Rich Domain Models |
| 239 | + |
| 240 | +Following Clean Architecture's "Package by Component" principle (from Chapter 34: "The Missing Chapter"), we should organize our domain types by business capability, not technical layer: |
| 241 | + |
| 242 | +``` |
| 243 | +src/ |
| 244 | +├── Order/ |
| 245 | +│ ├── Types.elm -- Order, OrderId, OrderStatus |
| 246 | +│ ├── Validation.elm -- ValidateOrder function |
| 247 | +│ ├── Pricing.elm -- PriceOrder function |
| 248 | +│ └── Acknowledgment.elm -- AcknowledgeOrder function |
| 249 | +├── Product/ |
| 250 | +│ ├── Types.elm -- Product, ProductCode |
| 251 | +│ └── Catalog.elm -- CheckProductCodeExists |
| 252 | +└── Customer/ |
| 253 | + ├── Types.elm -- Customer, EmailAddress |
| 254 | + └── Verification.elm -- CheckAddressExists |
| 255 | +``` |
| 256 | + |
| 257 | +Each component exposes only what other components need to know about, keeping implementation details private. This makes it easier to evolve business logic without breaking other parts of the system. |
| 258 | + |
| 259 | +## Practical Checklist for "Impossible States" |
| 260 | + |
| 261 | +- Define sum types for states that would otherwise be booleans that can be combined incorrectly |
| 262 | +- Create rich domain values (newtypes/aliases) for "important strings" like Email, UUID, NonEmpty, NonNegative, etc. |
| 263 | + - Use "smart constructors" internally and don't expose type constructors |
| 264 | +- Parse at boundaries (IO/HTTP/DB)—give the rest of the system safe types |
| 265 | +- Use phantom types to distinguish subgroups that shouldn't be mixed |
| 266 | +- Think building sequences as types (phantom builder) when order matters |
| 267 | +- Apply partial application for functional dependency injection |
| 268 | +- Package by component, not by technical layer |
| 269 | + |
| 270 | +## Testing and Compiler Assistance |
| 271 | + |
| 272 | +With strong types and rich domain models, the compiler takes much of the burden of ensuring illegal states cannot exist. This reduces the need for manual tests, since many errors are already caught at compile time. |
| 273 | + |
| 274 | +Testing is still important for ensuring logic behaves as expected, but the compiler helps us eliminate a large class of errors that would otherwise be difficult to find and fix. Instead of testing that "user cannot be both logged in and logged out," the type system guarantees this state simply cannot exist. |
| 275 | + |
| 276 | +## When Is This Worth It? |
| 277 | + |
| 278 | +This is especially valuable in systems where robustness, predictability, and maintainability are important. When the consequences of error states are significant—whether for users, business, or security—it pays to model the domain so that illegal states become impossible at compile time. |
| 279 | + |
| 280 | +As they say about writing tests: just do it where you don't want the application to fail... |
| 281 | + |
| 282 | +## Functional Architecture in Practice |
| 283 | + |
| 284 | +The combination of rich types and functional dependency injection gives us a powerful architecture pattern: |
| 285 | + |
| 286 | +1. **Domain layer**: Pure functions with rich types, dependencies as function parameters |
| 287 | +2. **Application layer**: Compose domain functions using partial application to inject dependencies |
| 288 | +3. **Infrastructure layer**: Implement the actual dependency functions (database access, external APIs, etc.) |
| 289 | + |
| 290 | +This creates a clean separation where the domain layer has no knowledge of infrastructure concerns, yet we avoid the complexity of traditional dependency injection frameworks. |
| 291 | + |
| 292 | +## Conclusion |
| 293 | + |
| 294 | +Typed functional languages make it both possible and natural to move validation from runtime to compile time. With sum types, rich domain values, phantom types, and functional dependency injection, we can achieve models that simply don't let us represent illegal states. |
| 295 | + |
| 296 | +Combined with Clean Architecture principles like dependency inversion and package by component, this approach gives us simpler code, safer refactoring, and fewer production errors. As system complexity continues to grow and robustness becomes increasingly critical, techniques for making impossible states impossible become more relevant than ever. |
| 297 | + |
| 298 | +The compiler becomes our most trusted teammate—one that never gets tired, never misses edge cases, and works 24/7 to ensure our domain models stay consistent and correct. |
| 299 | + |
| 300 | +## References |
| 301 | + |
| 302 | +- [Parse, don't validate – Lexi Lambda](https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/) |
| 303 | +- [Single out elements using phantom types – Jeroen Engels](https://jfmengels.net/single-out-elements-using-phantom-types) |
| 304 | +- [Making impossible states impossible (video)](https://www.youtube.com/watch?v=IcgmSRJHu_8) |
| 305 | +- [Phantom Builder Pattern (video)](https://www.youtube.com/watch?v=Trp3tmpMb-o&t=377s) |
| 306 | +- [Domain Modeling Made Functional – Scott Wlaschin](https://amzn.to/4loKAlq) |
| 307 | +- [Elm Patterns – Process flow using phantom types](https://sporto.github.io/elm-patterns/advanced/flow-phantom-types.html) |
| 308 | +- [Clean Architecture – Robert C. Martin](https://www.amazon.com/Clean-Architecture-Craftsmans-Software-Structure/dp/0134494164) |
0 commit comments