I also started to study Haskell recently. In my view, it's great from mathematical perspective, but it has its problems.
Monads sort of force you to make plumbing visible, and it's not so neat as a result. For example, consider a big program that has two modules. The module A calls module B to do something. Now later, you want to add logging to the application. In normal languages, you can just call logging functions (which do IO), from within module B; module A doesn't have to know a thing. In Haskell though, you have to wire the IO monad (or some other monad that does the logging and encompasses it) all the way from A to B. Or say, you want now to access database from module B. Again, you have to wire your DB access up from A, because that's where the entry point is.
In normal languages, plumbing like log access, configuration, DB access can be accessed from any place via global variables or singletons, without imposing a dependency on the main module (or other modules). In Haskell, it's like a military installation - every interaction with outside world has to go through main gate. I am not really sure if there is any benefit to it, but there is certainly a downside that the plumbing is becoming visible in Haskell.
If there would be way to declare something as "plumbing" and have it always available (but still explicitly declared as part of function signature), without having to pass it along everywhere, it would be great compromise, I think. It could even make the programs more type safe, because instead of passing RWS or IO monad everywhere, you could make functions dependent on just DB monad for database access, for instance.
Or you could then configure the plumbing for specific modules, something like dependency injection.
Maybe I am missing something, but I tried to find some articles about how to write large scale programs in Haskell, but no one really seems to explain this.
I don't think the "Xy monad will taint all your code" stands.
I used to think that too, but if you have monadic code M and pure code P, if you need to tie P to M (say at a third callsite C), you just lift the P into the monad at C, and that's it. P stays pure, C of course gets monadic, but that is since it _is_ monadic.
Now logging: I guess people overpanic this. There are two separate sides of logging I think:
1) Effect logging: If you want to log effects (going to send the email, etc), you are already in IO, no worries.
2) App logic logging: This is more like debug-logging to verify that you logic works and flows as expected. If you need this in pure code, throw in a Writer monad for logging the stuff (can discard it if not needed).
2a) Eventually you'll get somewhere where you have IO, so you can dump the aggregated logs if you want.
2b) Or just use unsafePerformIO to send to a logging thread (beat me with a stick).
As a bonus, for app logging you might use a proper ADT for your log statements instead of string, which is great for testing, and even greater for persisting (in json, protobuf, whatever) and later inspection.
I am aware of lifting, but the question is if you have monadic code and pure code in different modules (or you need to go through function which was previously pure), where do you put the lift? If you put it outside the module, you break the modularity. If you put it inside, well then you might as well make the functions monadic in the first place. Basically if you have functions in module API (which may be in itself pure) that may eventually end up calling unpure functions, you have to provision for that somehow, either in the module by making them monadic, or in the caller via lifting. Either way, it's not as clean as it could be.
But I thought about it some more, and to me it seems that actually parametrizing the functions to outside world is not that bad; it's a kind of dependency injection, and seems fine. What is really problematic is returning all the IOs (or other monads) from them; especially since you cannot curry return parameters just like you can entry parameters. So even if that could be replaced by some other mechanism, it would be helpful.
But I didn't know unsafePerformIO, sounds like it can be helpful in some cases.
I agree there isn't a lot written that explains this but I have inferred and used the following pattern. I build a massive monad tower that includes the different things I need like a reader with configuration data, a resourcet process monad, a logging writer etc. However I rarely use that monad in my signatures, I generally use a more restricted type class. For example I have a ConfigReader type class that does exactly and only what you would expect. I have a typeclass for writing to the database, for call distributed process, etc. Now because some of those require MonadIO I do end up with a lot of code that could theoretically do any IO. That could be restricted at the cost of creating more and richer typelcass interfaces myself but I do not think it is necessary for the most part.
Monads sort of force you to make plumbing visible, and it's not so neat as a result. For example, consider a big program that has two modules. The module A calls module B to do something. Now later, you want to add logging to the application. In normal languages, you can just call logging functions (which do IO), from within module B; module A doesn't have to know a thing. In Haskell though, you have to wire the IO monad (or some other monad that does the logging and encompasses it) all the way from A to B. Or say, you want now to access database from module B. Again, you have to wire your DB access up from A, because that's where the entry point is.
In normal languages, plumbing like log access, configuration, DB access can be accessed from any place via global variables or singletons, without imposing a dependency on the main module (or other modules). In Haskell, it's like a military installation - every interaction with outside world has to go through main gate. I am not really sure if there is any benefit to it, but there is certainly a downside that the plumbing is becoming visible in Haskell.
If there would be way to declare something as "plumbing" and have it always available (but still explicitly declared as part of function signature), without having to pass it along everywhere, it would be great compromise, I think. It could even make the programs more type safe, because instead of passing RWS or IO monad everywhere, you could make functions dependent on just DB monad for database access, for instance.
Or you could then configure the plumbing for specific modules, something like dependency injection.
Maybe I am missing something, but I tried to find some articles about how to write large scale programs in Haskell, but no one really seems to explain this.