Architecting Data Boundaries in PureScript with Aff and Effect Monads
Learn how to isolate side effects and enforce data boundaries using PureScript's Aff monad and Effect system to build predictable, functional frontend architectures.
08 May 2026, 13:42 UTC

Frontend code often leaks side effects into business logic: a component directly calls localStorage, a reducer fires an HTTP request, or a utility function reads the clock. Once I/O is woven through the domain, tests become slow, reasoning becomes local, and refactors risk breaking unrelated behavior. The useful takeaway is to treat effects as explicit, typed values rather than hidden occurrences. In PureScript, that means keeping pure functions free of Effect and Aff, and pushing all I/O to the edges where untrusted data is parsed into trusted domain types.
Requirements for an Effect-Safe Architecture
Three constraints drive the design. First, the core domain logic must be pure: given the same inputs, it returns the same outputs without touching the outside world. Second, every side effect must appear in a type signature, so the compiler—not a code reviewer—enforces the boundary. Third, external data must be validated before it reaches the core, turning untrusted bytes into typed values that the domain can trust.
Smallest Suitable Design: Pure Core, Aff Shell
The smallest design that satisfies these requirements separates the application into a pure core and an effectful shell. The core contains functions like updateUsername that transform records. The shell contains Aff actions that perform asynchronous work such as HTTP requests, timers, or database calls. Synchronous effects like logging or random number generation use the Effect type.
Below is a minimal boundary pattern. The domain model is a plain record. The pure function transforms it. The Aff action simulates fetching data and parses it at the edge before returning a trusted type.
module Main where
import Prelude
import Data.Either (Either(..))
import Data.Argonaut.Decode (decodeJson, parseJson)
import Effect (Effect)
import Effect.Aff (Aff)
-- Domain model (pure)
type User =
{ id :: Int
, name :: String
}
-- Pure logic: no Effect or Aff in the signature
updateUsername :: String -> User -> User
updateUsername newName user = user { name = newName }
-- Boundary: parse untrusted JSON into a trusted domain type
decodeUser :: String -> Either String User
decodeUser raw = parseJson raw >>= decodeJson
-- Asynchronous effectful shell
fetchUser :: Int -> Aff (Either String User)
fetchUser _ = do
raw <- pure "{\"id\": 1, \"name\": \"Alice\"}"
pure $ decodeUser raw
Replace the pure line inside fetchUser with an HTTP request from your preferred client library. The key point is that decodeUser runs at the boundary: malformed input produces a Left value, and the core never sees a partially parsed structure.
Trust and Data Boundaries
Trust is established through types. Assume every value from an API, user input, or local storage is untrusted. Use a decoding library such as purescript-argonaut to map external shapes to internal domain models immediately. If parsing fails, the error is represented by Either or Maybe, which forces the caller to handle the failure case explicitly rather than discovering it later as a runtime exception.
This approach prevents the undefined is not a function class of errors common in dynamically typed frontends. A function that accepts User can rely on the invariant that id is an Int and name is a String, because the only way to construct a User from external data is through the decoder.
Operational Checks
Operational checks happen at compile time and in tests. To verify effect isolation, attempt to call an Aff function from inside a pure function and observe the compiler error. For example, changing updateUsername to call fetchUser will fail because Aff cannot appear in a signature that returns User.
To verify boundary integrity, feed malformed JSON to decodeUser and assert that the result is Left. Run these checks from your project root with spago test or purs compile. You need the PureScript compiler and a package manager such as spago or psc-package installed. The compiler requires read access to your source files and write access to the output directory.
Failure Modes and Maintenance Risks
Over-reliance on nested monad transformers is the most common failure mode. Stacking Aff with ReaderT, StateT, or custom effects produces long type signatures that are difficult to read and debug, especially for teams without a background in category theory. Keep the Aff stack flat unless a transformer removes genuine duplication.
A second risk is performance overhead in high-frequency loops. Wrapping every iteration in Aff or Effect can add allocation and scheduling costs compared to plain JavaScript. If a loop runs thousands of times per frame, profile the compiled output and consider moving the hot path into a single synchronous Effect block rather than composing fine-grained effectful actions.
Conditions That Would Change the Design
If your application is entirely synchronous—no network, timers, or asynchronous storage—use Effect alone and skip Aff. Introducing Aff for synchronous work adds complexity without benefit. Conversely, if you need cancellation, parallelism, or resource cleanup, Aff is the right tool because it provides these semantics through its type class instances.
If your team is small and unfamiliar with monad transformers, prefer explicit parameter passing over ReaderT and mutable local state over StateT. The goal is a boundary the team can maintain, not a maximal demonstration of type-level machinery.
Limitations and Verification
This architecture assumes you control the build pipeline and can enforce the PureScript compiler as a gate. It also assumes your decoding library version matches the import paths shown; verify the exact module names against your spago.dhall or psc-package.json because purescript-argonaut has changed its API across major versions. Finally, Aff is not free: it introduces a runtime layer for asynchronous scheduling, so measure before adopting it in performance-critical inner loops.
To check the result, compile the project and run the boundary tests. A correct implementation will reject malformed JSON at the decoder, keep the core functions free of Effect and Aff, and produce compiler errors when an effectful action is called from a pure context.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.