A typical way to organize Haskell programs is to build an application-specific monad that manages the effects that are required for your application domain. This can be done by layering the needed functionality in what we call a "monad transformer stack". If IO is pervasive in the application, then IO may be concretely specified as the base of the stack (that is the only place it can fit, as it is the only monad that can't be deconstructed by user-level code), but the base monad can often be left abstract, meaning that you can instantiate it with a non-IO monad for testing.
To be more specific, monad transformer stacks are often built with a set of standard transformers known as Reader, Writer, and State. Each provides a different "effect" that is implicitly threaded through code written in that monad. For your logging purposes, the Writer monad (in its transformer form, WriterT) is often used; it's essentially a Monoid that you provide that builds up some output based on calls to its tell
method. If you implement a log function based on tell
, then any function in your application monad can have a log message mappend
ed to the log output. The Reader functionality is often used for providing a set of fixed environmental parameters via the ask
method; State is rather obvious; it threads some transformable data type through your application and allows your application to transform it via get
and put
methods. Other monad transformers are provided by libraries as well; EitherT can provide an exception-like functionality for your application, ListT can provide non-determinism via the List monad, etc.
With a transformer stack like that, you generally want to confine it to an "application logic" layer of your program, so that you don't require any functions to be in your application monad that don't need the effects. Regular modular programming practice applies; keep your abstractions loosely coupled and highly cohesive, and provide functionality in them via normal pure functions so that the application logic can operate on them at a high-level of abstraction. For example, if you have a notion of a Person in your business logic, you would implement data and functions about Person in a Person module that doesn't know anything about your application monad. Just make its functions that might fail return an Either value with enough information to make a log entry; when your application logic manipulates Person values with these functions, it can pattern-match on the result or work in an on-the-fly EitherT monad if you need to combine several possibly-failing functions. Then you can use your Writer-based logging functionality there in your application layer.
The top level of every Haskell program is always in the IO monad. If you have made your stack abstract with respect to its base Monad, or just made it pure altogether, then you'll need a small top-level to provide the IO functionality your application needs. You might run your application monad a step at a time if it's interactive, in which case you can unpack the Writer to get log entries and perhaps information about other IO actions requested by the application logic. Results can be fed back into the application environment via a Reader or State layer. If your application is just a batch processor, you might just provide the necessary inputs via the results of IO actions, run the application monad, then dump the log from the Writer via IO.
The point of all this is to illustrate that monads and monad transformers allow you a very clean way to separate various parts of real-world applications so that you can take full advantage of pure, simple functions for data transformation in most places and leave your code very clean and testable. You can sequester IO operations in a small "runtime support" layer, application-specific logic in a similar but larger layer built around a (possibly pure) monad transformer stack, and business data manipulation in a set of modules that don't rely on any features of the application logic that uses them. This lets you easily re-use those modules for applications in a similar domain later.
Getting the hang of program structuring in Haskell requires practice (as it does in any language) but I think you'll find that after reading through a few Haskell applications and writing a few of your own, the features it provides allow you to build very nicely-structured applications that are incredibly easy to extend and refactor. Good luck in your efforts!