Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 25 additions & 44 deletions equinox-web-csharp/Domain/Aggregate.cs
Original file line number Diff line number Diff line change
@@ -1,55 +1,48 @@
using Microsoft.FSharp.Core;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace TodoBackendTemplate;

public static class Aggregate
{
public interface IEvent { }
/// NB - these types and names reflect the actual storage formats and hence need to be versioned with care
public abstract class Event
public static class Event
{
public class Happened : Event
{
}
public record Happened : IEvent { }
public record Snapshotted(bool HasHappened) : IEvent;

public class Snapshotted : Event
{
public bool HasHappened { get; set; }
}
static readonly SystemTextJsonUtf8Codec Codec = new(new System.Text.Json.JsonSerializerOptions());

static readonly SystemTextJsonUtf8Codec Codec = new(new());

public static FSharpValueOption<Event> TryDecode(string et, ReadOnlyMemory<byte> json) =>
public static IEvent? TryDecode(string et, ReadOnlyMemory<byte> json) =>
et switch
{
nameof(Happened) => Codec.Decode<Happened>(json),
nameof(Snapshotted) => Codec.Decode<Snapshotted>(json),
_ => FSharpValueOption<Event>.None
_ => null
};

public static (string, ReadOnlyMemory<byte>) Encode(Event e) => (e.GetType().Name, Codec.Encode(e));
public const string Category = "Aggregate";
public static (string, ReadOnlyMemory<byte>) Encode(IEvent e) => (e.GetType().Name, Codec.Encode(e));
public const string Category = "Aggregate";
public static string StreamId(ClientId id) => id.ToString();
}

public class State
{
public bool Happened { get; set; }

State(bool happened) { Happened = happened; }

public static readonly State Initial = new (false);
public static readonly State Initial = new(false);

static void Evolve(State s, Event x) =>
static void Evolve(State s, IEvent x) =>
s.Happened = x switch
{
Event.Happened => true,
Event.Snapshotted e => e.HasHappened,
_ => throw new ArgumentOutOfRangeException(nameof(x), x, "invalid")
};

public static State Fold(State origin, IEnumerable<Event> xs)
public static State Fold(State origin, IEnumerable<IEvent> xs)
{
// NB Fold must not mutate the origin
var s = new State(origin.Happened);
Expand All @@ -58,48 +51,36 @@ public static State Fold(State origin, IEnumerable<Event> xs)
return s;
}

public static bool IsOrigin(Event e) => e is Event.Snapshotted;
public static Event Snapshot(State s) => new Event.Snapshotted {HasHappened = s.Happened};
public static bool IsOrigin(IEvent e) => e is Event.Snapshotted;

public static IEvent Snapshot(State s) => new Event.Snapshotted(s.Happened);
}

/// Defines the decision process which maps from the intent of the `Command` to the `Event`s that represent that decision in the Stream
/// Defines the decision process which maps from the intent of the `Command` to the `Event`s that represent that decision in the Stream
public abstract class Command
{
public class MakeItSo : Command
{
}
public class MakeItSo : Command { }

public Event[] Interpret(State s) => this switch
public IEvent[] Interpret(State s) => this switch
{
MakeItSo =>
s.Happened
? Array.Empty<Event>()
: new Event[] { new Event.Happened() },
MakeItSo => s.Happened ? [] : [new Event.Happened()],
_ => throw new ArgumentOutOfRangeException(nameof(Command), this, "invalid")
};
}

public record View(bool Sorted);

public class Service
public class Service(Func<ClientId, Equinox.DeciderCore<IEvent, State>> resolve)
{
/// Maps a ClientId to Handler for the relevant stream
readonly Func<ClientId, Equinox.DeciderCore<Event, State>> _resolve;

public Service(Func<ClientId, Equinox.DeciderCore<Event, State>> resolve) =>
_resolve = resolve;

/// Execute the specified command
/// Execute the specified command
public Task<Unit> Execute(ClientId id, Command command) =>
_resolve(id).Transact(command.Interpret);
resolve(id).Transact(command.Interpret);

/// Read the present state
// TOCONSIDER: you should probably be separating this out per CQRS and reading from a denormalized/cached set of projections
public Task<View> Read(ClientId id) =>
_resolve(id).Query(Render);
resolve(id).Query(Render);

static View Render(State s) =>
new (Sorted: s.Happened);
static View Render(State s) => new(Sorted: s.Happened);
}
}
29 changes: 3 additions & 26 deletions equinox-web-csharp/Domain/ClientId.cs
Original file line number Diff line number Diff line change
@@ -1,34 +1,11 @@
using System;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Runtime.Serialization;
using System.Runtime.Serialization;

namespace TodoBackendTemplate;

/// ClientId strongly typed id
// To support model binding using aspnetcore 2 FromHeader
[TypeConverter(typeof(ClientIdStringConverter))]
public class ClientId
public class ClientId(Guid value)
{
ClientId(Guid value) => Value = value;

[IgnoreDataMember] // Prevent Swashbuckle inferring there is a Value property
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
public Guid Value { get; }

// TOCONSIDER - happy for this to become a ctor and ClientIdStringConverter to be removed if it just works correctly as-is
// when this type is used to Bind to a HTTP Request header
public static ClientId Parse(string input) => new(Guid.Parse(input));

public Guid Value { get; } = value;
public override string ToString() => Value.ToString("N");
}

class ClientIdStringConverter : TypeConverter
{
public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType) =>
sourceType == typeof(string) || base.CanConvertFrom(context, sourceType);

public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value) =>
value is string s ? ClientId.Parse(s) : base.ConvertFrom(context, culture, value);
}
5 changes: 3 additions & 2 deletions equinox-web-csharp/Domain/Domain.csproj
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>

Copilot AI Apr 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

<TargetFramework>net10.0</TargetFramework> is likely incompatible with the repo’s pinned SDK (global.json uses .NET SDK 8.0.100). Unless the repo is moving to a .NET 10 toolchain, this will break template build validation. Consider targeting net8.0 and configuring C# language version explicitly if needed.

Suggested change
<TargetFramework>net10.0</TargetFramework>
<TargetFramework>net8.0</TargetFramework>

Copilot uses AI. Check for mistakes.
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<WarningLevel>5</WarningLevel>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Equinox" Version="4.1.0" />
<PackageReference Include="FsCodec.SystemTextJson" Version="3.1.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
</ItemGroup>

</Project>
17 changes: 6 additions & 11 deletions equinox-web-csharp/Domain/Infrastructure.cs
Original file line number Diff line number Diff line change
@@ -1,16 +1,11 @@
using System;

namespace TodoBackendTemplate;

/// System.Text.Json implementation of IEncoder that encodes direct to a UTF-8 Buffer
public class SystemTextJsonUtf8Codec
public class SystemTextJsonUtf8Codec(TypeShape.UnionContract.IEncoder<ReadOnlyMemory<byte>> codec)
{
readonly TypeShape.UnionContract.IEncoder<ReadOnlyMemory<byte>> _codec;

public SystemTextJsonUtf8Codec(System.Text.Json.JsonSerializerOptions options) =>
_codec = new FsCodec.SystemTextJson.Core.ReadOnlyMemoryEncoder(new FsCodec.SystemTextJson.Serdes(options));

public ReadOnlyMemory<byte> Encode<T>(T value) => _codec.Encode(value);

public T Decode<T>(ReadOnlyMemory<byte> json) => _codec.Decode<T>(json);
SystemTextJsonUtf8Codec(FsCodec.SystemTextJson.Serdes serdes) : this(new FsCodec.SystemTextJson.Core.ReadOnlyMemoryEncoder(serdes)) { }
public SystemTextJsonUtf8Codec(System.Text.Json.JsonSerializerOptions options) : this(new FsCodec.SystemTextJson.Serdes(options)) { }
public ReadOnlyMemory<byte> Encode(object value) => EncodeTyped(value);
public ReadOnlyMemory<byte> EncodeTyped<T>(T value) => codec.Encode(value);
public T Decode<T>(ReadOnlyMemory<byte> json) => codec.Decode<T>(json);
}
Loading
Loading