Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

What should I do when I feel the urge to use object-style polymorphic messaging in Haskell?

I have a follow-up question to this question. What's the idiomatic Haskell equivalent to a polymorphic class-level constant in an object-oriented language?


I'm experimenting with event-sourcing, using Event Store and Haskell. I've got stuck trying to figure out the logic that saves and loads events.

Event Store is based on the concept of event streams; in an object-oriented domain model there's normally a 1:1 relation between event streams and aggregates. You can organise the streams into categories; typically you'll have one category per aggregate class in your domain model. Here's a sketch of how you might model it in C#:

interface IEventStream<T> where T : Event
{
    string Category { get; }
    string StreamName { get; }
    IEnumerable<T> Events { get; }
}

class PlayerEventStream : IEventStream<PlayerEvent>
{
    public string Category { get { return "Player"; } }
    public string StreamName { get; private set; }
    public IEnumerable<PlayerEvent> Events { get; private set; }
    public PlayerEventStream(int aggregateId)
    {
        StreamName = Category + "-" + aggregateId;
    }
}

class GameEventStream : IEventStream<GameEvent>
{
    public string Category { get { return "Game"; } }
    public string StreamName { get; private set; }
    public IEnumerable<GameEvent> Events { get; private set; }
    public GameEventStream(int aggregateId)
    {
        StreamName = Category + "-" + aggregateId;
    }
}

class EventStreamSaver
{
    public void Save(IEventStream stream)
    {
        CreateStream(stream.StreamName);
        AddToCategory(stream.StreamName, stream.Category);
        SaveEvents(stream.StreamName, stream.Events);
    }
}

This code ensures a GameEvent never gets sent to a Player's event stream and vice versa, and that all the categories are correctly assigned. I'm using polymorphic constants for Category to help protect this invariant, and to make it easy to add new stream types later.

Here's my first attempt at translating this structure into Haskell:

data EventStream e = EventStream AggregateID [e]

streamName :: Event e => EventStream e -> String
streamName (EventStream aggregateID (e:events)) = (eventCategory e) ++ '-':(toString aggregateID)

class Event e where
    eventCategory :: e -> String
    -- and some other functions related to serialisation

instance Event PlayerEvent where
    eventCategory _ = "Player"

instance Event GameEvent where
    eventCategory _ = "Game"

saveEventStream :: Event e => EventStream e -> IO ()
saveEventStream stream@(EventStream id events) =
    let name = streamName stream
        category = eventCategory $ head events
    in do
        createStream name
        addToCategory name category
        saveEvents name events

This is pretty ugly. The type system requires eventCategory to mention e somewhere in its signature, even though it's not used anywhere in the function. It'll also fail if the stream contains no events (because I'm trying to attach the category to the type of events).

I'm aware that I'm trying to write C# in Haskell - is there a nicer way to implement polymorphic constants of this type?


Update: As requested, here are the type signatures that I think the (presently unimplemented) stubs in the do block should have:

type StreamName = String
type CategoryName = String

createStream :: StreamName -> IO ()
addToCategory :: StreamName -> CategoryName -> IO ()
saveEvents :: Event e => StreamName -> [e] -> IO ()

These functions would be responsible for communicating with the database - setting up the schema and serialising out the events.

like image 967
Benjamin Hodgson Avatar asked Feb 13 '23 16:02

Benjamin Hodgson


2 Answers

Some people suggest existential types, but unless I am grossly misunderstanding, you want to limit certain even streams to certain types.

Well first of all,

data EventStream e = EventStream AggregateID [e]

streamName :: Event e => EventStream e -> String
streamName (EventStream aggregateID (e:events)) = (eventCategory e) ++ '-':(toString aggregateID)

Should seem strange. You call eventCategory on the first even and throw away the rest, so you assume the category of all the events is the same. But of course, the eventCategory can return different strings for different values of an event. And if there are no events, you have to do eventCategory undefined.

One idea is to change the type of eventCategory:

data Proxy p = Proxy 

class Event e where
    eventCategory :: Proxy e -> String

Now it is impossible for the function to return different strings for different values of the event, because it has no access to an actual value. In other words, eventCategory depends only on the type, not the value.

Another possibility is to follow the c# code, namely, category is a property of a stream, not an event:

{-# LANGUAGE MultiParamTypeClasses #-}

import Data.ByteString 

class Event e where 
  deserialize :: ByteString -> e 
  ... other stuff

class Event e => EventStream t e where 
  category :: t e -> String
  aggregateId :: t e -> Int 
  events :: t e -> [e] 
  name :: t e -> String 
  name s = category s ++ "-" ++ show (aggregateId s)

The EventStream typeclass corresponds closely with the interface.

Notice how name is inside the typeclass, but you can write it without knowing which instance you are using. You could just as easily move it out of the typeclass, but an implementation may decide it will define a custom name, which would override the default defintion.

Then you define your events:

data PlayerEvent = ...
instance Event PlayerEvent where ...

data GameEvent = ...
instance Event GameEvent where ...

Now the stream types:

data PlayerEventStream e = PES Int [e] 
instance EventStream PlayerEventStream PlayerEvent where 
  category = const "Player" 
  aggregateId (PES n _) = n
  events (PES _ e) = e 

data GameEventStream e = GES Int [e] 
instance EventStream GameEventStream GameEvent where 
  category = const "Game" 
  aggregateId (GES n _) = n
  events (GES _ e) = e 

Notice the event types are isomorphic, but still distinct types. You can't have PlayerEvents in a GameEventStream (or rather, you can, but there is no EventStream instance for a GameEventStream containing PlayerEvents). You can even strengthen this relation:

class Event e => EventStream t e | t -> e where 

This says that for a given stream type, only one event type may exist, so defining two instances like so is a type error :

instance EventStream PlayerEventStream PlayerEvent where 
instance EventStream PlayerEventStream GameEvent where 

The save function is trivial:

saveEventStream :: EventStream t e => t e -> IO ()
saveEventStream s = do 
  createStream (name s)
  addToCategory (name s) (category s) 
  saveEvents (name s) (events s) 

I have no idea if this is what you are actually looking for but it seems to accomplish the same things as the c# code.

like image 60
user2407038 Avatar answered Feb 16 '23 08:02

user2407038


I'd like to show you how this might be done without using type classes. I often find that the results are simpler.

First, here are a few guesses at what types you might be using. You provided some of these:

type CategoryName = String
type Name = String
type PlayerID = Int
type StreamName = String
data Move = Left | Right | Wait

data PlayerEvent = PlayerCreated Name | NameUpdated Name
data GameEvent = GameStarted PlayerID PlayerID | MoveMade PlayerID Move

Record types are often a useful replacement for classes. In this example e will either be a GameEvent or a PlayerEvent.

data EventStream e = EventStream
    { category :: String
    , name :: String
    , events :: [e]
    }

In C# you had subclasses which override the Category property. in Haskell you can use smart constructors for this purpose. If your C# subclasses were overriding methods, then in Haskell you would include functions within the EventStream e type. Other modules in your program can be prevented from creating invalid EventStream objects by only exposing these smart constructors:

-- generalized smart constructor
mkEventStream :: CategoryName -> Int -> EventStream e
mkEventStream cat ident = EventStream cat (cat ++ " - " ++ show ident) []

playerEventStream :: Int -> EventStream PlayerEvent
playerEventStream = mkEventStream "Player"

gameEventStream :: Int -> EventStream GameEvent
gameEventStream = mkEventStream "Game"

Finally, you have defined a few functions. Here's how they might be written:

createStream :: StreamName -> IO ()
createStream = undefined

addToCategory :: StreamName -> CategoryName -> IO ()
addToCategory = undefined

saveEvents :: EventStream e -> IO ()
saveEvents = undefined

saveEventStream :: EventStream e -> IO ()
saveEventStream stream = do
    createStream name'
    addToCategory name' (category stream)
    saveEvents stream
  where
    name' = name stream

This is closer to what your C# example does. The stream's name is fixed when created, and the category is part of each stream rather than linked to the type of each element.

like image 31
Michael Steele Avatar answered Feb 16 '23 08:02

Michael Steele