Follow up to: Weird behavior with bracket and global IORef
We had the below requirements at work and wrote a solution that works decently well. It’s recently started bothering me again, so I’m taking another stab at it. The challenge for you is to do better. A bonus challenge: is there a relatively small change to GHC that we could make someday that would improve this?
For simplicity, assume that this is a greenfield project, so you can architect the entire codebase around this design. The requirements are:
- Every change to the code must be feature flagged when first released. This includes bug fixes and refactorings.
- Feature flags are fetched at runtime from the database
- If a refactor breaks prod, you can turn it off immediately without waiting for a rebuild
- Unit tests should exercise all branches
- e.g. disabling a feature flag reverts to the old behavior successfully
The solution we came up with:
- Fetch flags in main, store in global IORef
- getFlagIO reads from global IORef
- getFlag reads from a NeedsFlags constraint which contains the flags
- We used reflection, but you could also imagine using implicit parameters here
- Add the NeedsFlags constraint up the call stack to the nearest IO function, which uses a passFlags function to read the global IORef and pass it in the constraint
Solutions we rejected:
- unsafePerformIO in pure functions - GHC optimizes these out so we cant toggle the flag in unit tests
- would a hypothetical
noupdate
function help this? (see linked discussion)
- would a hypothetical
- pass flags as first arg to every function in the code base - too manual
- make every function monadic (e.g. ReaderT) - too verbose
- cpp macros - cant modify at runtime