Abstracting storage details with Effectful
How we can use algebraic effects to separate our storage definitions from their implementations
2025-05-28
I want to share a nice implementation detail I’m using in a project I’m working on. Some quick Googling shows that I’m definitely not the first to “discover” it, but I found it really neat so I thought I’d present it here for anyone who might find it useful.
Some things we love in Haskell projects:
Separation of concerns: The more we can isolate bits of code into small, manageable, independent chunks, the better. This leads to easier testing, better scalability, and reusability.
In Haskell, there are different levels of granularity we can use to keep unrelated code apart: functions, modules, and packages.
For more detail on this concept and others related to it, see:
Explicit effects: Being able to track effects at the type level gives us better encapsulation, and is frankly really cool. It also helps with #1 above: it lets us separate the definition of effects (what a function does) from the implementation of those effects (how it performs those effects).
Effect libraries such as Effectful, Bluefin and fused-effects, give us the ability to say exactly what types of effects a particular function can perform.
For a great breakdown on effect systems, I recommend this talk by Alexis King.
This article assumes some knowledge of Effectful and a few other libraries - see the relevant documentation for detailed guides.
All the code from this article can be found in this repo.
Package design
The Haskell backend of this project is a big monorepo, split into quite a lot of smaller packages:
prelude
- custom prelude based on Protoludecore
- core domain types + logicbackend
- backend serverbackend/backend-api
- Servant API definitionsbackend/backend-server
- server implementation for the API
- etc.
An important part of any application is how it stores data. My project needs to store user information, user data, server data etc. When we’re developing our application we might start off keeping all the data in memory; later we might use a SQLite database, and later still we might migrate to something more serious like PostgreSQL.
If we made the mistake of intertwining our storage code with our API code in backend-server
, each of these changes would require a lot of refactoring, and could be very error-prone.
So let’s extract all our storage code into its own package:
prelude
core
backend
backend/backend-api
backend/backend-server
storage
- how we store and manage application data- etc.
However, we can go a step further. What if we want the option to support multiple different storage backends, or all of them? Maybe we want to use the in-memory backend for pure testing, the SQLite database for local development, and PostgreSQL in production? It would be great if we could separate the definitions of what we want to store and what operations we want to support from the actual implementations. Fortunately, in Haskell we can do anything. The approach I’m going to take is using the extensible effects library Effectful.
Let’s split our storage
package up again: one package for defining our storage effect, and one for each backend (of course we’re free to add more in the future, completely orthogonally):
prelude
core
backend
backend/backend-api
backend/backend-server
storage
storage/storage
- our storage abstractionstorage/storage-memory
- implementation of our storage abstraction using an in-memory backendstorage/storage-postgresql
- implementation of our storage abstraction using a PostgreSQL database
- etc.
Right, let’s look at some actual code.
Domain model
First, let’s review some of our domain types in core
, omitting details about parameters and fields:
-- file core/src/Core.hs
{-# LANGUAGE TemplateHaskell #-}
module Core where
import Control.Lens
type UserId = Int
-- | This parameterisation allows us to define useful type synonyms.
data User' id ... = User
_userId :: id
{...
,
}deriving (Show, Generic)
-- | A full user in the model
type User =
User'
UserId
...
-- | A user without IDs etc, to be created
type UserCreate =
User'
()...
type TransactionId = Int
data Transaction' id userId ... = Transaction
_transactionId :: id
{ _transactionUserId :: userId
,...
,
}deriving (Show, Generic)
type Transaction =
Transaction'
TransactionId
UserId
...
type TransactionCreate =
Transaction'
()
()...
-- generate lenses for easy and awesome manipulation
'User'
makeLenses ''Transaction' makeLenses '
API definition
Now, let’s look at our backend API, since that will inform the design of our Storage
abstraction.
-- file backend/api/src/Backend/API.hs
module Backend.API where
import Core
import Servant.API
type UserAPI =
"list" :> Get '[JSON] [User]
-- ^ list all the users
:<|> "create" :> ReqBody '[JSON] UserCreate :> Post '[JSON] User
-- ^ create a new user
type TransactionAPI =
"list" :> Get '[JSON] [Transaction]
-- ^ list all user transactions
:<|> "delete" :> Capture "id" TransactionId :> DeleteNoContent
-- ^ delete a transaction
-- | The full API.
type API =
"user" :> UserAPI
:<|> "transaction" :> TransactionAPI
Pretty standard stuff.
But now we know what operations we need our Storage
effect to support:
Storage abstraction
-- file storage/storage/src/Storage/Effect.hs
{-# LANGUAGE TemplateHaskell #-}
module Storage.Effect where
import Core
import Effectful
import Effectful.TH
data Storage :: Effect where
ListUsers :: Storage m [User]
CreateUser :: UserCreate -> Storage m User
ListTransactions :: Storage m [Transaction]
DeleteTransaction :: TransactionId -> Storage m ()
'Storage makeEffect '
Note that last Template Haskell splice will generate code that looks like:
listUsers :: (Storage :> es) => Eff es [User]
createUser :: (Storage :> es) => UserCreate -> Eff es User
These will come in very useful when we implement the server…
Server implementation
Now, we can write our Servant handlers:
-- file backend/server/src/Backend/Server.hs
module Backend.Server where
import Backend.API
import Data.Functor (($>))
import Effectful
import Servant.API hiding ((:>))
import Servant.Server
import Storage.Effect
userServer :: (Storage :> es) => ServerT UserAPI (Eff es)
= listUsers :<|> createUser
userServer
transactionServer ::
Storage :> es) => ServerT TransactionAPI (Eff es)
(=
transactionServer :<|> deleteTransaction'
listTransactions where
= deleteTransaction tId $> NoContent
deleteTransaction' tId
apiServer :: (Storage :> es) => ServerT API (Eff es)
= userServer :<|> transactionServer apiServer
Wow! That was almost boring with how mechanical it was.
And apart from being concise, notice that none of the code in this module cares about how the Storage
effect is dispatched.
There isn’t even an interpreter for the Storage
effect in scope!
Some interpreters
Speaking of which, now we can write some handlers for our Storage
effect.
Let’s start with a simple in-memory implementation.
In-memory interpreter
-- file storage/memory/src/Storage/Memory.hs
{-# LANGUAGE TemplateHaskell #-}
module Storage.Memory where
import Control.Lens
import Core
import Effectful
import Effectful.Dispatch.Dynamic
import Effectful.State.Static.Local
import qualified Data.Map as M
import Storage.Effect
-- first we define a datatype to hold our data in-memory
data Memory = Memory
_users :: M.Map UserId User
{ _transactions :: M.Map TransactionId Transaction
, _nextId :: Int
,
}deriving (Show)
'Memory
makeLenses '
-- in real life, we'd want a cleaner way of generating IDs, but this
-- is good enough for demonstration purposes
runStorageMemory ::
State Memory :> es) => Eff (Storage : es) a -> Eff es a
(= interpret $ \_ -> \case
runStorageMemory ListUsers -> gets $ M.elems . _users
CreateUser userCreate -> do
<- state $ nextId <+~ 1
uId let newUser = userCreate & userId .~ uId
$ users . at uId ?~ newUser
modify pure newUser
ListTransactions -> gets $ M.elems . _transactions
DeleteTransaction tId -> modify $ transactions . at tId .~ Nothing
PostgreSQL interpreter
--file storage/memory/src/Storage/Postgres.hs
{-# OPTIONS_GHC -Wno-orphans #-}
module Storage.Postgres where
import Control.Lens
import Control.Monad (void)
import Core
import Database.PostgreSQL.Simple
import Effectful
import Effectful.Dispatch.Dynamic
import Effectful.State.Static.Local
import Storage.Effect
-- first we need some instances
instance FromRow User
instance ToRow User
instance FromRow Transaction
instance ToRow Transaction
-- again, we use 'State' for generating IDs
runStoragePostgres ::
State Int :> es, IOE :> es) =>
(Connection ->
Eff (Storage : es) a ->
Eff es a
= interpret $ \_ -> \case
runStoragePostgres conn ListUsers -> liftIO $ query_ conn "SELECT * FROM app_users"
CreateUser userCreate -> do
<- get
uId + 1)
modify (let newUser = userCreate & userId .~ uId
$
liftIO "INSERT INTO app_users values (?)" newUser
execute conn pure newUser
ListTransactions ->
$ query_ conn "SELECT * FROM transactions"
liftIO DeleteTransaction tId ->
. liftIO $
void "DELETE FROM transactions where id = ?" (Only tId) execute conn
Conclusion
As you can see, this is a very powerful technique. Algebraic effects let us separate the definition of what storage operations we want to support, from how we implement them. This is very similar in the way that Servant types let us separate the shape of an API, from how its server functions are implemented.