haskell-webapps icon indicating copy to clipboard operation
haskell-webapps copied to clipboard

How to represent error conditions in the domain API?

Open saurabhnanda opened this issue 8 years ago • 4 comments

Copied over from the Reddit thread

Along my crazy journey to build a "minimum viable webapp architecture", I'm stuck with a design-level issue. How do I represent error conditions/cases in my domain API?

In a bid to keep things explicit and pure [1], I designed the following first:

createTenant :: NewTenant -> AppM (Either TenantCreationError Tenant)
activateTenant :: TenantId -> AppM (Either TenantActivationError Tenant)

type AppM = ReaderT AppConfig IO
data TenantCreationError = DuplicateBackofficeDomainError Text deriving Show
data TenantActivationError = OwnerIdAbsentError deriving Show

All, was good till I had to write code dealing with createTenant and activateTenant, both. It turns out that Either TenantCreationError and Either TenantActivationError don't compose easily! For example, take a look at the type that the compiler infers for the following function:

createAndActivateTenant :: NewTenant -> AppM (Either TenantCreationError (Either TenantActivationError Tenant))
createAndActivateTenant newTenant = (createTenant newTenant) >>= \case
  Left x -> return $ Left x
  Right y -> do
    t <- activateTenant $ (y ^. key)
    return $ Right t

So, I'm left with the following options:

Option 1: Keep using Either

Continue with the current design and create even more ADTs for the Left part of functions that compose two underlying Eithers. eg: createAndActivateTenant :: NewTenant -> Either TenantCreationOrActivationError Tenant -- something I'm not too excited about.

Option 2: Unify all error cases

Sacrifice granularity and the self-documenting nature of the more specific type-signatures, and go with a unified ADT for all domain-level error cases, eg:

data DomainError = DuplicateBackofficeDomainError Text | OwnerIdAbsentError
createTenant :: NewTenant -> Either DomainError Tenant
activateTenant :: TenantId -> Either DomainError Tenant

Option 3: Dump Either and use MonadCatch

Not too happy with this because the error cases are not visible in the type signature at all.

Option 4: Use advanced type-level hackery

Figure out how to get something like the following:

createAndActivateTenant :: NewTenant -> Either '(Errors [TenantCreationError TenantActivationError]) Tenant

Questions

Is there any other possibility that I'm missing? Any advantage/disadvantage that I'm missing? Is there any library that implements Option 4 mentioned above?

[1] I know, it's in a ReaderT r IO monad, so it's not "pure" in the "pure" sense, but it's "pure" in the "doesn't throw errors" sense.

saurabhnanda avatar Sep 29 '16 09:09 saurabhnanda

Cool, I like that you make issues from it, I'm curious what you're gonna choose in the end!

BartAdv avatar Sep 29 '16 09:09 BartAdv

@BartAdv I'll settle for Option 2 (unify into a single ADT) or Option 3 (throw exceptions instead) and see how the domain API gets used in the servant handler. If it doesn't come out well, I'm expected Haskell to help me with the refactor.

saurabhnanda avatar Sep 29 '16 09:09 saurabhnanda

@saurabhnanda I like the way Servant handles errors and its very flexible!

https://hackage.haskell.org/package/servant-server-0.9/docs/src/Servant-Server-Internal-ServantErr.html

data ServantErr = ServantErr { errHTTPCode     :: Int
                             , errReasonPhrase :: String
                             , errBody         :: LBS.ByteString
                             , errHeaders      :: [HTTP.Header]
                             } deriving (Show, Eq, Read, Typeable)

and when raising errors, you can have specific errors... like err404

-- | 'err400' Bad Request
--
-- Example:
--
-- > failingHandler :: Handler ()
-- > failingHandler = throwError $ err400 { errBody = "Your request makes no sense to me." }
--
err404 :: ServantErr
err404 = ServantErr { errHTTPCode = 404
                    , errReasonPhrase = "Not Found"
                    , errBody = ""
                    , errHeaders = []
                    }

and uses Handler

type Handler = ExceptT ServantErr IO

I believe rather than using ADT a record would make better sense?

What do you think?

sudhirvkumar avatar Oct 04 '16 08:10 sudhirvkumar

there are at least 2 classes of errors, 1, bad data, likerequest can't be parsed, product id is invalid or something. For public consumption, i'd go with 400 or 404. since the only user of the api is the UI, it might make sense to return some json that indicates what's wrong more precisely. - my preference is still 400 - bad request the other class of errors, something internal is broken, like the database is down, or unreadable, or a logfile can't be written, system can't run type stuff. i like health checks. easy to monitor.

jfoutz avatar Oct 15 '16 03:10 jfoutz