> How many times did you leave a comment on some branch of code stating "this CANNOT happen" and thrown an exception? Did you ever find yourself surprised when eventually it did happen? I know I did, since then I at least add some logs even if I think I'm sure that it really cannot happen.
I'm not sure what the author expects the program to do when there's an internal logic error that has no known cause and no definite recovery path. Further down the article, the author suggests bubbling up the error with a result type, but you can only bubble it up so far before you have to get rid of it one way or another. Unless you bubble everything all the way to the top, but then you've just reinvented unchecked exceptions.
At some level, the simplest thing to do is to give up and crash if things are no longer sane. After all, there's no guarantee that 'unreachable' recovery paths won't introduce further bugs or vulnerabilities. Logging can typically be done just fine within a top-level exception handler or panic handler in many languages.
Ideally, if you can convince yourself something cannot happen, you can also convince the compiler, and get rid of the branch entirely by expressing the predicate as part of the type (or a function on the type, etc.)
Language support for that varies. Rust is great, but not perfect. Typescript is surprisingly good in many cases. Enums and algebraic type systems are your friend. It'll never be 100% but it sure helps fill a lot of holes in the swiss cheese.
Because there's no such thing as a purely internal error in a well-constructed program. Every "logic error" has to bottom out in data from outside the code eventually-- otherwise it could be refactored to be static. Client input is wrong? Error the request! Config doesn't parse? Better specify defaults! Network call fails? Yeah, you should have a plan for that.
A comment "this CANNOT happen" has no value on itself. Unless you've formally verified the code (including its dependencies) and have the proof linked, such comments may as well be wishes and prayers.
Yes, sometimes, the compiler or the hardware have bugs that violate the premises you're operating on, but that's rare. But most non pure algorithms (side effects and external systems) have documented failure cases.
>Further down the article, the author suggests bubbling up the error with a result type, but you can only bubble it up so far before you have to get rid of it one way or another. Unless you bubble everything all the way to the top, but then you've just reinvented unchecked exceptions.
Not necessarily. Result types are explicit and require the function signature to be changed for them.
I would much prefer to see a call to foo()?; where it's explicit that it may bubble up from here, instead of a call to foo(); that may or may not throw an exception my way with no way of knowing.
Rust is absolutely not perfect with this though since any downstream function may panic!() without any indication from its function signature that it could do so.
> At some level, the simplest thing to do is to give up and crash if things are no longer sane.
The problem with this attitude (that many of my co-workers espouse) is that it can have serious consequences for both the user and your business.
- The user may have unsaved data
- Your software may gain a reputation of being crash-prone
If a valid alternative is to halt normal operations and present an alert box to the user saying "internal error 573 occurred. please restart the app", then that is much preferred IMO.
Heh, recently I had to fix a bug in some code that had one of these comments. Feels like a sign of bad code or laziness. Why make a path that should not happen? I can get it when it's on some while loop that should find something to return, but on a if else sequence it feels really wrong.
> A common pattern would be to separate pure business logic from data fetching/writing. So instead of intertwining database calls with computation, you split into three separate phases: fetch, compute, store (a tiny ETL). First fetch all the data you need from a database, then you pass it to a (pure) function that produces some output, then pass the output of the pure function to a store procedure.
Does anyone have any good resources on how to get better at doing "functional core imperative shell" style design? I've heard a lot about it, contrived examples make it seem like something I'd want, but I often find it's much more difficult in real-world cases.
Random example from my codebase: I have a function that periodically sends out reminders for usage-based billing customers. It pulls customer metadata, checks the customer type, and then based on that it computes their latest usage charges, and then based on that it may trigger automatic balance top-ups or subscription overage emails (again, depending on the customer type). The code feels very messy and procedural, with business logic mixed with side effects, but I'm not sure where a natural separation point would be -- there's no way to "fetch all the data" up front.
What I'm currently doing could be called compute-fetch-store: the compute part is done entirely in the database with SQL views stacked one on top of the other. Then the program just fetches the result of the last view and stores it where it needs to be stored.
Stacked views are sometimes considered an anti-pattern, but I really like them because they're purely functional, have no side-effects whatsoever and cannot break (they either work or they don't, but they can't start breaking in the future). And they're also stateless: they present a holistic view of the data that avoids iterations and changes how you think about it. (Data is never really 'transformed', it's simply 'viewed' from a different perspective.)
Not saying that's the only way, or the best way, or even a good way! But it works for me.
I think it would apply well to the example: you could have a view, or a series of views, that compute balance top-ups based on a series of criteria; then the program would read that view and send email without doing any new calculation.
> It pulls customer metadata, checks the customer type, and then based on that it computes their latest usage charges, and then based on that it may trigger automatic balance top-ups or subscription overage emails (again, depending on the customer type).
So compute those things, and store them somewhere (if only an in-memory queue to start with)? Like, I can already see a separation between an ETL stage that computes usage charges, which are probably worth recording in a datastore, and then another ETL stage that computes which top-ups and emails should be sent based on that, which again is probably worth recording for tracing purposes, and then two more stages to actually send emails and execute payment pulls, which it's actually quite nice to have separated from the figuring out which emails to send part (if only so you can retry/debug the latter without sending out actual emails)
> Does anyone have any good resources on how to get better at doing "functional core imperative shell" style design?
Hexagonal architecture[0] is a good place to start. The domain model core can be defined with functional concepts while also defining abstract contracts ( abstractly "ports", concretely interface/trait types) implemented in "adapters" (usually technology specific, such as HTTP and/or SMTP in your example).
> there's no way to "fetch all the data" up front.
this is incorrect
I assume there's more nuance and complexity as for why it feels like there's no way. Probably involving larger design decisions that feel difficult to unwind. But data collection, decisions, and actions can all be separated without much difficulty with some intent to do so.
I would suggest caution, before implementating this directly: but imagine a subroutine that all it did was lock some database table, read the current list of pending top up charges required, issue the charge, update the row, and unlock the table. An entirely different subroutine wouldn't need to concern itself with anything other than data collection, and calculating deltas, it has no idea if a customer will be charged, all it does is calculate a reasonable amount. Something smart wouldn't run for deactivated/expiring accounts, but why does this need to be smart? It's not going to charge anything, it's just updating the price, that hypothetically might be used later based on data/logic that's irrelevant to the price calculation.
Once any complexity got involved, this is closer to how I would want to implement it, because this also gives you a clear transcript about which actions happened why. I would want to be able to inspect the metadata around each decision to make a charge.
If your required logic separates nicely into steps (like "fetch, compute, store"), then a procedural interface makes sense, because sequential and hierarchical control flow work well with procedural programming.
But some requirements, like yours, require control flow to be interwoven between multiple concerns. It's hard to do this cleanly with procedural programming because where you want to draw the module boundaries (e.g.: so as to separate logic and infrastructure concerns) doesn't line up with the sequential or hierarchical flow of the program. In that case you have to bring in some more powerful tools. Usually it means polymorphism. Depending on your language that might be using interfaces, typeclasses, callbacks, or something more exotic. But you pay for these more powerful tools! They are more complex to set up and harder to understand than simple straightforward procedural code.
In many cases judicious splitting of a "mixed-concern function" might be enough and that should probably be the first option on the list. But it's a tradeoff. For instance, you then could lose cohesion and invariance properties (a logically singular operation is now in multiple temporally coupled operations), or pay for the extra complexity of all the data types that interface between all the suboperations.
To give an example, in "classic" object-oriented Domain-Driven Design approaches, you use the Repository pattern. The Repository serves as the interface or hinge point between your business logic and database logic. Now, like I said in the last paragraph, you could instead design it so the business logic returned its desired side-effects to the co-ordinating layer and have it handle dispatching those to the database functions. But if a single business logic operation naturally intertwines multiple queries or other side-effectful operations then the Repository can sometimes be simpler.
This stuff is quite new to me as I’ve been learning F#, so take this with a pinch of salt. Some of the things you’d want are:
- a function to produce a list of customers
- a function or two to retrieve the data, which would be passed into the customer list function. This allows the customer list function to be independent of the data retrieval. This is essentially functional dependency injection
- a function to take a list of customers and return a list of effects: things that should happen
- this is where I wave my hands as I’m not sure of the plumbing. But the final part is something that takes the list of effects and does something with them
With the above you have a core that is ignorant of where its inputs come from and how its effects are achieved - it’s very much a pure domain model, with the messy interfaces with the outside world kept at the edges
Sounds like a chain of “fetch compute store” stages, where the output of one is used as input to the next, where you then decide what other data needs to be fetched. So a pipeline instead of just a single shell and a single core.
Maybe check out Scott Wlaschin's videos on YouTube. There is one talk for his book "Domain Modeling Made Functional" which, if I remember, was very clear and easy to follow.
Conceptually, can you break your processing up into a more or less "pure" functional core, surrounded by some gooey, imperative, state-dependent input loading and output effecting stages? For each processing stage, implement functions of well-defined inputs and outputs, with any global side effects clearly stated (i.e. updating a customer record, sending an email) Then factor all the imperative-ish querying (that is to say, anything dependent on external state such as is stored in a database) to the earlier phases, recognizing that some of the querying is going to be data-dependent ("if customer type X, fetch the limits for type X accounts"). The output of these phases should be a sequence of intermediate records that contain all the necessary data to drive the subsequent ones.
Whenever there is an action decision point ("we will be sending an email to this customer"), instead of actually performing that step right then and there, emit a kind of deferred-intent action data object, e.g. "OverageEmailData(customerID, email, name, usage, limits)". Finally, the later phases are also highly imperative, and actually perform the intended actions that have global visibility and mutate state in durable data stores.
You will need to consider some transactional semantics, such as, what if the customer records change during the course of running this process? Or, what if my process fails half-way through sending customer emails? It is helpful if your queries can be point-in-time based, as in "query customer usage as-of the start time for this overall process". That way you can update your process, re-run it with the same inputs as of the last time you ran it, and see what your updates changed in terms of the output.
If those initial querying phases take a long time to run because they are computationally or database query heavy, then during your development, run those once and dump the intermediate output records. Then you can reload them to use as inputs into an isolated later phase of the processing. Or you can manually filter those intermediates down to a more useful representative set (i.e. a small number of customers of each type).
Also, its really helpful to track the stateful processing of the action steps (i.e. for an email, track state as Queued, Sending, Success, Fail). If you have a bug that only bites during a later step in the processing, you can fix it and resume from where you left off (or only re-run for the affected failed actions). Also, by tracking the globally affecting actions you can actually take the results of previous runs into account during subsequent ones ("if we sent an overage email to this customer within the past 7 days, skip sending another one for now"). You now have a log of the stateful effects of your processing, which you can also query ("how many overage emails have been sent, and what numbers did they include?")
Good luck! Don't go overboard with functional purity, but just remember, state mutations now can usually be turned into data that can be applied later.
I really like modern Swift. It makes a lot of what this author is complaining about, impossible.
The worst file I ever inherited to work on was the ObjC class for
Instagram’s User Profile page. It looked like it’d been written by a JavaScript fan. There were no types in the whole file, everything was an ‘id’ (aka void*) and there were ‘isKindOfClass’ and null checks all over the place. I wanted to quit when I saw it. (I soon did).
Modern swift makes this technically possible but so cluttered that it's effectively impossible, especially compared with typescript.
Swift distinguishes between inclusive and exclusive / exhaustive unions with enum vs protocols and provides no easy or simple way to bridge between the two. If you want to define something that typescript provides as easy as the vertical bar, you have to write an enum definition, a protocol bridge with a type identifier, a necessarily unchecked cast back (even if you can logically prove that the type enum has a 1:1 mapping), and loads of unnecessary forwarding code. You can try and elide some of it with (iirc, its been a couple years) @dynamicMemberLookup, but the compiler often chokes on this, it kills autocomplete, and it explodes compile times because Swift's type checker degrades to exponential far more frequently than other languages, especially when used in practice, such as in SwiftUI.
When I tried to do learn some to put together a little app, every search result for my questions was for a quick blog seemingly aimed at iOS devs who didn’t want to learn and just wanted to copy-paste the answer - usually in the form of an extension method
Typing is great, presuming that the developer did a thorough job of defining their type system. If they get the model wrong, or it is incomplete then you aren't really gaining much out of a strictly typed language. Every change is a fight. You are likely to hack the model to make the code compile. There is a reason that Rust is most successful at low level code. This is where the models are concrete and simple to create. As you move up the stack, complexity increases and the ability to create a coherent model goes beyond human abilities. That's why coding isn't math or religion. Different languages and approaches for different domains.
Well, you could argue whether or not coding is math or not, but coding is _certainly_ religion. Complete with wars among sects, inquisition, excommunication, priesthood and crusades.
This was a great breakdown and very well written. I think you made one of the better arguments for rust Ive read on the internet but you also made sure to acknowledge that large code bases are just a different beast all together. Personally I will say that AI has made making code proofs or "formal verification" more accessible. Actually writing a proof for your code or code verification is very hard to do for most programmers which is why it is not done by most programmers, but AI is making it accessible and with formal verification of code you prevent so many problems. It will be interesting to see where programming and compliers go when "formal verification" becomes normal.
> Rust makes it possible to safely manage memory without using a garbage collector, probably one of the biggest pain points of using low-level languages like C and C++. It boils down to the fact that many of the common memory issues that we can experience, things like dangling pointers, double freeing memory, and data races, all stem from the same thing: uncontrolled sharing of mutable state.
Minor nit: this should be mutable state and lifetimes. I worked with Rust for two years before recently working with Zig, and I have to say opt-in explicit lifetimes without XOR mutability requirements would be a nice combo.
There is a trade off here (of course) as in anything.
You can write the type heavy language with the nullable-type and the carefully thought through logic. Or you can use the dynamic language with the likelihood that it will crash. The issue is not “you are a bad coder, and should be guilty” but that there is a cost to a crash and a cost to moving wholesale to Haskell or perhaps more realistically to typed python, and those costs are quantifiable- and perhaps sometimes the throwaway code that has made it to production is on the right side of the cost curve.
Great read. C# has the concept of nullable reference types[1] which requires you to be explicit if a variable can be null and the compiler is aware of this. I would love to see a similar feature in languages like TypeScript and Go.
Oh wow, I didn't know this was a thing. I knew about nullable value types, and have started to use them a bit, but this looks like it could be very useful
The "lies" described here are essentially the definition of weakly typed programming, even in statically typed languages.
Functional languages like ML/Haskell/Lisp dialects has no lies built in for decades, and it's good to see the mainstream programming (Java, TS, C++, etc.) to catch up as well.
There are also cute benefits of having strong schemas for your API as well -- for example, that endpoint becomes an MCP for LLMs automatically.
The whole article gives a generated vibe, but I did want to point out this particular snippet
> The compiler is always angry. It's always yelling at us for no good reason. It's only happy when we surrender to it and do what it tells us to do. Why do we agree to such an abusive relationship?
Programming languages are a formal notation for the execution steps of a computing machine. A formal system is always built around rules and not following the rules is an error, in this case a malformed statement/expression. It's like writing: afjdla lkwcn oqbcn. Yes, they are characters, but they're not english words.
Apart from the syntax, which is a formal system on its own, the compiler may have additional rules (like a type system). And you can add even more rules with a static analysis tool (linter). Even though there may be false positives, failing one of those usually means that what you wrote is meaningless in some way. It may run, but it can have unexpected behavior.
Natural language have a lot of tolerance for ambiguous statements (which people may not be aware of if they share the same metaphor set). But a computer has none. You either follow the rules or you do not and have an error.
> Rust makes it possible to safely manage memory without using a garbage collector, probably one of the biggest pain points of using low-level languages like C and C++.
In C++, memory management has not been a pain point for many years, and you basically don't need to do it at all if you don't want to. The standard library takes care of it well enough - with owning containers and smart pointers.
> And Rust is famous for its optimizations in the style of "zero cost abstractions".
No, it isn't that famous for those. The safety and no-UB constraints prevent a lot of that.
By the way, C++, which is more famous for them, still struggles in some cases. For example, ABI restrictions prevent passing unique_ptr's via single registers, see: https://stackoverflow.com/q/58339165/1593077
> writing the compiler in the very same language it's supposed to compile is considered an important milestone.
But it doesn't always make sense -- e.g. a language for large-scale linear algebras, or a language for web GUIs might be not the best to compile itself.
Hello baader meinhoff my old friend - while I’m familiar with the convention, I was just introduced formally to the phrase “functional core, imperative shell” the other day, and now here it is again.
“Learn to stop worrying and love the bomb” was definitely a process I had to go through moving from JavaScript to Typescript, but I do mostly agree with the author here wrt convention. Some things, like using type names as additional levels of context - UserUUID and ItemUUID each alias UUID, which in turn is just an alias for String - have occurred to me naturally, even.
I'm not a fan of the recent trend in software development, started by the OOP craze but in the modern day largely driven by Rust advocates, of noun-based programming, where type hierarchies are the primary interface between the programmer and the code, rather than the data or the instructions. It's just so... dogmatic. Inexpressive. It ultimately feels to me like a barrier between intention and reality, another abstraction. The type system is the program, rather than the program being the program. But speaking of dogma, the author's insistence that not abiding by this noun-based programming model is a form of 'lying' is quite the accusatory stretch of language... but I digress at the notion that I might just be a hit dog hollering.
The kind of noun-based programming you don’t like is great for large teams and large code bases where there is an inherent communication barrier based on the number of people involved. (N choose 2 = N*(N-1)/2 so it grows quadratically.) Type hierarchies need to be the primary interface between the programmers and the code because it communicates invariants on the data more precisely than words. It is dogmatic, because that’s the only way it could work for large teams.
When you are the only programmer, this matters way less. Just do whatever based on your personal taste.
"Bad programmers worry about the code. Good programmers worry about data structures and their relationships."
> It's just so... dogmatic. Inexpressive. It ultimately feels to me like a barrier between intention and reality, another abstraction.
On the contrary, it's a much more effective way to express intention when you have a language that can implement it. Programmers in C-family languages waste most of their time working around the absence of sum types, they just don't realise that that's what they're doing. Yes it is an abstraction, all programming is abstraction.
Agreed. It's often accompanied by the dogma "make invalid states unrepresentable" which sounds good until you start trying to encode into the type system foo.bar being 1-42 unless foo.baz is above 10, where now foo.bar can be -42-1 instead, but if foo.omfg is prefixed with "wtf" then foo.baz needs to be above 20 for its modifiers to kick in.
Yeah good luck doing that in the type system in a way that is maintainable, open to modification, an scales with complexity.
For another perspective on "lying to the compiler," I enjoyed the section on Loopholes in Niklaus Wirth's "Good Ideas, Through the Looking Glass"[1]. An excerpt:
Experience showed that normal users will not shy away from using the loophole, but rather enthusiastically grab on to it as a wonderful feature that they use wherever possible. This is particularly so if manuals caution against its use.
[...]
The presence of a loophole facility usually points to a deficiency in the language proper, revealing that certain things could not be expressed.
Wirth's use of loophole most closely aligns with the unchecked casts that the article uses. I don't think exceptions amount to lying to the compiler. They amount more to assuming for sake of contradiction, which is not quite lying (e.g., AFSOC is a valid proof technique, but proofs can be wrong). Null as a form of lying is not the fault of the programmer, that's more the fault of the language, so again doesn't feel like lying.
So his view is from a programmer / developer. That's fine.
I had an issue on my local computer system yesterday; manjaro would not
boot with a new kernel I compiled from source. It would freeze, at the
boot menu, which I never had before. Anyway. I installed linuxmint today
and went on to actually compile a multitude of things from source. I
finally finished compiling mesa, xorg-server, ffmpeg, mpv, gtk3 + gtk4 -
and the prior dependencies (llvm etc...). So I am almost finished finally.
I had to invest quite a lot of time hunting for dependencies. Most recent
one was glad2 for libplacebo. Turns out "pip install glad2" suffices here.
But getting that wasn't so trivial. The project project at pip website was
virtually useless; respectively I installed "pip install glad" which was
too old. Also took me perhaps one full minute or more to realise it.
I am tapping into LFS and BLFS webpage (Linux from scratch), which helps a
lot but it is not perfect. So much information is not described and people
have to know what they are doing. You can say this is fair, as this is more
for advanced users. Ok. The problem is ... so many things that compilers do,
is not well-described; or at the least you can not easily find high quality
documentation. Google search is almost virtually useless now; AI just hallucinates
and flat out lies to you often. Or tells you things that are trivia and you already
know it. We kind of lose quality here. It's as if everything got dumbed down.
Meanwhile more and more software is required to build other software. Take
mesa. Now I need not only LLVM but also the whole spirv-stack. And shaderc. And
lots more. And also rust - why is rust suddenly such a huge dependency? Why is
there such a proliferation of programming languages? Ok, perhaps C and C++ are
no longer the best language, but WHY is the whole stack constantly expanding?
We worship complexity. The compilers also become bigger and bigger.
About two days ago I cloned gcc from https://github.com/gcc-mirror/gcc.
The .tar.xz sits at 3.8 GB. Granted, regular tarball releases are much
smaller, e. g. 15.1.0 tar.xz at 97MB (at https://ftp.gnu.org/gnu/gcc/?C=M;O=D).
But still. These things become bigger and bigger. gcc-7.2.0.tar.xz from 9 years ago had a size of 59M. Almost twice the size now in less than 10 years. And
that's really just like all the other software too. We ended up worshipping
more and more bloat. Nobody cares about size. Now one can say "this is just
static code", but this is expanded and it just keeps on getting bigger. Look
at LLVM. How to compile this beast: https://www.linuxfromscratch.org/blfs/view/svn/general/llvm.... - and this will
only get bigger and bigger and bigger.
So, back to the "are compilers your best friend"? I am not sure. We seem to
have the problem of more and more complexity getting in at the same time. And
everyone seems to think this is no issue. I believe there are issues. Take
slackware; basically it was a one person maintains it. This may not be the
primary reason, but slackware slowed down a lot in the last some years. Perhaps
maintaining all of that requires a team of people. Older engineers cared about
size due to constraints. Now that the constraints are less important, bloat
became the default.
[+] [-] LegionMammal978|2 months ago|reply
I'm not sure what the author expects the program to do when there's an internal logic error that has no known cause and no definite recovery path. Further down the article, the author suggests bubbling up the error with a result type, but you can only bubble it up so far before you have to get rid of it one way or another. Unless you bubble everything all the way to the top, but then you've just reinvented unchecked exceptions.
At some level, the simplest thing to do is to give up and crash if things are no longer sane. After all, there's no guarantee that 'unreachable' recovery paths won't introduce further bugs or vulnerabilities. Logging can typically be done just fine within a top-level exception handler or panic handler in many languages.
[+] [-] thatoneengineer|2 months ago|reply
Language support for that varies. Rust is great, but not perfect. Typescript is surprisingly good in many cases. Enums and algebraic type systems are your friend. It'll never be 100% but it sure helps fill a lot of holes in the swiss cheese.
Because there's no such thing as a purely internal error in a well-constructed program. Every "logic error" has to bottom out in data from outside the code eventually-- otherwise it could be refactored to be static. Client input is wrong? Error the request! Config doesn't parse? Better specify defaults! Network call fails? Yeah, you should have a plan for that.
[+] [-] skydhash|2 months ago|reply
Yes, sometimes, the compiler or the hardware have bugs that violate the premises you're operating on, but that's rare. But most non pure algorithms (side effects and external systems) have documented failure cases.
[+] [-] CupricTea|2 months ago|reply
Not necessarily. Result types are explicit and require the function signature to be changed for them.
I would much prefer to see a call to foo()?; where it's explicit that it may bubble up from here, instead of a call to foo(); that may or may not throw an exception my way with no way of knowing.
Rust is absolutely not perfect with this though since any downstream function may panic!() without any indication from its function signature that it could do so.
[+] [-] svantana|2 months ago|reply
The problem with this attitude (that many of my co-workers espouse) is that it can have serious consequences for both the user and your business.
- The user may have unsaved data - Your software may gain a reputation of being crash-prone
If a valid alternative is to halt normal operations and present an alert box to the user saying "internal error 573 occurred. please restart the app", then that is much preferred IMO.
[+] [-] the__alchemist|2 months ago|reply
[+] [-] GabrielBRAA|2 months ago|reply
[+] [-] supermdguy|2 months ago|reply
Does anyone have any good resources on how to get better at doing "functional core imperative shell" style design? I've heard a lot about it, contrived examples make it seem like something I'd want, but I often find it's much more difficult in real-world cases.
Random example from my codebase: I have a function that periodically sends out reminders for usage-based billing customers. It pulls customer metadata, checks the customer type, and then based on that it computes their latest usage charges, and then based on that it may trigger automatic balance top-ups or subscription overage emails (again, depending on the customer type). The code feels very messy and procedural, with business logic mixed with side effects, but I'm not sure where a natural separation point would be -- there's no way to "fetch all the data" up front.
[+] [-] bambax|2 months ago|reply
Stacked views are sometimes considered an anti-pattern, but I really like them because they're purely functional, have no side-effects whatsoever and cannot break (they either work or they don't, but they can't start breaking in the future). And they're also stateless: they present a holistic view of the data that avoids iterations and changes how you think about it. (Data is never really 'transformed', it's simply 'viewed' from a different perspective.)
Not saying that's the only way, or the best way, or even a good way! But it works for me.
I think it would apply well to the example: you could have a view, or a series of views, that compute balance top-ups based on a series of criteria; then the program would read that view and send email without doing any new calculation.
[+] [-] lmm|2 months ago|reply
That said:
> It pulls customer metadata, checks the customer type, and then based on that it computes their latest usage charges, and then based on that it may trigger automatic balance top-ups or subscription overage emails (again, depending on the customer type).
So compute those things, and store them somewhere (if only an in-memory queue to start with)? Like, I can already see a separation between an ETL stage that computes usage charges, which are probably worth recording in a datastore, and then another ETL stage that computes which top-ups and emails should be sent based on that, which again is probably worth recording for tracing purposes, and then two more stages to actually send emails and execute payment pulls, which it's actually quite nice to have separated from the figuring out which emails to send part (if only so you can retry/debug the latter without sending out actual emails)
[+] [-] sltr|2 months ago|reply
I can recommend Grokking Simplicity by Eric Normand. https://www.manning.com/books/grokking-simplicity
[+] [-] AdieuToLogic|2 months ago|reply
Hexagonal architecture[0] is a good place to start. The domain model core can be defined with functional concepts while also defining abstract contracts ( abstractly "ports", concretely interface/trait types) implemented in "adapters" (usually technology specific, such as HTTP and/or SMTP in your example).
0 - https://en.wikipedia.org/wiki/Hexagonal_architecture_(softwa...
[+] [-] grayhatter|2 months ago|reply
this is incorrect
I assume there's more nuance and complexity as for why it feels like there's no way. Probably involving larger design decisions that feel difficult to unwind. But data collection, decisions, and actions can all be separated without much difficulty with some intent to do so.
I would suggest caution, before implementating this directly: but imagine a subroutine that all it did was lock some database table, read the current list of pending top up charges required, issue the charge, update the row, and unlock the table. An entirely different subroutine wouldn't need to concern itself with anything other than data collection, and calculating deltas, it has no idea if a customer will be charged, all it does is calculate a reasonable amount. Something smart wouldn't run for deactivated/expiring accounts, but why does this need to be smart? It's not going to charge anything, it's just updating the price, that hypothetically might be used later based on data/logic that's irrelevant to the price calculation.
Once any complexity got involved, this is closer to how I would want to implement it, because this also gives you a clear transcript about which actions happened why. I would want to be able to inspect the metadata around each decision to make a charge.
[+] [-] movpasd|2 months ago|reply
But some requirements, like yours, require control flow to be interwoven between multiple concerns. It's hard to do this cleanly with procedural programming because where you want to draw the module boundaries (e.g.: so as to separate logic and infrastructure concerns) doesn't line up with the sequential or hierarchical flow of the program. In that case you have to bring in some more powerful tools. Usually it means polymorphism. Depending on your language that might be using interfaces, typeclasses, callbacks, or something more exotic. But you pay for these more powerful tools! They are more complex to set up and harder to understand than simple straightforward procedural code.
In many cases judicious splitting of a "mixed-concern function" might be enough and that should probably be the first option on the list. But it's a tradeoff. For instance, you then could lose cohesion and invariance properties (a logically singular operation is now in multiple temporally coupled operations), or pay for the extra complexity of all the data types that interface between all the suboperations.
To give an example, in "classic" object-oriented Domain-Driven Design approaches, you use the Repository pattern. The Repository serves as the interface or hinge point between your business logic and database logic. Now, like I said in the last paragraph, you could instead design it so the business logic returned its desired side-effects to the co-ordinating layer and have it handle dispatching those to the database functions. But if a single business logic operation naturally intertwines multiple queries or other side-effectful operations then the Repository can sometimes be simpler.
[+] [-] brickers|2 months ago|reply
- a function or two to retrieve the data, which would be passed into the customer list function. This allows the customer list function to be independent of the data retrieval. This is essentially functional dependency injection
- a function to take a list of customers and return a list of effects: things that should happen
- this is where I wave my hands as I’m not sure of the plumbing. But the final part is something that takes the list of effects and does something with them
With the above you have a core that is ignorant of where its inputs come from and how its effects are achieved - it’s very much a pure domain model, with the messy interfaces with the outside world kept at the edges
[+] [-] vismit2000|2 months ago|reply
[+] [-] jimbokun|2 months ago|reply
[+] [-] raegis|2 months ago|reply
[+] [-] pdmccormick|2 months ago|reply
Whenever there is an action decision point ("we will be sending an email to this customer"), instead of actually performing that step right then and there, emit a kind of deferred-intent action data object, e.g. "OverageEmailData(customerID, email, name, usage, limits)". Finally, the later phases are also highly imperative, and actually perform the intended actions that have global visibility and mutate state in durable data stores.
You will need to consider some transactional semantics, such as, what if the customer records change during the course of running this process? Or, what if my process fails half-way through sending customer emails? It is helpful if your queries can be point-in-time based, as in "query customer usage as-of the start time for this overall process". That way you can update your process, re-run it with the same inputs as of the last time you ran it, and see what your updates changed in terms of the output.
If those initial querying phases take a long time to run because they are computationally or database query heavy, then during your development, run those once and dump the intermediate output records. Then you can reload them to use as inputs into an isolated later phase of the processing. Or you can manually filter those intermediates down to a more useful representative set (i.e. a small number of customers of each type).
Also, its really helpful to track the stateful processing of the action steps (i.e. for an email, track state as Queued, Sending, Success, Fail). If you have a bug that only bites during a later step in the processing, you can fix it and resume from where you left off (or only re-run for the affected failed actions). Also, by tracking the globally affecting actions you can actually take the results of previous runs into account during subsequent ones ("if we sent an overage email to this customer within the past 7 days, skip sending another one for now"). You now have a log of the stateful effects of your processing, which you can also query ("how many overage emails have been sent, and what numbers did they include?")
Good luck! Don't go overboard with functional purity, but just remember, state mutations now can usually be turned into data that can be applied later.
[+] [-] kridsdale1|2 months ago|reply
The worst file I ever inherited to work on was the ObjC class for Instagram’s User Profile page. It looked like it’d been written by a JavaScript fan. There were no types in the whole file, everything was an ‘id’ (aka void*) and there were ‘isKindOfClass’ and null checks all over the place. I wanted to quit when I saw it. (I soon did).
[+] [-] JackYoustra|2 months ago|reply
Swift distinguishes between inclusive and exclusive / exhaustive unions with enum vs protocols and provides no easy or simple way to bridge between the two. If you want to define something that typescript provides as easy as the vertical bar, you have to write an enum definition, a protocol bridge with a type identifier, a necessarily unchecked cast back (even if you can logically prove that the type enum has a 1:1 mapping), and loads of unnecessary forwarding code. You can try and elide some of it with (iirc, its been a couple years) @dynamicMemberLookup, but the compiler often chokes on this, it kills autocomplete, and it explodes compile times because Swift's type checker degrades to exponential far more frequently than other languages, especially when used in practice, such as in SwiftUI.
[+] [-] glenjamin|2 months ago|reply
When I tried to do learn some to put together a little app, every search result for my questions was for a quick blog seemingly aimed at iOS devs who didn’t want to learn and just wanted to copy-paste the answer - usually in the form of an extension method
[+] [-] WalterBright|2 months ago|reply
My code is peppered with `assert(0)` for cases that should never happen. When they trip, then I figure out why it happened and fix it.
This is basic programming technique.
[+] [-] doug_durham|2 months ago|reply
[+] [-] einpoklum|2 months ago|reply
[+] [-] ZebusJesus|2 months ago|reply
[+] [-] smj-edison|2 months ago|reply
Minor nit: this should be mutable state and lifetimes. I worked with Rust for two years before recently working with Zig, and I have to say opt-in explicit lifetimes without XOR mutability requirements would be a nice combo.
[+] [-] lifeisstillgood|2 months ago|reply
You can write the type heavy language with the nullable-type and the carefully thought through logic. Or you can use the dynamic language with the likelihood that it will crash. The issue is not “you are a bad coder, and should be guilty” but that there is a cost to a crash and a cost to moving wholesale to Haskell or perhaps more realistically to typed python, and those costs are quantifiable- and perhaps sometimes the throwaway code that has made it to production is on the right side of the cost curve.
[+] [-] joeriddles|2 months ago|reply
[1]: https://learn.microsoft.com/en-us/dotnet/csharp/nullable-ref...
[+] [-] armchairhacker|2 months ago|reply
[+] [-] voidUpdate|2 months ago|reply
[+] [-] barishnamazov|2 months ago|reply
Functional languages like ML/Haskell/Lisp dialects has no lies built in for decades, and it's good to see the mainstream programming (Java, TS, C++, etc.) to catch up as well.
There are also cute benefits of having strong schemas for your API as well -- for example, that endpoint becomes an MCP for LLMs automatically.
[+] [-] jibal|2 months ago|reply
Zig is one. For that matter standard C has no exceptions
[+] [-] teo_zero|2 months ago|reply
[+] [-] kennykartman|2 months ago|reply
[+] [-] skydhash|2 months ago|reply
> The compiler is always angry. It's always yelling at us for no good reason. It's only happy when we surrender to it and do what it tells us to do. Why do we agree to such an abusive relationship?
Programming languages are a formal notation for the execution steps of a computing machine. A formal system is always built around rules and not following the rules is an error, in this case a malformed statement/expression. It's like writing: afjdla lkwcn oqbcn. Yes, they are characters, but they're not english words.
Apart from the syntax, which is a formal system on its own, the compiler may have additional rules (like a type system). And you can add even more rules with a static analysis tool (linter). Even though there may be false positives, failing one of those usually means that what you wrote is meaningless in some way. It may run, but it can have unexpected behavior.
Natural language have a lot of tolerance for ambiguous statements (which people may not be aware of if they share the same metaphor set). But a computer has none. You either follow the rules or you do not and have an error.
[+] [-] xnorswap|2 months ago|reply
The guard rails aren't abusing you, they're helping you. They aren't "angry", they're just constraints.
[+] [-] einpoklum|2 months ago|reply
In C++, memory management has not been a pain point for many years, and you basically don't need to do it at all if you don't want to. The standard library takes care of it well enough - with owning containers and smart pointers.
> And Rust is famous for its optimizations in the style of "zero cost abstractions".
No, it isn't that famous for those. The safety and no-UB constraints prevent a lot of that.
By the way, C++, which is more famous for them, still struggles in some cases. For example, ABI restrictions prevent passing unique_ptr's via single registers, see: https://stackoverflow.com/q/58339165/1593077
[+] [-] deepsun|2 months ago|reply
But it doesn't always make sense -- e.g. a language for large-scale linear algebras, or a language for web GUIs might be not the best to compile itself.
[+] [-] mock-possum|2 months ago|reply
“Learn to stop worrying and love the bomb” was definitely a process I had to go through moving from JavaScript to Typescript, but I do mostly agree with the author here wrt convention. Some things, like using type names as additional levels of context - UserUUID and ItemUUID each alias UUID, which in turn is just an alias for String - have occurred to me naturally, even.
[+] [-] onionisafruit|2 months ago|reply
[+] [-] moth-fuzz|2 months ago|reply
[+] [-] kccqzy|2 months ago|reply
When you are the only programmer, this matters way less. Just do whatever based on your personal taste.
[+] [-] lmm|2 months ago|reply
> It's just so... dogmatic. Inexpressive. It ultimately feels to me like a barrier between intention and reality, another abstraction.
On the contrary, it's a much more effective way to express intention when you have a language that can implement it. Programmers in C-family languages waste most of their time working around the absence of sum types, they just don't realise that that's what they're doing. Yes it is an abstraction, all programming is abstraction.
[+] [-] llmslave2|2 months ago|reply
Yeah good luck doing that in the type system in a way that is maintainable, open to modification, an scales with complexity.
[+] [-] jez|2 months ago|reply
Experience showed that normal users will not shy away from using the loophole, but rather enthusiastically grab on to it as a wonderful feature that they use wherever possible. This is particularly so if manuals caution against its use.
[...]
The presence of a loophole facility usually points to a deficiency in the language proper, revealing that certain things could not be expressed.
Wirth's use of loophole most closely aligns with the unchecked casts that the article uses. I don't think exceptions amount to lying to the compiler. They amount more to assuming for sake of contradiction, which is not quite lying (e.g., AFSOC is a valid proof technique, but proofs can be wrong). Null as a form of lying is not the fault of the programmer, that's more the fault of the language, so again doesn't feel like lying.
[1] https://people.inf.ethz.ch/wirth/Articles/GoodIdeas.pdf
[+] [-] zk108|2 months ago|reply
[+] [-] shevy-java|2 months ago|reply
I had an issue on my local computer system yesterday; manjaro would not boot with a new kernel I compiled from source. It would freeze, at the boot menu, which I never had before. Anyway. I installed linuxmint today and went on to actually compile a multitude of things from source. I finally finished compiling mesa, xorg-server, ffmpeg, mpv, gtk3 + gtk4 - and the prior dependencies (llvm etc...). So I am almost finished finally.
I had to invest quite a lot of time hunting for dependencies. Most recent one was glad2 for libplacebo. Turns out "pip install glad2" suffices here. But getting that wasn't so trivial. The project project at pip website was virtually useless; respectively I installed "pip install glad" which was too old. Also took me perhaps one full minute or more to realise it.
I am tapping into LFS and BLFS webpage (Linux from scratch), which helps a lot but it is not perfect. So much information is not described and people have to know what they are doing. You can say this is fair, as this is more for advanced users. Ok. The problem is ... so many things that compilers do, is not well-described; or at the least you can not easily find high quality documentation. Google search is almost virtually useless now; AI just hallucinates and flat out lies to you often. Or tells you things that are trivia and you already know it. We kind of lose quality here. It's as if everything got dumbed down.
Meanwhile more and more software is required to build other software. Take mesa. Now I need not only LLVM but also the whole spirv-stack. And shaderc. And lots more. And also rust - why is rust suddenly such a huge dependency? Why is there such a proliferation of programming languages? Ok, perhaps C and C++ are no longer the best language, but WHY is the whole stack constantly expanding?
We worship complexity. The compilers also become bigger and bigger.
About two days ago I cloned gcc from https://github.com/gcc-mirror/gcc. The .tar.xz sits at 3.8 GB. Granted, regular tarball releases are much smaller, e. g. 15.1.0 tar.xz at 97MB (at https://ftp.gnu.org/gnu/gcc/?C=M;O=D). But still. These things become bigger and bigger. gcc-7.2.0.tar.xz from 9 years ago had a size of 59M. Almost twice the size now in less than 10 years. And that's really just like all the other software too. We ended up worshipping more and more bloat. Nobody cares about size. Now one can say "this is just static code", but this is expanded and it just keeps on getting bigger. Look at LLVM. How to compile this beast: https://www.linuxfromscratch.org/blfs/view/svn/general/llvm.... - and this will only get bigger and bigger and bigger.
So, back to the "are compilers your best friend"? I am not sure. We seem to have the problem of more and more complexity getting in at the same time. And everyone seems to think this is no issue. I believe there are issues. Take slackware; basically it was a one person maintains it. This may not be the primary reason, but slackware slowed down a lot in the last some years. Perhaps maintaining all of that requires a team of people. Older engineers cared about size due to constraints. Now that the constraints are less important, bloat became the default.
[+] [-] tosapple|2 months ago|reply