Skip to content

Instantly share code, notes, and snippets.

@Revolucent
Last active September 2, 2024 14:52
Show Gist options
  • Save Revolucent/ac9f0e02e2e324333e7617b56be23d6b to your computer and use it in GitHub Desktop.
Save Revolucent/ac9f0e02e2e324333e7617b56be23d6b to your computer and use it in GitHub Desktop.
Haskell Repository Pattern With Type Classes
> {-# LANGUAGE FunctionalDependencies #-}
> module UserRepository where
This is an example of adapting the Repository pattern to Haskell using type classes, with the aim of decoupling data access from its interface for the purpose of writing better unit tests. Is this the best way to achieve this goal? Well, it's one way.
It makes the assumption that your application is using the ReaderT IO pattern promoted by libraries like rio.
First, we create a typeclass that contains our data access methods:
> class UserRepository r where
> getAllUsersR :: MonadIO m => r -> m [User]
> getUserR :: MonadIO m => Int -> r -> m User
Note the R suffix on each method. This serves to distinguish the methods on our type class from the actual functions that will be used in the rest of our codebase. (More on that in a bit.)
Also note that the repository argument itself — r — is always the last argument. This will help us integrate more easily with our helper function:
> class HasUserRepository r a | a -> r where
> getUserRepository :: a -> r
>
> withUserRepository :: (MonadReader env m, HasUserRepository r env, MonadIO m) => (r -> m a) -> m a
> withUserRepository action = asks getUserRepository >>= action
Using this helper function, we know implement the actual functions we'll use in our application:
> getAllUsers :: (MonadReader env m, HasUserRepository r env, MonadIO m) => m [User]
> getAllUsers = withUserRepository getAllUsersR
>
> getUser :: (MonadReader env m, HasUserRepository r env, MonadIO m) => Int -> m User
> getUser id = withUserRepository $ getUserR id
The beauty of this is that when we're in our application monad, we don't have to explicitly pass the repository to our data access methods. It's handled implicitly, as God intended.
Now, how do we use this? Let's look at a toy environment that talks to a PostgreSQL database:
> newtype AppEnvironment = AppEnvironment { connection :: Connection }
And now let's adapt this for our UserRepository:
> instance UserRepository Connection where
> getAllUsersR conn = liftIO $ query_ conn "SELECT * FROM user"
> getUserR id conn = liftIO $ query conn "SELECT * FROM user WHERE id = ?" (Only id)
>
> instance HasUserRepository Connection AppEnvironment where
> getUserRepository = connection
Once we've done this, everything just works:
> foo :: ReaderT AppEnvironment IO User
> foo = getUser 7 -- I guess user 7 is the "foo" user
Now, what about testing? This is relatively simple:
> newtype MockDB = MockDB { users :: [User] }
>
> instance UserRepository MockDB where
> getAllUsersR db = return $ users db
> getUserR id db = undefined -- You get the idea
> instance HasUserRepository MockDB MockDB where
> getUserRepository = id
Of course, this means our foo function above isn't easily testable, so let's rewrite it so we can use it either with AppEnvironment or MockDB:
> foo :: (MonadReader env m, HasUserRepository r env, MonadIO m) => m User
> foo = getUser 7
Now if we run in a ReaderT MockDB IO context, we get the mock methods. If we run in ReaderT AppEnviroment IO, we get the production methods.
Perfect? No. Usable? Yes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment