Last active
September 2, 2024 14:52
-
-
Save Revolucent/ac9f0e02e2e324333e7617b56be23d6b to your computer and use it in GitHub Desktop.
Haskell Repository Pattern With Type Classes
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
> {-# 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