๐ Welcome to ElmSharp.
I came across the Elm Language a few years ago and it has deeply changed the way I approach software. Elm is multiple things: it is a language, it is a package ecosystem and more fundamentally it is an architecture.
I do feel, however, that sometimes it is hard to describe to my fellow colleagues how Elm works and what are the rules of the game. So I decided: what better way to show-and-tell than to "create Elm" in csharp.
Why was I so moved by the elm architecture? My experience is that it brings a set of healthy constraints that lead you towards objectively better software: many decisions that would be made later in a software project must be made earlier and more consciously. The practices this architecture enforces (immutability, pureness of functions, unidirectional data flow) align very well with the ultimate goal of having testable and reliable software.
The elm architecture is very well explained in the elm guide so I will use the bullet points I find more important:
- Your application has one single piece of state
- This state is immutable: the only way to "move state forward" is via the
Update
function - The
Update
function is always triggered by aMessage
- The only way to have a side-effect in the world is via a
Command
Message
s can come from theView
, fromSubscriptions
or from the result of aCommand
You can think of a Message
as a fact: "the user clicked on this button", "time has elapsed", "this HTTP request has failed", "the time is now 12:24:00".
You can think of a Command
as an intention to affect or obtain information from the world: "can you please run this HTTP request?", "can you please tell me the time?", "can you please give me a random number between 0 and 100?", "I would like a new Guid, please".
Just like the Elm architecture, there in an implicit loop that ElmSharp's runtime handles for you. From a high level standpoint, it goes something like this:
sequenceDiagram
ElmSharp->>+User: Init()
User->>-ElmSharp: state=๐ฆ, cmd=๐
Note over ElmSharp: ElmSharp remembers<br/>state=๐ฆ, cmd=๐
opt Subscription management
ElmSharp->>+User: Subscriptions(state: ๐ฆ)
User->>-ElmSharp: desiredSubs = [โ, โจ, โ]
Note over ElmSharp: ElmSharp subscribes to<br/>the desired subscriptions,<br/> if any
ElmSharp-->>+Subs: ๐ Activate subscription(๐ซ)
Subs-->>-MailBox: โ Subscription event
end
loop while cmd != StopApp
ElmSharp-->>+Cmds: ๐โโ๏ธ Run command (๐ซ)
Cmds->>-MailBox: โ Cmd result
ElmSharp->>+User: View(state: ๐ฆ, ๐ซ)
User-->>MailBox: โ User performed action
User->>-ElmSharp: ๐จ View representation
Note over ElmSharp: ElmSharp renders the view<br/>(only command line<br/>supported, for the time being)
ElmSharp->>+MailBox: โ Wait for a message...
MailBox->>-ElmSharp: โ Message
ElmSharp->>+User: Update(msg: โ, state: ๐ฆ)
User->>-ElmSharp: state=๐บ, cmd=๐
Note over ElmSharp: ElmSharp remembers<br/>state=๐บ, cmd=๐
opt Subscription management
ElmSharp->>+User: Subscriptions(state: ๐บ)
User->>-ElmSharp: desiredSubs = [๐ฎโโ๏ธ, ๐จ]
Note over ElmSharp: ElmSharp compares the delta<br/>before previous subscriptions<br/>and the new desired ones<br/> subscribing to new ones and<br/>unsubscribing from old ones
ElmSharp-->>Subs: โ Unsubscribe
ElmSharp-->>Subs: ๐ Activate subscription(๐ซ)
end
end
ElmSharp-->>Subs: โ Unsubscribe
ElmSharp->>User: ๐ช Exit with StopApp status code
Explaining it on a textual level: when an ElmSharp application starts (User runs await ElmSharp<Model, Message>.Run(...)
), ElmSharp internally sets up a mailbox (leveraging System.Threading.Channels
). This mailbox has a Write
mechanism, called the dispatcher
. ElmSharp then calls the user's Init()
function, which returns a Model
instance and a Command
to be executed. ElmSharp stores this instance of the Model
as the current state (the words Model
and state are used somewhat interchangeably in this document).
Now that ElmSharp has the current state, it once again reaches to the user code via the Subscriptions
function. This function will return a list of desired subscriptions, so that ElmSharp can perform the wiring up of these subscriptions. Wiring up a subscription consists of creating a new CancellationTokenSource
per subscription and invoking the internal Subscribe
method on the subscription instance, which receives a CancellationToken
and the dispatcher
. The subscriptions themselves are executed as non awaited Task
s so they don't block ElmSharp's main loop. A subscription communicates with the user via the aforementioned mailbox (leveraging the dispatcher
).
ElmSharp now enters a while(true)
loop. Inside it, ElmSharp first looks at the Command
returned by the user. If the command is the StopAppCommand
we break out of the loop, unsubscribe for any existing subscriptions and return from the await .Run(...)
method with the same status code as requested on the StopAppCommand. Otherwise, this is a normal Command
and ElmSharp will invoke its Run
method, much like what happened with the subscriptions, above. One big difference is that while a Subscription has access to the dispatcher
and therefore can create multiple messages, a Command
doesn't: a Command
can only return the appropriate Message
indicating its success or failures (semantics vary per command, ElmSharp doesn't distinguish between success or failure). This returned messaged is then dispatched to the mailbox. Commands also execute as non awaited Task
, which means they don't block ElmSharp's loop.
One further step is the View
function, where ElmSharp once again reaches to user code and provides the current state, as well as the dispatcher
. This allows the user to build user interfaces which can themselves dispatch messages into ElmSharp's mailbox. The View
aspect of ElmSharp is still an area under development, and for now only Console Applications are supported. Due to their nature, console applications do not leverage the dispatcher
of the View
function.
Finally, ElmSharp will await for a message in the mailbox, which signals that something of interest has happened. Remember, a message can come from the View
, or the result of a Command
or from a Subscription
. Once ElmSharp receives this message, it will invoke the user's Update
function. This function receives this Message
as well as the current Model
. The job of this function, much like the Init()
function is to return a new instance of the Model
as well as any Command
that should be executed.
As the last step, since the Model
has potentially been modified by the Update()
function, ElmSharp will once again invoke the Subscriptions()
function, applying the logic described above . And the loop repeats.
There are two worlds in an ElmSharp application: the runtime world and the user world. You are the user, the creator of awesome ElmSharp applications.
As a user, your job consists of:
-
Creating a
Model
. For instance, if your application keeps track of "todo" items, yourModel
could bepublic record Model(ImmutableList<Todo> Todos);
-
Declaring the list of
Message
s that your application understands. For the simple todo tracking application, we can imagine a few messages:TodoCreated
,TodoMarkedInProgress
,TodoMarkedCompleted
andTodoDeleted
-
Implementing an
Init
function which tells ElmSharp about the initial state when your application starts -
Implementing the
Subscriptions
function, which is the way that you let ElmSharp know "given my model is currently X, I want to subscribe to these interesting events about the world (or none)" -
Implementing the
View
function which is how you will represent yourModel
to the user. The whole view can only be dependent on data present in theModel
-
Implementing the
Update
function, which is how you make your model progress, in response to incomingMessage
s
โน More advanced use cases will require you to implement your specific
Command
andSubscription
but this is something we will cover in a later topic.
As with any architecture, we can only reap benefits if we follow the ground rules associated with it. For both Elm and ElmSharp's architecture there is one fundamental ground rule: immutability. In Elm this is trivial, because the language itself doesn't have any mutability "escape hatches". C# however, has plenty of those ๐
. This means that just like TDD or SOLID enforce certain practices to reap any benefits, ElmSharp's architecture requires the Model
to be fully immutable. Without this rule, there won't just be dragons, there will be dฬตrฬดaฬตgฬดoฬธnฬธsฬถ ฬทwฬถhฬธaฬทtฬด ฬถiฬธsฬด ฬดhฬดaฬตpฬถpฬถeฬดnฬทiฬดnฬดgฬธ,ฬด ฬทoฬถhฬด ฬทnฬดoฬดoฬถoฬด. You have been warned ๐ฒ๐
Assuming the following GlobalUsings.cs
in your project UserCode
:
// ๐ GlobalUsings.cs
global using Cmd = ElmSharp.ElmSharp<UserCode.Model, UserCode.Message>.Command;
global using Sub = ElmSharp.ElmSharp<UserCode.Model, UserCode.Message>.Subscription;
These are the signatures of the important functions:
// MODEL (immutable; holds you application state)
public record Model(
ImmutableDictionary<Guid, Todo> Todos);
// MESSAGE (immutable; communicates facts that happened)
public abstract record Message
{
public sealed record TodoCreated(string TodoDescription) : Message { }
public sealed record TodoMarkedInProgress(Guid TodoId) : Message { }
public sealed record TodoMarkedCompleted(Guid TodoId) : Message { }
public sealed record TodoDeleted(Guid TodoId) : Message { }
// ...;
}
// INIT (pure function; provides the initial state of the app and commands to execute)
public static (Model, Cmd) Init() => /*...*/;
// SUBSCRIPTIONS (pure function; allows you to obtain messages from non-user inputs)
public static ImmutableDictionary<string, Sub> Subscriptions(Model model) => /*...*/;
// VIEW (pure function; given a current model, return a visualization intention)
public static object View(Model model, Action<Message> dispatch) => /*...*/;
// UPDATE (pure function; the only way to move your state forward; gets triggered by incoming messages)
public static (Model, Cmd) Update(Message message, Model model) => message switch
{
Message.TodoCreated info =>
model.OnTodoCreated(info),
Message.TodoMarkedInProgress info =>
model.OnTodoMarkedInProgress(todoId: info.TodoId),
Message.TodoMarkedCompleted info =>
model.OnTodoMarkedCompleted(todoId: info.TodoId),
Message.TodoDeleted info =>
model.OnTodoDeleted(todoId: info.TodoId),
//...
}
// ...
// Using extension methods (pure functions) on Model, to allow for a cleaner looking Update function
internal static (Model, Cmd) OnTodoDeleted(
this Model model,
Guid todoId) =>
(model with { Todos = model.Todos.Remove(todoId) }, Cmd.None);
// ...
For a simple project, you can expect your source tree to look something like this:
GlobalUsings.cs
Init.cs
Message.cs
Model.cs
Program.cs
Subscriptions.cs
Update.cs
View.cs
I hope you enjoy using ElmSharp, as much as I enjoyed creating it.
Made by someone who โฅ building things.