├── .gitignore ├── ComplexProjections ├── ComplexProjections.csproj ├── Misc.cs ├── Program.cs ├── Properties │ └── launchSettings.json ├── appsettings.Development.json └── appsettings.json ├── Core ├── Core.csproj ├── Events.cs ├── Order.cs └── playground.linq ├── EventSourcing.sln ├── README.md ├── Snapshotter ├── Linqpad │ ├── listen.linq │ ├── read.linq │ └── write.linq ├── Misc.cs ├── Program.cs ├── Properties │ └── launchSettings.json ├── Snapshotter.csproj ├── appsettings.Development.json ├── appsettings.json └── setup.sql └── WhenToSnapshot ├── Misc.cs ├── Program.cs ├── Properties └── launchSettings.json ├── WhenToSnapshot.csproj ├── appsettings.Development.json └── appsettings.json /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | bin/ 3 | obj/ -------------------------------------------------------------------------------- /ComplexProjections/ComplexProjections.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /ComplexProjections/Misc.cs: -------------------------------------------------------------------------------- 1 | namespace ComplexProjections; 2 | 3 | # region BASE 4 | 5 | public record Event; 6 | 7 | public interface IStore 8 | { 9 | T GetAsync(string id) where T : class; 10 | T ListAsync(string[] ids) where T : class; 11 | List AllAsync() where T : class; 12 | Task Update(T v) where T : class; 13 | } 14 | 15 | # endregion 16 | 17 | #region Hard Read 18 | 19 | public record Posted : Event; 20 | 21 | public record UpdatedAvatar : Event; 22 | 23 | public record Post(string Id, string Content, string UserId); 24 | 25 | public record User(string Id, string Username, string Avatar); 26 | 27 | #endregion 28 | 29 | #region Hard Write 30 | 31 | public record Created : Event; 32 | 33 | public record Viewed : Event; 34 | 35 | public record Commented : Event; 36 | 37 | public record Preview(string Id, string Title, int Views); 38 | 39 | public record Full(string Id, string Title, string Description); 40 | 41 | public record AdminPreview(string Id, string Title, string Description, int Status, int Comments); 42 | 43 | #endregion -------------------------------------------------------------------------------- /ComplexProjections/Program.cs: -------------------------------------------------------------------------------- 1 | using ComplexProjections; 2 | 3 | var builder = WebApplication.CreateBuilder(args); 4 | var app = builder.Build(); 5 | 6 | #region Hard Read 7 | 8 | app.MapGet("/posts", (IStore store) => 9 | { 10 | var posts = store.AllAsync(); 11 | var users = store.ListAsync(posts.Select(x => x.UserId).ToArray()); 12 | return new { posts, users }; 13 | }); 14 | 15 | #endregion 16 | 17 | #region Hard Write 18 | 19 | app.MapPost("/comment", async (string videoId, string comment, IStore store) => 20 | { 21 | var e = new Commented(); 22 | // projections to update when event ... 23 | 24 | 25 | var adminPreview = store.GetAsync(videoId); 26 | adminPreview = adminPreview with { Comments = adminPreview.Comments + 1 }; 27 | await store.Update(adminPreview); 28 | }); 29 | 30 | #endregion 31 | 32 | app.Run(); -------------------------------------------------------------------------------- /ComplexProjections/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "iisSettings": { 3 | "windowsAuthentication": false, 4 | "anonymousAuthentication": true, 5 | "iisExpress": { 6 | "applicationUrl": "http://localhost:39331", 7 | "sslPort": 44358 8 | } 9 | }, 10 | "profiles": { 11 | "ComplexProjections": { 12 | "commandName": "Project", 13 | "dotnetRunMessages": true, 14 | "launchBrowser": true, 15 | "applicationUrl": "https://localhost:7178;http://localhost:5178", 16 | "environmentVariables": { 17 | "ASPNETCORE_ENVIRONMENT": "Development" 18 | } 19 | }, 20 | "IIS Express": { 21 | "commandName": "IISExpress", 22 | "launchBrowser": true, 23 | "environmentVariables": { 24 | "ASPNETCORE_ENVIRONMENT": "Development" 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /ComplexProjections/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /ComplexProjections/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "AllowedHosts": "*" 9 | } 10 | -------------------------------------------------------------------------------- /Core/Core.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /Core/Events.cs: -------------------------------------------------------------------------------- 1 | namespace Core; 2 | 3 | public record Event 4 | { 5 | public string Name => GetType().Name; 6 | } 7 | 8 | 9 | public record AddedCart(string UserId) : Event; 10 | public record AddedItemToCart(int ProductId, int Qty) : Event; 11 | public record RemovedItemFromCart(int ProductId, int Qty) : Event; 12 | public record AddedShippingInformationCart(string Address, string PhoneNumber) : Event; -------------------------------------------------------------------------------- /Core/Order.cs: -------------------------------------------------------------------------------- 1 | namespace Core; 2 | 3 | public class Order 4 | { 5 | public string UserId { get; set; } 6 | public List Products { get; set; } = new(); 7 | public string Address { get; set; } 8 | public string PhoneNumber { get; set; } 9 | } 10 | 11 | public class Product 12 | { 13 | public int Id { get; set; } 14 | } -------------------------------------------------------------------------------- /Core/playground.linq: -------------------------------------------------------------------------------- 1 | 2 | D:\WS\Rider\EventSourcing\Core\bin\Debug\net6.0\Core.dll 3 | Core 4 | 5 | 6 | void Main() 7 | { 8 | var events = new List { 9 | new AddedCart("userId"), 10 | new AddedItemToCart(1, 2), 11 | new AddedItemToCart(7, 1), 12 | new AddedItemToCart(9, 2), 13 | new AddedShippingInformationCart("42 Road Lane", "12343218764"), 14 | }; 15 | 16 | var orderAggregate = (Order order, Event e) => 17 | { 18 | if (e is AddedCart ac) 19 | { 20 | order.UserId = ac.UserId; 21 | } 22 | else if (e is AddedItemToCart ai) 23 | { 24 | for (int i = 0; i < ai.Qty; i++) 25 | { 26 | order.Products.Add(new Product() { Id = ai.ProductId }); 27 | } 28 | } 29 | else if (e is RemovedItemFromCart ri) 30 | { 31 | for (int i = 0; i < ri.Qty; i++) 32 | { 33 | var productToRemove = order.Products.FirstOrDefault(x => x.Id == ri.ProductId); 34 | order.Products.Remove(productToRemove); 35 | } 36 | } 37 | else if (e is AddedShippingInformationCart asi) 38 | { 39 | order.Address = asi.Address; 40 | order.PhoneNumber = asi.PhoneNumber; 41 | } 42 | return order; 43 | }; 44 | 45 | var order1 = events.Aggregate(new Order(), orderAggregate); 46 | 47 | order1.Dump(); 48 | 49 | 50 | var events2 = new List { 51 | new RemovedItemFromCart(1, 1), 52 | }; 53 | 54 | var order2 = events2.Aggregate(order1, orderAggregate); 55 | 56 | order2.Dump(); 57 | } -------------------------------------------------------------------------------- /EventSourcing.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core", "Core\Core.csproj", "{2C500A8E-1BBA-4C19-8287-B6169247E4E7}" 4 | EndProject 5 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WhenToSnapshot", "WhenToSnapshot\WhenToSnapshot.csproj", "{76E6A6DA-441B-4A81-8C02-71B5C17CA223}" 6 | EndProject 7 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ComplexProjections", "ComplexProjections\ComplexProjections.csproj", "{FECE9566-14EC-4FFD-887E-338942FCD547}" 8 | EndProject 9 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Snapshotter", "Snapshotter\Snapshotter.csproj", "{52F29BA1-08D4-41DA-9F21-CD198B6625BC}" 10 | EndProject 11 | Global 12 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 13 | Debug|Any CPU = Debug|Any CPU 14 | Release|Any CPU = Release|Any CPU 15 | EndGlobalSection 16 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 17 | {2C500A8E-1BBA-4C19-8287-B6169247E4E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 18 | {2C500A8E-1BBA-4C19-8287-B6169247E4E7}.Debug|Any CPU.Build.0 = Debug|Any CPU 19 | {2C500A8E-1BBA-4C19-8287-B6169247E4E7}.Release|Any CPU.ActiveCfg = Release|Any CPU 20 | {2C500A8E-1BBA-4C19-8287-B6169247E4E7}.Release|Any CPU.Build.0 = Release|Any CPU 21 | {76E6A6DA-441B-4A81-8C02-71B5C17CA223}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 22 | {76E6A6DA-441B-4A81-8C02-71B5C17CA223}.Debug|Any CPU.Build.0 = Debug|Any CPU 23 | {76E6A6DA-441B-4A81-8C02-71B5C17CA223}.Release|Any CPU.ActiveCfg = Release|Any CPU 24 | {76E6A6DA-441B-4A81-8C02-71B5C17CA223}.Release|Any CPU.Build.0 = Release|Any CPU 25 | {FECE9566-14EC-4FFD-887E-338942FCD547}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 26 | {FECE9566-14EC-4FFD-887E-338942FCD547}.Debug|Any CPU.Build.0 = Debug|Any CPU 27 | {FECE9566-14EC-4FFD-887E-338942FCD547}.Release|Any CPU.ActiveCfg = Release|Any CPU 28 | {FECE9566-14EC-4FFD-887E-338942FCD547}.Release|Any CPU.Build.0 = Release|Any CPU 29 | {52F29BA1-08D4-41DA-9F21-CD198B6625BC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 30 | {52F29BA1-08D4-41DA-9F21-CD198B6625BC}.Debug|Any CPU.Build.0 = Debug|Any CPU 31 | {52F29BA1-08D4-41DA-9F21-CD198B6625BC}.Release|Any CPU.ActiveCfg = Release|Any CPU 32 | {52F29BA1-08D4-41DA-9F21-CD198B6625BC}.Release|Any CPU.Build.0 = Release|Any CPU 33 | EndGlobalSection 34 | EndGlobal 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Video: https://youtu.be/EGYMNsI_Opo -------------------------------------------------------------------------------- /Snapshotter/Linqpad/listen.linq: -------------------------------------------------------------------------------- 1 | 2 | D:\WS\Rider\EventSourcing\Snapshotter\bin\Debug\net6.0\Npgsql.dll 3 | D:\WS\Rider\EventSourcing\Snapshotter\bin\Debug\net6.0\Snapshotter.dll 4 | Npgsql 5 | System.Threading.Tasks 6 | System.Text.Json 7 | 8 | 9 | // https://www.npgsql.org/doc/wait.html 10 | // https://www.postgresql.org/docs/9.1/sql-listen.html 11 | async Task Main() 12 | { 13 | await using var conn = new NpgsqlConnection(Connection.ConnectionString); 14 | await conn.OpenAsync(); 15 | conn.Notification += async (o, e) => 16 | { 17 | await HandleEvent(new Guid(e.Payload)); 18 | }; 19 | 20 | await using (var cmd = new NpgsqlCommand($"LISTEN {Connection.NewEventChannel}", conn)) 21 | { 22 | cmd.ExecuteNonQuery(); 23 | } 24 | 25 | while (true) 26 | { 27 | await conn.WaitAsync(); 28 | "Recieved Event!".Dump("Recieved Event!"); 29 | } 30 | } 31 | 32 | public async Task HandleEvent(Guid eventId) 33 | { 34 | await using var conn = new NpgsqlConnection(Connection.ConnectionString); 35 | await conn.OpenAsync(); 36 | 37 | await using var cmd = new NpgsqlCommand($"SELECT * FROM events where Id = '{eventId}'", conn); 38 | await using var reader = await cmd.ExecuteReaderAsync(); 39 | 40 | await reader.ReadAsync(); 41 | var payload = reader.GetString(1); 42 | var type = reader.GetString(2); 43 | 44 | var obj = Payload.Parse(type, payload); 45 | obj.Dump(); 46 | 47 | await reader.DisposeAsync(); 48 | await cmd.DisposeAsync(); 49 | 50 | var userId = "c31e05e7-f966-46a7-9bb8-eb0ca3bcc95a"; 51 | await using var cmd2 = new NpgsqlCommand($"SELECT payload FROM projections where Id = '{userId}' and type = 'Cart'", conn); 52 | await using var reader2 = await cmd2.ExecuteReaderAsync(); 53 | 54 | var exists = await reader2.ReadAsync(); 55 | var cart = exists ? JsonSerializer.Deserialize(reader2.GetString(0)) : new Cart(); 56 | 57 | await reader2.DisposeAsync(); 58 | await cmd2.DisposeAsync(); 59 | 60 | if (obj is AddedCart ac) 61 | { 62 | cart.UserId = ac.UserId; 63 | } 64 | else if (obj is AddedToCart atc) 65 | { 66 | cart.Products.Add(new Product 67 | { 68 | Id = atc.ProductId, 69 | Name = atc.ProductName, 70 | Qty = atc.Qty 71 | }); 72 | } 73 | else if (obj is AddedShippingInformationCart si) 74 | { 75 | cart.Address = si.Address; 76 | cart.PhoneNumber = si.PhoneNumber; 77 | } 78 | 79 | var newPayload = JsonSerializer.Serialize(cart); 80 | var upsertquery = $@" 81 | insert into projections values('{userId}', '{newPayload}', 'Cart') 82 | on conflict on constraint type_id 83 | do update set payload = '{newPayload}'; 84 | "; 85 | await using var cmd3 = new NpgsqlCommand(upsertquery, conn); 86 | await cmd3.ExecuteNonQueryAsync(); 87 | await cmd3.DisposeAsync(); 88 | } 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | -------------------------------------------------------------------------------- /Snapshotter/Linqpad/read.linq: -------------------------------------------------------------------------------- 1 | 2 | D:\WS\Rider\EventSourcing\Snapshotter\bin\Debug\net6.0\Npgsql.dll 3 | D:\WS\Rider\EventSourcing\Snapshotter\bin\Debug\net6.0\Snapshotter.dll 4 | Npgsql 5 | System.Threading.Tasks 6 | 7 | 8 | // https://www.npgsql.org/doc/basic-usage.html 9 | async Task Main() 10 | { 11 | await using var conn = new NpgsqlConnection(Connection.ConnectionString); 12 | await conn.OpenAsync(); 13 | 14 | 15 | var userId = "c31e05e7-f966-46a7-9bb8-eb0ca3bcc95a"; 16 | await using var cmd2 = new NpgsqlCommand($"SELECT payload FROM projections where Id = '{userId}' and type = 'Cart'", conn); 17 | await using var reader2 = await cmd2.ExecuteReaderAsync(); 18 | 19 | var exists = await reader2.ReadAsync(); 20 | exists.Dump(); 21 | } -------------------------------------------------------------------------------- /Snapshotter/Linqpad/write.linq: -------------------------------------------------------------------------------- 1 | 2 | D:\WS\Rider\EventSourcing\Snapshotter\bin\Debug\net6.0\Npgsql.dll 3 | D:\WS\Rider\EventSourcing\Snapshotter\bin\Debug\net6.0\Snapshotter.dll 4 | Npgsql 5 | System.Threading.Tasks 6 | 7 | 8 | // https://www.postgresql.org/docs/current/sql-notify.html 9 | // https://www.npgsql.org/doc/basic-usage.html 10 | async Task Main() 11 | { 12 | var userId = "c31e05e7-f966-46a7-9bb8-eb0ca3bcc95a"; 13 | //var e = Event.Create(new AddedCart(userId)); 14 | //var e = Event.Create(new AddedShippingInformationCart("Road Street", "999")); 15 | var e = Event.Create(new AddedToCart(2, "Table 3000", 1)); 16 | 17 | await using var conn = new NpgsqlConnection(Connection.ConnectionString); 18 | await conn.OpenAsync(); 19 | 20 | var q = $"INSERT INTO events VALUES ('{e.Id}','{e.Payload}','{e.Type}')"; 21 | await using var batch = new NpgsqlBatch(conn) 22 | { 23 | BatchCommands = 24 | { 25 | new(q), 26 | new($"NOTIFY {Connection.NewEventChannel}, '{e.Id}'") 27 | } 28 | }; 29 | 30 | await using var reader = await batch.ExecuteReaderAsync(); 31 | } -------------------------------------------------------------------------------- /Snapshotter/Misc.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using System.Text.Json.Serialization; 3 | 4 | public class Connection 5 | { 6 | public const string ConnectionString = "host=127.0.0.1;port=5666;database=snapshotter;user id=postgres;password=password;"; 7 | public const string NewEventChannel = "new_events"; 8 | } 9 | 10 | public class Event 11 | { 12 | public Guid Id { get; set; } 13 | public string Payload { get; set; } 14 | public string Type { get; set; } 15 | 16 | public static Event Create(object payload) 17 | { 18 | return new() 19 | { 20 | Id = Guid.NewGuid(), 21 | Payload = JsonSerializer.Serialize(payload), 22 | Type = payload.GetType().Name, 23 | }; 24 | } 25 | } 26 | 27 | public class Payload 28 | { 29 | public static object Parse(string type, string json) => 30 | type switch 31 | { 32 | "AddedCart" => JsonSerializer.Deserialize(json), 33 | "AddedToCart" => JsonSerializer.Deserialize(json), 34 | "AddedShippingInformationCart" => JsonSerializer.Deserialize(json), 35 | }; 36 | } 37 | 38 | public record AddedCart(string UserId); 39 | public record AddedToCart(int ProductId, string ProductName, int Qty); 40 | public record AddedShippingInformationCart(string Address, string PhoneNumber); 41 | 42 | public class Cart 43 | { 44 | public string UserId { get; set; } 45 | public List Products { get; set; } = new(); 46 | public string Address { get; set; } 47 | public string PhoneNumber { get; set; } 48 | } 49 | 50 | public class Product 51 | { 52 | public int Id { get; set; } 53 | public string Name { get; set; } 54 | public int Qty { get; set; } 55 | } -------------------------------------------------------------------------------- /Snapshotter/Program.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using Npgsql; 3 | 4 | var builder = WebApplication.CreateBuilder(args); 5 | builder.Services.AddHostedService(); 6 | var app = builder.Build(); 7 | 8 | app.MapGet("/", async () => 9 | { 10 | await using var conn = new NpgsqlConnection(Connection.ConnectionString); 11 | await conn.OpenAsync(); 12 | 13 | var userId = "c31e05e7-f966-46a7-9bb8-eb0ca3bcc95a"; 14 | await using var cmd2 = new NpgsqlCommand($"SELECT payload FROM projections where Id = '{userId}' and type = 'Cart'", conn); 15 | await using var reader2 = await cmd2.ExecuteReaderAsync(); 16 | 17 | var exists = await reader2.ReadAsync(); 18 | return exists ? reader2.GetString(0) : ""; 19 | }); 20 | 21 | app.MapPost("/{type}", async (string type) => 22 | { 23 | var userId = "c31e05e7-f966-46a7-9bb8-eb0ca3bcc95a"; 24 | var e = type switch 25 | { 26 | "AddedCart" => Event.Create(new AddedCart(userId)), 27 | "AddedShippingInformationCart" => Event.Create(new AddedShippingInformationCart("Road Street", "999")), 28 | "Table" => Event.Create(new AddedToCart(2, "Table 3000", 1)), 29 | _ => Event.Create(new AddedToCart(1, "Anything", 3)), 30 | }; 31 | 32 | await using var conn = new NpgsqlConnection(Connection.ConnectionString); 33 | await conn.OpenAsync(); 34 | 35 | var q = $"INSERT INTO events VALUES ('{e.Id}','{e.Payload}','{e.Type}')"; 36 | await using var batch = new NpgsqlBatch(conn) 37 | { 38 | BatchCommands = 39 | { 40 | new(q), 41 | new($"NOTIFY {Connection.NewEventChannel}, '{e.Id}'") 42 | } 43 | }; 44 | 45 | await using var reader = await batch.ExecuteReaderAsync(); 46 | }); 47 | 48 | app.Run(); 49 | 50 | public class Projector : BackgroundService 51 | { 52 | private readonly ILogger _logger; 53 | 54 | public Projector(ILogger logger) 55 | { 56 | _logger = logger; 57 | } 58 | 59 | protected override async Task ExecuteAsync(CancellationToken stoppingToken) 60 | { 61 | _logger.LogInformation("projector started!!!"); 62 | await using var conn = new NpgsqlConnection(Connection.ConnectionString); 63 | await conn.OpenAsync(); 64 | while (true) 65 | { 66 | await using var cmd2 = new NpgsqlCommand($"SELECT pg_try_advisory_lock(1)", conn); 67 | await using var reader2 = await cmd2.ExecuteReaderAsync(); 68 | 69 | await reader2.ReadAsync(); 70 | var gotLock = reader2.GetBoolean(0); 71 | if (gotLock) 72 | { 73 | _logger.LogInformation("Lock Acquired!!!"); 74 | break; 75 | } 76 | 77 | _logger.LogInformation("Lock Busy..."); 78 | await Task.Delay(5000); 79 | } 80 | 81 | conn.Notification += async (o, e) => 82 | { 83 | _logger.LogInformation($"processing {e.Payload}"); 84 | await HandleEvent(new Guid(e.Payload)); 85 | }; 86 | 87 | await using (var cmd = new NpgsqlCommand($"LISTEN {Connection.NewEventChannel}", conn)) 88 | { 89 | cmd.ExecuteNonQuery(); 90 | } 91 | 92 | while (true) 93 | { 94 | await conn.WaitAsync(); 95 | } 96 | } 97 | 98 | public async Task HandleEvent(Guid eventId) 99 | { 100 | await using var conn = new NpgsqlConnection(Connection.ConnectionString); 101 | await conn.OpenAsync(); 102 | 103 | await using var cmd = new NpgsqlCommand($"SELECT * FROM events where Id = '{eventId}'", conn); 104 | await using var reader = await cmd.ExecuteReaderAsync(); 105 | 106 | await reader.ReadAsync(); 107 | var payload = reader.GetString(1); 108 | var type = reader.GetString(2); 109 | 110 | var obj = Payload.Parse(type, payload); 111 | 112 | await reader.DisposeAsync(); 113 | await cmd.DisposeAsync(); 114 | 115 | var userId = "c31e05e7-f966-46a7-9bb8-eb0ca3bcc95a"; 116 | await using var cmd2 = new NpgsqlCommand($"SELECT payload FROM projections where Id = '{userId}' and type = 'Cart'", conn); 117 | await using var reader2 = await cmd2.ExecuteReaderAsync(); 118 | 119 | var exists = await reader2.ReadAsync(); 120 | var cart = exists ? JsonSerializer.Deserialize(reader2.GetString(0)) : new Cart(); 121 | 122 | await reader2.DisposeAsync(); 123 | await cmd2.DisposeAsync(); 124 | 125 | if (obj is AddedCart ac) 126 | { 127 | cart.UserId = ac.UserId; 128 | } 129 | else if (obj is AddedToCart atc) 130 | { 131 | cart.Products.Add(new Product 132 | { 133 | Id = atc.ProductId, 134 | Name = atc.ProductName, 135 | Qty = atc.Qty 136 | }); 137 | } 138 | else if (obj is AddedShippingInformationCart si) 139 | { 140 | cart.Address = si.Address; 141 | cart.PhoneNumber = si.PhoneNumber; 142 | } 143 | 144 | var newPayload = JsonSerializer.Serialize(cart); 145 | var upsertquery = $@" 146 | insert into projections values('{userId}', '{newPayload}', 'Cart') 147 | on conflict on constraint type_id 148 | do update set payload = '{newPayload}'; 149 | "; 150 | await using var cmd3 = new NpgsqlCommand(upsertquery, conn); 151 | await cmd3.ExecuteNonQueryAsync(); 152 | await cmd3.DisposeAsync(); 153 | } 154 | } -------------------------------------------------------------------------------- /Snapshotter/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "iisSettings": { 3 | "windowsAuthentication": false, 4 | "anonymousAuthentication": true, 5 | "iisExpress": { 6 | "applicationUrl": "http://localhost:8516", 7 | "sslPort": 44311 8 | } 9 | }, 10 | "profiles": { 11 | "Snapshotter": { 12 | "commandName": "Project", 13 | "dotnetRunMessages": true, 14 | "launchBrowser": true, 15 | "applicationUrl": "https://localhost:7034;http://localhost:5034", 16 | "environmentVariables": { 17 | "ASPNETCORE_ENVIRONMENT": "Development" 18 | } 19 | }, 20 | "IIS Express": { 21 | "commandName": "IISExpress", 22 | "launchBrowser": true, 23 | "environmentVariables": { 24 | "ASPNETCORE_ENVIRONMENT": "Development" 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Snapshotter/Snapshotter.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /Snapshotter/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Snapshotter/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "AllowedHosts": "*" 9 | } 10 | -------------------------------------------------------------------------------- /Snapshotter/setup.sql: -------------------------------------------------------------------------------- 1 | -- https://www.postgresql.org/docs/14/sql-createtable.html 2 | create database snapshotter; 3 | 4 | create table events 5 | ( 6 | id uuid primary key, 7 | event jsonb, 8 | type varchar 9 | ); 10 | 11 | create table projections 12 | ( 13 | id uuid, 14 | payload jsonb, 15 | type varchar, 16 | constraint type_id primary key(type, id) 17 | ); 18 | -------------------------------------------------------------------------------- /WhenToSnapshot/Misc.cs: -------------------------------------------------------------------------------- 1 | namespace WhenToSnapshot; 2 | 3 | public class Event 4 | { 5 | public string Id { get; set; } 6 | public int Version { get; set; } 7 | } 8 | 9 | public class Projection 10 | { 11 | public static Projection New => new(); 12 | public int Version { get; set; } 13 | 14 | public static Projection Append(Projection seed, Event @event) 15 | { 16 | return null; 17 | } 18 | } 19 | 20 | public interface IStore 21 | { 22 | Task GetDoc(string id); 23 | Task GetDocs(string type); 24 | Task InsertDoc(string id, Projection data); 25 | 26 | Task GetEvents(string id); 27 | Task GetEvents(string id, int fromVersion); 28 | 29 | void UpsertDoc(string id, Projection data); 30 | void Append(string aggregateId, params Event[] e); 31 | Task SaveChangesAsync(); 32 | } 33 | 34 | public interface IQueue 35 | { 36 | Task PublishAsync(Event e); 37 | } -------------------------------------------------------------------------------- /WhenToSnapshot/Program.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc.ModelBinding; 2 | using WhenToSnapshot; 3 | 4 | var builder = WebApplication.CreateBuilder(args); 5 | var app = builder.Build(); 6 | 7 | app.MapGet("/", async (IStore store) => 8 | { 9 | var events = await store.GetEvents("id"); 10 | return events.Aggregate(Projection.New, Projection.Append); 11 | }); 12 | 13 | app.MapGet("/stale", async (IStore store) => 14 | { 15 | Projection projection = await store.GetDoc("id"); 16 | return projection; 17 | }); 18 | 19 | app.MapGet("/live", async (IStore store) => 20 | { 21 | Projection projection = await store.GetDoc("id"); 22 | var events = await store.GetEvents("id", projection.Version); 23 | if (events.Any()) 24 | { 25 | return events.Aggregate(projection, Projection.Append); 26 | } 27 | return projection; 28 | }); 29 | 30 | app.MapGet("/live-read-through", async (IStore store) => 31 | { 32 | Projection projection = await store.GetDoc("id"); 33 | var events = await store.GetEvents("id", projection.Version); 34 | if (events.Any()) 35 | { 36 | projection = events.Aggregate(projection, Projection.Append); 37 | await store.InsertDoc("id", projection); 38 | return projection; 39 | } 40 | return projection; 41 | }); 42 | 43 | 44 | app.MapPost("/write-update", async (IStore store) => 45 | { 46 | Projection projection = await store.GetDoc("id"); 47 | 48 | var newEvents = new Event[] { new(), new() }; 49 | store.Append("id", newEvents); 50 | 51 | projection = newEvents.Aggregate(projection, Projection.Append); 52 | store.UpsertDoc("id", projection); 53 | 54 | await store.SaveChangesAsync(); 55 | return "id"; 56 | }); 57 | 58 | 59 | app.MapPost("/async", async (IStore store, IQueue queue) => 60 | { 61 | var newEvents = new Event[] { new(), new() }; 62 | store.Append("id", newEvents); 63 | 64 | await store.SaveChangesAsync(); 65 | 66 | foreach (var e in newEvents) 67 | { 68 | await queue.PublishAsync(e); 69 | } 70 | 71 | return "id"; 72 | }); 73 | 74 | 75 | app.MapPost("/async-better", async (IStore store, IQueue queue) => 76 | { 77 | var newEvents = new Event[] { new(), new() }; 78 | store.Append("id", newEvents); 79 | await store.SaveChangesAsync(); 80 | return "id"; 81 | }); 82 | 83 | app.Run(); -------------------------------------------------------------------------------- /WhenToSnapshot/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "iisSettings": { 3 | "windowsAuthentication": false, 4 | "anonymousAuthentication": true, 5 | "iisExpress": { 6 | "applicationUrl": "http://localhost:65015", 7 | "sslPort": 44398 8 | } 9 | }, 10 | "profiles": { 11 | "WhenToSnapshot": { 12 | "commandName": "Project", 13 | "dotnetRunMessages": true, 14 | "launchBrowser": true, 15 | "applicationUrl": "https://localhost:7297;http://localhost:5297", 16 | "environmentVariables": { 17 | "ASPNETCORE_ENVIRONMENT": "Development" 18 | } 19 | }, 20 | "IIS Express": { 21 | "commandName": "IISExpress", 22 | "launchBrowser": true, 23 | "environmentVariables": { 24 | "ASPNETCORE_ENVIRONMENT": "Development" 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /WhenToSnapshot/WhenToSnapshot.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | enable 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /WhenToSnapshot/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /WhenToSnapshot/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "AllowedHosts": "*" 9 | } 10 | --------------------------------------------------------------------------------