Production Ready Event Sourcing in .NET
Nick Chapsas · 2025-03-05 · 16м 53с · 66 379 просмотров · YouTube ↗
Топики: durable-execution
🎧 Аудио
📝 Summary
model=deepseek-v4-flash · prompt=summary-v7 · 5 912→1 876 tokens · 2026-07-20 15:08:17
🎯 Главная суть
Martin — библиотека для event sourcing в .NET, работающая поверх PostgreSQL. Вместо хранения текущего состояния в строках таблицы, она сохраняет последовательность событий (event stream) и позволяет агрегировать их для получения актуального состояния или строить материализованные проекции для эффективного чтения. Подход даёт встроенный аудит, возможность «путешествовать во времени» и отказоустойчивость, при этом Postgres обрабатывает транзакции атомарно, что решает проблемы синхронизации между событиями и проекциями.
События как единственный источник истины
В event sourcing каждое изменение сущности — отдельное событие, которое никогда не удаляется и не изменяется, только добавляется в поток. Например, для заказа создаются события:
OrderCreated(содержит ID, название товара, адрес доставки)DeliveryAddressUpdatedOrderDispatchedOrderOutForDeliveryOrderDelivered
Каждое событие фиксирует момент в прошлом и может содержать собственные временные метки, если они не выводятся автоматически. Агрегация всех событий потока даёт текущее состояние объекта, а пересчёт с отсечением по времени — состояние на любую прошлую дату. Хранение всех событий дёшево (стоимость диска), дороги только вычисления при агрегации — поэтому используют проекции.
Настройка Martin в проекте
Установка пакета Martini в ASP.NET Core-приложение. В Program.cs добавляется:
builder.Services.AddMartini(options =>
{
options.Connection = "строка подключения к PostgreSQL";
options.Serializer = new Martini.Services.JsonNetSerializer(); // или System.Text.Json
if (builder.Environment.IsDevelopment())
options.AutoCreateSchemaObjects = AutoCreate.All;
});
Параметр AutoCreateSchemaObjects автоматически создаёт таблицы и индексы в БД при первом запуске — разработчику не нужно писать миграции. Martin использует нативный JSON-тип PostgreSQL для эффективного хранения тел событий.
Создание события и запись потока
Для записи нового события (например, создания заказа) нужно:
- Преобразовать входной запрос в объект события
OrderCreated. - Внедрить
IDocumentStore(основной интерфейс Martin для работы с данными). - Открыть лёгкую сессию (
IDocumentSession). - Запустить новый поток событий для сущности:
session.Events.StartStream<Order>(orderId, orderCreatedEvent). - Вызвать
session.SaveChangesAsync()— как в EF Core.
Если у события есть поле Id (GUID или строка), Martin использует его как идентификатор потока. После сохранения в БД появляются записи в таблицах events (JSON) и streams (связка потоков).
Чтение агрегированного состояния «на лету»
Базовый способ получить текущее состояние заказа — агрегация потока при каждом запросе:
var order = await session.Events.AggregateStreamAsync<Order>(orderId);
Чтобы это работало, нужно определить на классе Order метод Apply для каждого типа события:
public class Order
{
public Guid Id { get; set; }
public string ProductName { get; set; }
public string DeliveryAddress { get; set; }
// ...
public void Apply(OrderCreated ev)
{
Id = ev.Id; // уже известен из потока, но для наглядности
ProductName = ev.ProductName;
DeliveryAddress = ev.DeliveryAddress;
}
public void Apply(OrderDispatched ev) => DispatchedAt = ev.Timestamp;
// ...
}
AggregateStreamAsync загружает все события потока из БД и последовательно вызывает соответствующие Apply — получается объект Order. Минус: при каждом чтении выполняется полный пересчёт, что для частых запросов неэффективно.
Проекции — материализованные представления
Для быстрого чтения данных Martin поддерживает проекции (materialized views). Создаётся класс, реализующий SingleStreamProjection<Order>:
public class OrderProjection : SingleStreamProjection<Order>
{
public OrderProjection()
{
// Региструруем обработчики событий
ProjectEvent<OrderCreated>((order, ev) =>
{
order.ProductName = ev.ProductName;
order.DeliveryAddress = ev.DeliveryAddress;
});
ProjectEvent<OrderDispatched>((order, ev) => order.DispatchedAt = ev.Timestamp);
// ...
}
}
Проекцию нужно зарегистрировать в конфигурации Martin с указанием жизненного цикла:
options.Projections.Add<OrderProjection>(ProjectionLifecycle.Inline);
- Inline — проекция обновляется синхронно в той же транзакции, что и запись события. Если запись события откатится, проекция не обновится, и наоборот — атомарность гарантирована.
- Live — пересчитывается только по запросу (аналог
AggregateStreamAsync, но с кэшированием на уровне сессии). - Async — обновляется асинхронно в фоне, подходит для высокой нагрузки, но добавляет задержку консистентности.
После настройки чтение происходит через точное чтение (point read) из проекции:
var order = await session.LoadAsync<Order>(orderId); // читает из таблицы с материализованным состоянием
При этом не нужно агрегировать поток — данные уже готовы. Martin создаёт отдельную таблицу (например, mt_doc_order) для хранения проекций.
Обновление и добавление новых событий
Для дополнения существующего потока новыми событиями (например, смена адреса или отметка о доставке) используется метод Append:
session.Events.Append(orderId, new DeliveryAddressUpdated { NewAddress = "..." });
session.SaveChangesAsync();
Поток не нужно пересоздавать — просто добавляется новое событие. Martin автоматически обновит inline-проекцию в той же транзакции. Если же проекция async, то обновление произойдёт отдельно.
Получение списка всех заказов — через IQuerySession:
var orders = await session.Query<Order>().ToListAsync();
Запрос выполняется по таблице проекций, что существенно быстрее агрегации на лету.
Итог
Martin решает ключевые проблемы практического event sourcing в .NET: автоматическая синхронизация проекций с потоком событий через транзакции, встроенная поддержка PostgreSQL, минимальный код (без ручных миграций). Альтернативы (EventStore, DynamoDB/CosmosDB) либо плохо масштабируются в проде, либо не дают нативной транзакционности между событиями и проекциями. Библиотека подходит для аудита, аналитики временных рядов (сколько времени заказ был в сборке) и систем, где требуется полная история изменений.
📜 Transcript
en · 3 447 слов · 41 сегментов · clean
Показать текст транскрипта
Hello everybody, I'm Nick and in this video I'm going to show you how you can get started with event sourcing in .NET using arguably the best library to do this in .NET and that is Martin. Now Martin is unique in its way of doing event sourcing because usually you'd use an event sourcing specific database like EventStore which I don't recommend at all. I've used it in production at scale and it's terrible at scaling. And other companies like myself in the past who has implemented event sourcing in a payment system would use something like DynamoDB or CosmosDB because of the structure-less nature of it, the partitioning that comes with it, as well as the ability to have transactions, to build projections, and also a stream which you can listen for those changes and do things with it. However, Martin decided to use an RDBMS for it. Postgres, which is an excellent database, by the way, it's my recommended RDBMS as well. And it's using its features very, very smartly and very efficiently. Now, I've talked about event sourcing and the concept of event sourcing already. I'm going to put a link to that video over there in case you want to check that. However, here I'm going to show you how to implement the concept using Martin directly. I will explain what event sourcing is in principle. However, I will go straight into it assuming you're going to use this library. So let me show you what I have here. I have this API which doesn't really have any implementation yet. And all this API is representing is orders. I can create an order over here with this request. I can update the delivery address if I want. And the order ultimately looks like this. So it has the create order request, delivery update. request, then we have a dispatch call where you just say that the order has been dispatched, then you have the out for delivery, and then you have the delivered. So these endpoints update the state of the order as the order goes through its stages in the same way it would if you order something from something like Amazon, for example. Now, normally what you'd have is you'd have an RDBMS or any other database, and you just store the state. So you have an order object, and then you just update fields on that object on that entry in the database. However, event sourcing is different. In event sourcing, everything that happens to something... is an event. It is something that happened in a moment in time and it's true, meaning you never actually delete or update anything, you just create, you just append events in a sequence of flows. So you have the createOrder event, the updateDeliveryAddress event, the orderDispatched event, the orderOutForDelivery and so on. And then you would aggregate those events to see the current state. This allows you to do a few things, you can actually see exactly what happened on a given object, so it gives you like this built-in audit log of what happened as well as the ability to time travel and see what the state is at a given moment in time and ultimately because storage is cheap it's the compute that is expensive i will also show you how you don't have to recompute everything as you go with martin through projections again excellent library very feature rich and it does way more than i will show you here it has multi-tenancy it can do tons of things. I'm not sponsored by Martin in any way, but I just genuinely like the library. I don't like everything that Jasper FX does, the company behind it. However, this is an excellent product and I do recommend it for everyone. So like I said, everything is represented as events. So I'm going to show you the events first. What I'm just going to do is create an events class. I'm not saying you should do this, but I'm just going to put all of my events. in here so you can have a group of them in case you want to grab the code and play around with it. So here's an example of how the events would look. So we have this order created event where we have the id because we give it an id every time we create it. I'm using the new sort of create version 7 guides of dotnet 9. Now we have the product name and the delivery address. This is all really simplified. This model would be way more feature-rich actually and you have a product id here instead linking to a product but for the purpose of the demo, I'm going to oversimplify it just so you don't have to look at 15 different things at the same time. So this represents an order being created. This is the order being updated. The ID still refers to that order ID. Order dispatched, order ad for delivery, and then order delivered. So you see that we have these dates, which, by the way, because the events have a date in themselves, you don't have to have explicitly a date here unless that date is coming from somewhere else and you have to provide it. but I'm adding it just to show some state change. So now we have all the events here that represent different stages on the life cycle of that order. However, because I do want to represent that order object and not just events, I need to basically... aggregate into something, project into something, I'm going to also create an order class. Now by default my order class will just have this, the ID, product name, delivery address, and then the other times related to what happened to that order. And you can extrapolate things like has it been dispatched yet? Well, if date time is null, then it hasn't been dispatched. If out for delivery, then it's not out for delivery and so on. And then what I'm going to do is I'm going to introduce Martin in all of this. So Martin is just a library. You go ahead and you search for Martin. and you install it. And then we're going to go to program.cs and we're going to do a bit of wiring up. So what you need to say, just like any other library, you just say add Martin over here and you shouldn't just leave it like this. You need to configure it. So let's go ahead and go into options over here. The first thing you need to specify is a connection. I have a Postgres database here. I'm just going to run it in the background so I have a database running completely clean. So if I go over here, that's the only database running and then I'm going to go and just have the connection string. So this connects me to that database. Then I'm going to say explicitly that I want to use system.txt.json for serialization because ultimately that data will be stored as JSON and Postgres has a specific JSON type for its field so it's very optimized to store JSON objects. And then I'm going to say that, yeah, if this is in development, please create all the schema in the database for me so that we create all the tables and everything needed for Postgres' wiring app. If I go ahead and I refresh this, and actually yeah here we go then you see it's an empty database it doesn't really have anything so now with martin in how would i create the post where i create a new order well first i would take that request and i would create an event out of it so i'm taking the request and i'm mapping it into that order created event then what i need to do is inject the i document store which is a martin's type of way of dealing with data in that database because modern can be used for event sourcing but can also be used if you just want to use it as a json sort of store vehicle or library but ultimately you should be using this for event sourcing then after that you want to get a lightweight session and with that session you want to say session.events. and you can start a new stream of a specific type i can say it's of type order and you have a few overloads here you can just pass the events so i could just say do this I could also pass the ID of the order as well. So I can say, here's the order ID, and it's going to use that GUID as the order ID. And then I'm going to have the event. You should, by the way, in your events, have a GUID or string named ID. This is needed. Otherwise, you won't be able to just save anything. And then I'm going to say, just like EF Core, session.saveChangesAsync and that's it. And then in the end, I'm going to say return results.ok. I'm not returning created because I don't want to have to pass down the get endpoint with a name and I'm just going to return the order on the way out. So now this should implement me creating an order. But how do you get an order out of it? Let's just implement the getOrderByAsync method. So because we're querying, I'm going to use the iQuery session interface over here which allows us to query the store and i'm going to say var order equals await session dot events dot aggregate stream async and i'm going to give out the stream id which in this case is going to be the order id and i'm going to specify that what i'm getting back here is the order class and then i'm going to say if the order is not null then return okay with the order otherwise return not found very basic stuff now ultimately what aggregate stream async will do is it will on the fly use that id to get the entire stream and then apply every event on that stream but how we'll do that we never really defined any of that on the events or on the order well you actually have to define that on the order and the way you do it is you create a public void apply method and what you apply is the event you want to apply so in this case created auto create order request so we have the create order request and then we map the field so we say the id is coming from the request dot id actually we don't do that because this is already applied from the stream so in this case we're going to say product name equals request dot product name and then delivery address equals request dot delivery address simple as that Now you will have to do this for every single event and I will do that for you so you don't have to watch me type. So here's all the events. You just get that event and then you apply using this apply method. You can also, by the way, return order here if you want to have an immutability sort of approach with it. Even if you use records and you say with and you just return a new object. and then in the end you say return or return a brand new object that you're just creating this method. However, there is overhead in C Sharp with creating this object over and over again. So I'd say just use this if you actually care about performance. So with that, simple as that, if I go ahead now and I say run my API and I do nothing else, not have to create any database, Martin will do it for me. So if I go on Insomnia and I say create an order for Dome Train Pro, for my home in London as the delivery address then we go ahead and we created it's going to take some time to create those fields in the database but after that as you can see you get that order now if I go over here and I say get the order by copying that id and I say give me this order then as you'll see I'm getting the entire order object so we have this we have the name we have the home and then the rest is null because we haven't really set it. So it goes through that flow and with aggregate stream async on the fly, it builds that order. And if we take a look at the database just to see how this looks, the sequences, routines, as well as a few tables created for us. So we have these events over here, which is ultimately where the events are stored. And you can see here the raw JSON object, and then we have the stream. So the stream is a collection of events ultimately. So this is all very simple to implement. However, This one especially we're going to improve because now every time when I read an order or many orders you're gonna have to get a big collection of events to just return back and aggregate on the fly. We don't really want to do that. Instead what we want to do is we want to build projections which you can think of as materialized views that represent a projection, a collection of aggregated events that we pre-store. And the great thing about Martin is it does this as a transaction for us, which is one of the big pain points sometimes when you want to try to keep up projections in sync with the stream. So the way you do that is you'd go and create a new class called OrderProjection. I'll just go ahead and stop this. And you would say that this is a single stream projection for the object of order. And what you do is you again have apply methods. In this case, however, you have the event that you're applying. as well as the order you're maintaining the state for. So what this will do is, as you set these values, not only will you save that event in the stream, but you will also update the projection that you can then just load by point reading that materialized pre-computed view for your system. If I implement all the methods, this would look something like this. So... I will go ahead and duplicate and then every event I have is using this projection and then it's applying it building that view for us however I have to register that projection so I'm going to say option dot projections dot add the order projection and in here I have to specify the projection life cycle this is very important projection life cycles allows you to configure how the projection is sort of treated so if you do in line which is ultimately the default in a way and what you should do in most cases but there's some new ones there. Inline will actually update the projection on the same transaction as you save a new event. So every time you append a new event the state will be synchronously and atomically be updated for that projection. So if inserting an event fails the projection won't be updated. If the update projection fails the event won't be added. So this is great. There's a few other ways to do this. You can have a live projection, which is executed on demand only, and you can have an async projection, which is only executed using the async theme on this a bit of a more advanced thing that if this video does well, I will make a video on. So let's just leave this like this and run this API now. And actually, no, not run yet. We want to go here and change this aggregate stream async to instead use a load. So again, order ID for the stream ID, but now we're loading because we're loading it from the projection. Now, if I run again and I go on Insomnia and I say create a new order, and I'm doing this because the order hasn't been created using the projection before, then as you're going to see, I have a new thing over here and I can go to get order, put that ID down, and then I'm reading this point. from the projection. If you take a look at the table, you can see that I'm reading from empty doc order and I can actually show you the table created now for us here, which is storing that material as you. It's storing that projection in here and that is the pre-computed state, which is amazing for performance and for doing queries for big data sets. So ultimately adding the rest is very simple. For example, if I want to update the address, all I'd have to say is I'm going to need an i document store again. So document store. And then I'm just appending an event now. And then I'm saving. So I don't need to say start new event anymore. I don't specify the type. I just say this is the stream ID. Take this object. Append it. And there's overloads as well that don't need the order ID. They can get it from the object you're saving. So this is lovely. I'm just going to implement the rest of the features. There we go. So now we have all our methods implemented. And the last thing I want to do is I want to get all the orders. The way we do this is we get the query session again as the point read for that order but then we say query that specific type and then i say to list async or you can use any of the other methods over here to modify what you're getting back but fundamentally on a basic level this is more than enough and now if i go and run this api i'm going to go back and i'm going to copy that id i'm going to say okay now the order needs to be dispatched so let's go ahead and put that id down and say dispatch this order And as you're going to see after the cold run, the order has been dispatched. I know this because if I say get the order, I now have a dispatch ID. If I copy that and I say now that the order is out for delivery, then I can go ahead and send this event. And then if I get the order, this is set. And likewise, if I do this for the order delivered, then same thing. If I go here, I have the state and this is now stored both as a stream. of events that represent the state and I can say okay what was the order like at that moment in time or how did the order progress or calculate how long does it take for I don't know dispatch and delivery or how long it took to deliver after it was out for delivery so many things you can do with something like this and you can see all the streams here individually as well and on top of that you can also get all the events out using this very efficient query method. Martin is a incredible library for things like this, the stuff you can do, this just scraping the surface of what you can possibly do with Martin. And ultimately, if this video does well and you want to see more, leave a comment down below and I will do more because there's so much on this topic and it is still, after so many years in my career, my favorite way of dealing and storing data. And even though I've never used Martin, I kind of regret it. If we knew about this at the time, there's a very good chance we would have used it because it is excellent. Now I wonder from you, what do you think about Martin and actually event sourcing in general? Are you using it? Leave a comment down below and let me know. Well, that's all I had for you for this video. Thank you very much for watching. As always, keep coding.
⚙️ Pipeline jobs
| Stage | Status | Att. | Updated | Error |
|---|---|---|---|---|
| download | done | 1/3 | 2026-07-20 15:06:58 | |
| transcribe | done | 1/3 | 2026-07-20 15:07:56 | |
| summarize | done | 1/3 | 2026-07-20 15:08:17 | |
| embed | done | 1/3 | 2026-07-20 15:08:20 |
📄 Описание YouTube
Показать
Get our REST APIs course for free on Dometrain: https://dometrain.com/course/from-zero-to-hero-rest-apis-in-asp-net-core/ Subscribe to my weekly newsletter: https://nickchapsas.com Become a Patreon and get special perks: https://www.patreon.com/nickchapsas Hello, everybody. I'm Nick, and in this video, I will show you how to get started with Event Sourcing in .NET using Marten, the best Event Sourcing library in .NET Workshops: https://bit.ly/nickworkshops Don't forget to comment, like and subscribe :) Social Media: Follow me on GitHub: https://github.com/Elfocrash Follow me on Twitter: https://twitter.com/nickchapsas Connect on LinkedIn: https://www.linkedin.com/in/nick-chapsas #csharp #dotnet