Haskell's Pure Functions in Practical Applications
Maintaining reliability and speed in a chaotic world by isolating side effects.

Many developers first meet Haskell and immediately ask: sure, pure functions sound elegant, but do they actually help me ship better software when the database is down, the API changes without notice, and the front-end team needs a fix yesterday? That skepticism is healthy. In the real world, I have had to debug production issues that looked like they were caused by a missing if statement, but turned out to be a subtle change in shared mutable state. When deadlines are tight, it is tempting to reach for pragmatic, imperative code that works today. Yet, the industry is leaning heavily into resilience, concurrency, and testability, and that is exactly where pure functions shine.
This article is for engineers and curious developers who want to understand how pure functions work in Haskell, not in the abstract, but in the gritty details of actual applications. We will talk about how they affect architecture, testing, and team confidence, and we will look at patterns I have used and seen in practice. We will discuss when purity is a huge win, when it introduces friction, and how to balance the purity ideal with the messy reality of the systems we build.
*** here is placeholder: query = haskell code *** *** alt text for image = A developer workstation with a code editor on a large monitor displaying Haskell source code with clear function signatures, unit tests, and documentation comments in a structured, readable layout ***
Context: Where Haskell and pure functions fit today
Haskell is a statically typed, functional language that enforces a clear separation between pure computations and side effects. Its runtime is single-threaded by default, but the language encourages high concurrency via lightweight threads and message passing. The ecosystem is mature for services, compilers, parsers, and domain modeling. It is often chosen for systems where correctness and maintainability are critical: financial modeling, blockchain infrastructure, developer tooling, networking (e.g., the WireGuard formalization), and data transformation pipelines.
Pure functions, by definition, always produce the same output for the same input and have no observable side effects. In Haskell, the compiler enforces this by tracking effects in types. A function of type a -> b is guaranteed to be pure. If it performs I/O, it returns IO b. This constraint is not pedantic; it forces developers to make effects explicit. As a result, systems become easier to reason about, test, and parallelize. While other languages offer libraries to achieve similar discipline, Haskell builds it into the core of the language, which changes how you design programs from the start.
Who uses Haskell? Teams building long-lived systems where maintenance and correctness outweigh raw initial velocity. In practice, you will also see Haskell used alongside other languages in a polyglot architecture, often for services that benefit from precise domain modeling and transformation, while other parts of the stack handle UI, quick scripts, or glue code.
Compared to dynamic languages, Haskell trades some initial speed of prototyping for long-term safety. Compared to other statically typed languages like Rust or Scala, Haskell emphasizes immutability by default and a stricter separation of pure and effectful code. Rust excels at low-level control and fearless concurrency at systems level, while Haskell leans into higher-level abstractions, algebraic data types, and expressive type system features for domain modeling. Both are strong choices, but the Haskell ecosystem and language design revolve heavily around pure functions as a central architectural tool.
What makes a function pure in Haskell
At the most basic level, purity means no hidden dependencies and no surprises. A pure function in Haskell:
- Returns the same result for the same inputs (deterministic).
- Does not mutate inputs or any global state.
- Does not perform observable I/O (logging, network calls, file reads, randomness).
Haskell expresses purity and effects via its type system. The IO type wraps actions that interact with the outside world. The Either e a type models failure in a pure way. The State s a type models a computation that reads and writes a state value, but the state transitions are explicit and can be tested deterministically because the state is threaded through the computation rather than being a hidden global.
When you see a signature like Config -> Either ValidationError Result, you know that this function is pure. It does not depend on external configuration files, environment variables, or global mutable flags unless they are passed in. This makes it trivial to unit test and reuse in other contexts (e.g., a batch job or an interactive CLI) because the pure core does not care where its inputs come from.
Patterns for real-world applications
Pure business logic as a standalone module
In practice, business rules are often the most valuable part of an application to keep pure. Consider an onboarding flow that assigns a user to the correct plan and calculates the initial quota.
-- src/Domain/Onboarding.hs
module Domain.Onboarding (OnboardingResult(..), onboardUser) where
data Plan = Free | Pro | Enterprise deriving (Eq, Show)
data User = User
{ userId :: Int
, email :: String
, domain :: String
, isVerified :: Bool
} deriving (Eq, Show)
data OnboardingResult = OnboardingResult
{ assignedPlan :: Plan
, quota :: Int
, welcomeMessage :: String
} deriving (Eq, Show)
-- Pure business rules: no IO, no database, no randomness.
onboardUser :: User -> OnboardingResult
onboardUser u =
let plan = choosePlan u
quota = computeQuota plan
msg = welcomeMsg plan
in OnboardingResult plan quota msg
choosePlan :: User -> Plan
choosePlan u
| not (isVerified u) = Free
| domain u == "megacorp.com" = Enterprise
| length (email u) > 25 = Pro
| otherwise = Free
computeQuota :: Plan -> Int
computeQuota Free = 10
computeQuota Pro = 1000
computeQuota Enterprise = 100000
welcomeMsg :: Plan -> String
welcomeMsg Free = "Welcome! You are on the free plan."
welcomeMsg Pro = "Welcome Pro! You have advanced features."
welcomeMsg Enterprise = "Welcome to the enterprise. Contact support for SSO."
This module is trivial to test. It is also safe to run in multiple threads or in tests without mocking databases or network calls. Notice that onboardUser does not decide who the user is; it simply transforms a User value. The creation of that value and any side effects (writing to the database, sending emails) live in a separate layer.
Splitting pure and effectful code
A common architecture is to keep a pure core and a thin "imperative shell." The shell reads inputs, performs I/O, calls the pure core, and performs further effects based on the result. This pattern, sometimes called the "hexagonal" or "functional core, imperative shell" architecture, scales well to larger systems.
-- src/Effect/UserStore.hs
module Effect.UserStore (UserStore(..), UserEmail(..)) where
newtype UserEmail = UserEmail String deriving (Eq, Show)
class UserStore m where
findUserByEmail :: UserEmail -> m (Maybe Domain.Onboarding.User)
saveOnboardingResult :: Int -> Domain.Onboarding.OnboardingResult -> m ()
-- src/App/Onboarding/App.hs
module App.Onboarding.App (runOnboarding) where
import qualified Domain.Onboarding as D
import Effect.UserStore (UserStore(..), UserEmail(..))
-- We keep the core pure; the App orchestrates effects.
runOnboarding :: (UserStore m, Monad m) => UserEmail -> m (Maybe D.OnboardingResult)
runOnboarding email = do
mUser <- findUserByEmail email
case mUser of
Nothing -> pure Nothing
Just user -> do
let result = D.onboardUser user
saveOnboardingResult (D.userId user) result
pure (Just result)
In this pattern, runOnboarding is effectful but simple. It delegates all decision-making to the pure D.onboardUser. If business rules change, you update the domain module without touching effectful plumbing. If you need to add auditing, you add a new effect to the shell, not to the pure function.
Dependency injection via typeclasses
Haskell does not have runtime dependency injection in the sense of passing a service locator, but you can achieve similar goals with typeclasses and polymorphism. The UserStore typeclass above allows multiple implementations: one for production (with a real database), one for tests (in-memory), and perhaps one for local development (with a fake).
-- test/Spec/OnboardingSpec.hs
module Spec.OnboardingSpec (tests) where
import qualified Domain.Onboarding as D
import Effect.UserStore (UserStore(..), UserEmail(..))
import Test.Tasty
import Test.Tasty.HUnit
newtype TestUserStore m = TestUserStore { runTest :: m (Maybe D.Onboarding.User) }
instance Monad m => UserStore (TestUserStore m) where
findUserByEmail _ = TestUserStore (pure (Just (D.User 1 "alice@megacorp.com" "megacorp.com" True)))
saveOnboardingResult _ _ = TestUserStore (pure ())
tests :: TestTree
tests = testGroup "Onboarding"
[ testCase "Enterprise user is assigned Enterprise plan" $ do
let mResult = runOnboarding (UserEmail "alice@megacorp.com")
result <- runTest mResult
assertBool "Should return Just result" (result /= Nothing)
let Just r = result
D.assignedPlan r @?= D.Enterprise
]
This pattern keeps your test suite fast, deterministic, and free of network flakiness. The core logic does not change between environments. In practice, many teams prefer to define a simple record of functions (a "record of effects") rather than typeclasses for easier testing, but both approaches are valid and used in the wild.
Error handling without exceptions
Haskell’s idiomatic error handling uses Maybe for absence or Either e for informative failures. Because these types are pure, errors compose cleanly. Consider an input validation pipeline.
-- src/Domain/Validation.hs
module Domain.Validation (ValidationError(..), ValidatedUser(..), validateUser) where
import qualified Domain.Onboarding as D
data ValidationError = EmailMissing | EmailInvalid | DomainMissing | NotVerified deriving (Eq, Show)
data ValidatedUser = ValidatedUser D.User deriving (Eq, Show)
validateUser :: D.User -> Either ValidationError ValidatedUser
validateUser u = do
_ <- if null (D.email u) then Left EmailMissing else Right ()
_ <- if '@' `notElem` D.email u then Left EmailInvalid else Right ()
_ <- if null (D.domain u) then Left DomainMissing else Right ()
_ <- if not (D.isVerified u) then Left NotVerified else Right ()
pure (ValidatedUser u)
Because this is pure, you can run it on inputs from HTTP requests, CLI flags, or test fixtures without changes. You can also chain validations using Either's monadic interface, which short-circuits on the first error and aggregates errors in a straightforward way (or via Validation from accidental or either combinators if you want to collect multiple errors).
Dealing with time, randomness, and concurrency
Time and randomness are side effects. In Haskell, you model them explicitly. For example, a pure function cannot generate a random number, but it can describe what to do with randomness.
-- src/Domain/Lottery.hs
module Domain.Lottery (drawWinner) where
import System.Random (StdGen, randomR)
-- Pure: takes a random generator and returns next state and result.
drawWinner :: [a] -> StdGen -> (Maybe a, StdGen)
drawWinner [] g = (Nothing, g)
drawWinner xs g =
let (idx, g') = randomR (0, length xs - 1) g
in (Just (xs !! idx), g')
This pure function is deterministic if you know the starting generator. In IO, you wrap it to get the real-world randomness. The same idea applies to time: you pass the current time into pure functions as an argument rather than calling getCurrentTime inside them. This makes testing trivial because you can pass fixed timestamps and generators.
Concurrency is where purity really pays off. When data is immutable, you avoid locks and data races. Haskell’s lightweight threads (forkIO) and queues (STM or MVar) allow building highly concurrent systems. Because pure functions do not share mutable state, you can run them in parallel with near-linear speedups.
-- src/Domain/Analytics.hs
module Domain.Analytics (aggregateEvents) where
import Data.List (foldl')
import Data.Map (Map)
import qualified Data.Map as Map
type Event = (String, Int)
aggregateEvents :: [Event] -> Map String Int
aggregateEvents = foldl' (\m (k, v) -> Map.insertWith (+) k v m) Map.empty
This function is pure and parallelizable. If you need to process millions of events, you can use parallel strategies or libraries like parallel or repa to chunk the work, because no internal mutation occurs.
Effects in the large
When building larger services, teams often adopt an effect system like mtl, unliftio, or polysemy to manage a growing set of effects consistently. The goal is not to avoid IO at all costs, but to make the shape of effects explicit and modular. A common pattern:
- Define core domain functions as pure.
- Define a set of capabilities (persistence, logging, HTTP) as typeclasses or records.
- Implement capabilities once for production and once for tests.
- Compose capabilities at the edge.
This separation makes onboarding easier, because a new developer can reason about the pure business rules without understanding the entire infrastructure.
Strengths and tradeoffs
Strengths
- Predictability: Pure functions cannot depend on hidden inputs or mutate shared state, eliminating a large class of bugs.
- Testability: No need to mock time, filesystem, or network for pure logic. Tests run in milliseconds and are deterministic.
- Refactor safety: If the type signature stays the same, behavior stays the same, assuming you keep purity. Adding tests is trivial and cheap.
- Concurrency: Immutability reduces contention, making parallelization easier to reason about.
- Composability: Pure functions compose naturally into larger transformations.
Weaknesses and tradeoffs
- Learning curve: The mindset shift from mutable, imperative programming to pure, immutable pipelines can be steep.
- Boilerplate: Handling effects explicitly can feel verbose. Many teams adopt patterns and abstractions to reduce boilerplate, but the initial friction exists.
- Performance pitfalls: Naive immutability can lead to excessive copying. In practice, Haskell relies on lazy evaluation and efficient data structures (e.g.,
Vector,Text). You still need performance awareness and profiling. - Ecosystem fit: If your project is primarily about rapid UI iteration, database-centric CRUD, or integration with frameworks that assume side effects everywhere, the Haskell approach may feel heavy.
Haskell is not a silver bullet. It is an excellent choice for systems where domain complexity and correctness are critical. It is less ideal for small, short-lived scripts or teams that need to ship prototypes at breakneck speed using an ecosystem of ready-made, effect-heavy frameworks.
Personal experience: learning and applying purity
When I first leaned into writing Haskell at work, the hardest habit to break was the urge to sprinkle putStrLn in the middle of a function for “quick debugging.” In the short term, it was convenient. In the long term, those debug prints polluted our logic and made the tests noisy. Once I moved logging to the edges and kept the core pure, the codebase became easier to reason about, and we stopped getting weirdly intermittent test failures.
The other surprise was how often a “small change” to business rules would have rippling effects across multiple files in imperative codebases, while in Haskell, updating a pure function in Domain.Onboarding usually stayed contained. At first, I worried about performance due to immutability, but with the right data structures and occasional strictness, the services ran fast enough, and profiling guided us to the few places that needed attention.
One particularly memorable moment was during a production incident where a misbehaving worker was spawning too many threads. Because the core of our service was pure and concurrency happened via message passing, we could reason precisely about the system’s behavior, isolate the faulty component, and patch it without fearing state corruption.
Getting started: workflow, tooling, and project structure
A typical Haskell service might look like this:
my-service/
src/
Domain/
Onboarding.hs
Validation.hs
Analytics.hs
Lottery.hs
Effect/
UserStore.hs
Logging.hs
App/
Onboarding/
App.hs
HTTP.hs
CLI.hs
test/
Spec/
OnboardingSpec.hs
ValidationSpec.hs
app/
main.hs
cabal.project
my-service.cabal
hie.yaml
You can start with cabal or stack. cabal is the standard and works well with GHCup. A minimal workflow:
- Install GHCup (https://www.haskell.org/ghcup/). It manages GHC, cabal, and helpful tools like HLS (Haskell Language Server).
- Use HLS in your editor for rich feedback: type checking, hovers, go-to-definition, and inline hints.
- Use
cabal initto scaffold a project, then add dependencies to.cabalfiles underbuild-depends. - Write pure functions first, then build the effectful shell around them.
- Use
tastyorhspecfor tests. Keep tests fast by avoiding IO where possible. - Profile early if performance matters. GHC’s profiler is excellent and will show you where to focus.
A simple cabal.project for a local multi-package setup or single package looks like:
-- cabal.project
packages: .
-- optional: source-repository-package for pins
A minimal .cabal file snippet:
-- my-service.cabal
cabal-version: 3.0
name: my-service
version: 0.1.0.0
build-type: Simple
library
exposed-modules: Domain.Onboarding
, Domain.Validation
, Domain.Analytics
, Domain.Lottery
, Effect.UserStore
, App.Onboarding.App
build-depends: base ^>=4.17
, text
, containers
, random
hs-source-dirs: src
default-language: Haskell2010
test-suite spec
type: exitcode-stdio-1.0
main-is: Spec.hs
build-depends: base
, my-service
, tasty
, tasty-hunit
hs-source-dirs: test
default-language: Haskell2010
For HLS to work reliably, include a hie.yaml to tell it about your components:
cradle:
cabal:
- path: "src"
component: "lib:my-service"
- path: "test"
component: "test:spec"
Free learning resources
- Haskell.org documentation: The official site is the anchor for language reports, libraries, and community guidelines. See https://www.haskell.org/documentation/.
- GHCup: The recommended way to install GHC, cabal, and HLS. See https://www.haskell.org/ghcup/.
- Haskell Language Server: Brings IDE features to your editor. See https://github.com/haskell/haskell-language-server.
- School of Haskell (by FP Complete): Practical tutorials and articles on real-world Haskell patterns. See https://www.schoolofhaskell.com/.
- Real World Haskell (online book): Though older, it remains a useful introduction to building practical tools in Haskell. See http://book.realworldhaskell.org/.
- Learn You a Haskell for Great Good (online book): A friendly introduction to core concepts. See http://learnyouahaskell.com/.
- Hoogle: A search engine for Haskell functions and types. Useful when you want to know what a function does and its signature. See https://hoogle.haskell.org/.
- Stackage: Curated sets of packages to avoid version conflicts. Useful for stable builds. See https://www.stackage.org/.
Conclusion: Who should use Haskell and pure functions, and who might skip it
Use Haskell and pure functions when:
- Your domain is complex and benefits from strong modeling and precise types.
- Correctness, testability, and long-term maintainability are priorities.
- Concurrency and parallelism are part of the system’s requirements.
- You want to clearly separate business logic from the messiness of infrastructure.
Consider skipping or delaying Haskell when:
- The project is a short-lived prototype or a thin CRUD service that a framework in another language can spin up quickly.
- Your team has no bandwidth for onboarding and training, or the ecosystem integration you need (e.g., a specific cloud provider SDK) is far more mature elsewhere.
- You are tightly coupled to a UI-first or effect-heavy ecosystem and cannot afford the architectural shift.
Pure functions are not a universal solution, but they are a powerful tool for taming complexity. In my experience, the biggest benefit is confidence: when you read a pure function, you can understand it in isolation, and when you test it, you are testing the logic, not the environment. If your system is a house, pure functions are the straight, well-joined beams that hold it up, while effects are the doors and windows that connect it to the world. Build the frame straight, and the rest becomes much easier to manage.
If you are curious, start small: pull one business rule out of an existing service and make it pure. Measure how much easier it is to test and refactor. Then decide if the broader shift is worth it for your team. For many, that first step is the moment the value of purity becomes self-evident.




