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.
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 PlayerEvent
s in a GameEventStream
(or rather, you can, but there is no EventStream
instance for a GameEventStream
containing PlayerEvent
s). 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.
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.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With