[{"data":1,"prerenderedAt":2144},["ShallowReactive",2],{"guide-articles":3},[4,191,636,1059,1456,1769,1996],{"id":5,"title":6,"body":7,"date":172,"description":173,"extension":174,"meta":175,"navigation":184,"path":185,"seo":186,"sitemap":188,"stem":189,"__hash__":190},"content\u002Fblog\u002Fmarten-wolverine\u002Fef-core-vs-marten.md","EF Core vs Marten and Polecat",{"type":8,"value":9,"toc":164},"minimark",[10,17,20,29,34,50,56,59,65,69,72,75,81,84,88,95,99,118,123,130,133,137,140,146,149,152],[11,12,13],"prose-lead",{},[14,15,16],"p",{},"EF Core is the .NET default long after the thing you are saving has stopped being a set of rows. An order with its lines, discounts, and address is one decision. The template still models it as a join.",[14,18,19],{},"I reach for EF Core when the model is rows: a line that is queried without its parent, a report that joins five tables, a schema another service already reads with SQL. I reach for a document when that order is loaded and saved as one unit. The second choice does not require a second database. Marten stores the document as JSONB in the PostgreSQL you already run. Polecat is that document model on SQL Server. EF Core stays for the tables that are actually relational.",[14,21,22,23,28],{},"How a Marten document is identified, written, indexed, and saved under contention is ",[24,25,27],"a",{"href":26},"\u002Fguides\u002Fmarten-document-database","Marten as a Document Database",". This article is the decision in front of that guide.",[30,31,33],"h2",{"id":32},"the-aggregate-became-a-join","The aggregate became a join",[14,35,36,37,41,42,45,46,49],{},"The symptoms show up before anyone argues about databases. Loading one order walks every relationship the screen might need. ",[38,39,40],"code",{},"Include"," chains grow. ",[38,43,44],{},"AsSplitQuery"," appears because one query returned a cartesian product. The Fluent API in ",[38,47,48],{},"OnModelCreating"," holds the mapping the class no longer shows.",[51,52],"code-sample",{"code":53,"filename":54,"lang":55},"var order = await db.Orders\n    .Include(o => o.Items)\n        .ThenInclude(i => i.Discounts)\n    .Include(o => o.ShippingAddress)\n    .Include(o => o.StatusHistory)\n    .AsSplitQuery()\n    .SingleAsync(o => o.Id == orderId);\n","LoadOrderEfCore.cs","csharp",[14,57,58],{},"That query is honest about a relational model. It is a tax on a document. Early in a product, every new field on the aggregate wants a migration, and the migration is arguing with a schema the business has not settled. The mapping layer is there so the ORM can split a thing the domain keeps together.",[14,60,61,64],{},[38,62,63],{},"ToJson"," puts an owned graph into a JSON column on that same row. The order is still a table, the mapping still lives in the model, and the change tracker still decides the save. The column got easier. The aggregate is still something the ORM assembled.",[30,66,68],{"id":67},"why-the-default-stays","Why the default stays",[14,70,71],{},"Three habits, and they reinforce each other.",[14,73,74],{},"Official templates and most conference talks start from EF Core. It is familiar, it is staffable, and the ecosystem assumes it. Choosing it needs no meeting. Choosing anything else does.",[14,76,77,78,80],{},"Most of us were trained to normalize first. Third normal form is the right reflex for an invoice line that accounting will sum without the order, and for a ledger. Applied to an aggregate, it produces the ",[38,79,40],{}," chain above and calls that design.",[14,82,83],{},"The alternative people picture is another cluster. MongoDB or DynamoDB, a second backup story, a second failure domain, and a dual write between the document and the relational system that still has to exist. That trade is real when the document cluster is the product. It is the wrong trade when the only goal was to stop joining an order to itself.",[30,85,87],{"id":86},"one-roundtrip-same-engine","One roundtrip, same engine",[14,89,90,91,94],{},"The document session loads the order by id, the domain decides, and one commit writes the document back. Marten does this on PostgreSQL. Polecat does this on SQL Server. The table is ",[38,92,93],{},"mt_doc_order",". The body is JSONB, or JSON on SQL Server. There is no line-item table unless you create one.",[51,96],{"code":97,"filename":98,"lang":55},"var order = await session.LoadAsync\u003COrder>(orderId);\n\norder.ApplyDiscount(coupon);\n\nsession.Store(order);\nawait session.SaveChangesAsync();\n","LoadOrderDocument.cs",[14,100,101,104,105,108,109,112,113,117],{},[38,102,103],{},"Store"," is the upsert. The rules for ",[38,106,107],{},"Insert",", ",[38,110,111],{},"Update",", indexes, and a lost update are the document guide. A stream of facts, when you need the history and not only the current order, is ",[24,114,116],{"href":115},"\u002Fguides\u002Fmarten-event-sourcing","Marten Event Sourcing in Production",".",[119,120],"article-table",{":columns":121,":rows":122},"[null,\"EF Core\",\"Marten \u002F Polecat\"]","[[\"Model\",\"Tables and JOINs\",{\"text\":\"JSON document\",\"weight\":\"semibold\"}],[\"Database\",\"Your Postgres or SQL Server\",{\"text\":\"Your Postgres or SQL Server\",\"weight\":\"semibold\"}],[\"ACID across models\",\"Yes. Relational only.\",{\"text\":\"Yes. One transaction.\",\"weight\":\"semibold\"}],[\"Aggregates\",\"Includes and mapping\",{\"text\":\"Natural and typed\",\"weight\":\"semibold\"}],[\"Schema friction\",\"Schema changes need a migration.\",{\"text\":\"Low\",\"weight\":\"semibold\"}]]",[14,124,125,126,129],{},"\"Low\" is the document, not the database. A new property inside the JSON is not a new column. A computed index is cheap to add. A duplicated column, which a ",[38,127,128],{},"DateTime"," filter needs, is still a migration, and production applies it before the new code serves traffic. The friction that goes away is the migration for every field of an aggregate that is still changing shape.",[14,131,132],{},"Both columns say the same database because that is the point. You do not stand up MongoDB to get a document. Backups, restore drills, and the people who already run PostgreSQL or SQL Server stay the operational home. A dedicated document cluster still fits when the access pattern is an aggregation pipeline, sharding, and operators who already run it. Moving that workload onto Marten so the architecture diagram says Postgres trades a database the team knows for a JSONB schema that will not behave like that pipeline.",[30,134,136],{"id":135},"both-models-one-instance","Both models, one instance",[14,138,139],{},"I do not throw EF Core away to adopt the document. One PostgreSQL or SQL Server instance holds both.",[14,141,142,143,145],{},"On one side, Marten or Polecat: the aggregates that are loaded and saved whole, one roundtrip by id, ",[38,144,93],{},". On the other, EF Core or Dapper: invoices, the general ledger, the report that joins tables, the legacy schema that already has a reader. A screen that wants five joins is telling you the data is relational. A command that loads one order to decide is telling you the data is a document.",[14,147,148],{},"The table's \"one transaction\" means those two writes can share the database transaction you already have. It does not mean a distributed transaction between PostgreSQL and a second cluster, and it does not mean every command should touch both models. On PostgreSQL, a Marten session and an EF Core context can enlist in the same transaction when one request truly writes both. That request is rarer than a diagram suggests. If every command needs both, the boundary is wrong. The enlistment is also specific to that engine. I am not going to pretend a PostgreSQL transaction sample is the Polecat and SQL Server wiring.",[14,150,151],{},"Polyglot, on this reading, is another model in the same engine. Strictly relational access stays on EF Core or Dapper. Time-series telemetry on PostgreSQL can be TimescaleDB beside Marten, still one cluster to back up. A second engine is a decision about operations, not a requirement of the document.",[14,153,154,155,159,160,117],{},"On a greenfield service, or on a bounded context cut out of a monolith, I stop modeling that aggregate as a join because the template did. That design, on your domain, is ",[24,156,158],{"href":157},"\u002Fmarten-polecat-wolverine-architecture","Marten, Polecat, and Wolverine architecture",". The hands-on version for the team is the ",[24,161,163],{"href":162},"\u002Fmarten-wolverine-training","Marten and Wolverine workshop",{"title":165,"searchDepth":166,"depth":166,"links":167},"",2,[168,169,170,171],{"id":32,"depth":166,"text":33},{"id":67,"depth":166,"text":68},{"id":86,"depth":166,"text":87},{"id":135,"depth":166,"text":136},"2026-09-29","EF Core is still the .NET default when the aggregate is one document. Marten on PostgreSQL and Polecat on SQL Server keep that document on the database you already run, beside EF Core.","md",{"category":176,"author":177,"readTime":180,"image":181,"imageAlt":182,"imageShow":183},"marten-wolverine",{"name":178,"avatar":179},"Ondřej Podhorný","\u002Fimg\u002Fmy_avatar.jpeg","9","blog\u002Fmarten-wolverine\u002Fef-core-vs-marten\u002Fef-core-vs-marten.png","One database. The aggregate stays a document.","list",true,"\u002Fblog\u002Fmarten-wolverine\u002Fef-core-vs-marten",{"title":6,"description":173,"keywords":187},"ef core, marten, polecat",{"loc":185},"blog\u002Fmarten-wolverine\u002Fef-core-vs-marten","EbyVjSfr-QUcirEgBbtr3VAmWuqmGR6VQrPQlxy9XcI",{"id":192,"title":193,"body":194,"date":172,"description":625,"extension":174,"meta":626,"navigation":184,"path":630,"seo":631,"sitemap":633,"stem":634,"__hash__":635},"content\u002Fguides\u002Fcqrs-dotnet-wolverine.md","CQRS in .NET with Wolverine",{"type":8,"value":195,"toc":613},[196,201,216,235,239,242,249,256,260,263,267,277,281,294,298,318,328,335,342,345,349,356,360,379,392,395,399,402,405,408,412,415,421,424,428,434,437,459,463,474,477,481,484,497,510,517,523,526,530,540,547,551,557,567,570,574,577,591,597,601,610],[11,197,198],{},[14,199,200],{},"CQRS in .NET is a split between a command that decides and a query that reads. Wolverine .NET makes that split a handler method, not a mediator interface on every type.",[14,202,203,204,207,208,211,212,117],{},"This guide is one order, followed all the way through. ",[38,205,206],{},"ShipOrder"," is allowed to refuse. When it accepts, it returns ",[38,209,210],{},"OrderShipped",". That return is the next message: the warehouse is told, the customer is billed, and the message either stays in this process or leaves for a broker. The MediatR alternative I ship is that pipeline. The side-by-side matrix is on the blog, ",[24,213,215],{"href":214},"\u002Fblog\u002Fmarten-wolverine\u002Fmediatr-vs-wolverine","MediatR and Wolverine, compared",[14,217,218,219,221,222,224,225,229,230,232,233,117],{},"The read model a query hits is in ",[24,220,116],{"href":115},". A current document with no stream behind it is ",[24,223,27],{"href":26},". A message that must not outlive the database commit is in ",[24,226,228],{"href":227},"\u002Fguides\u002Ftransactional-outbox-wolverine","Transactional Outbox with Wolverine",". The design of that context is ",[24,231,158],{"href":157},". The hands-on version, on your domain, is the ",[24,234,163],{"href":162},[30,236,238],{"id":237},"when-the-split-is-worth-it","When the split is worth it",[14,240,241],{},"Split the write from the read when they change for different reasons. The command protects an invariant: an order that is already shipped cannot be shipped again. The query serves a screen: open orders for a customer, with the address the screen wants and the aggregate does not. Most of the value is keeping those two from sharing a class.",[14,243,244,245,248],{},"A command bus in front of CRUD is still CRUD. One writer, one reader, one shape, and a ",[38,246,247],{},"Handle"," method that inserts a row, is a mapping you now maintain. I introduce the split when a screen wants a join the decision must not know about, or when the write has a rule the screen must not bypass.",[14,250,251,252,255],{},"Wolverine is the MediatR alternative when the next requirement is a cascade, a retry, or a broker, and the marker interface is only in the way. It is a poor trade when every handler is in-process, the work is a method call, and nobody is asking for messaging. Replacing ",[38,253,254],{},"IRequest"," so the code looks newer produces a large diff and the same behaviour.",[30,257,259],{"id":258},"one-order","One order",[14,261,262],{},"The domain in this guide is shipping an order. The command names the decision. The message it returns names the fact. Two later messages name the work that fact causes.",[51,264],{"code":265,"filename":266,"lang":55},"public enum OrderStatus { Open, Shipped }\n\npublic sealed record Order(Guid Id, OrderStatus Status);\n\npublic record ShipOrder(Guid OrderId, DateTimeOffset ShippedAt);\n\n[MessageIdentity(\"order-shipped\")]\npublic record OrderShipped(Guid OrderId, DateTimeOffset ShippedAt);\n\npublic record NotifyWarehouse(Guid OrderId);\npublic record BillCustomer(Guid OrderId);\n","ShipOrder.cs",[14,268,269,272,273,276],{},[38,270,271],{},"[MessageIdentity]"," pins the name Wolverine puts on the envelope. The default is the full CLR type name. I pin it before the first deploy, because a rename or a namespace move changes that string, and a message already sitting in a queue no longer finds a handler. How the ",[38,274,275],{},"Order"," itself is stored, as a document or as a stream, is the other guides. This one only decides.",[30,278,280],{"id":279},"the-handler-is-a-function","The handler is a function",[14,282,283,284,286,287,289,290,293],{},"A command is a type. A handler is a method that accepts that type. Wolverine finds ",[38,285,247],{}," by convention and injects whatever else the method asks for. There is no ",[38,288,254],{},", no ",[38,291,292],{},"IRequestHandler",", and no constructor whose only job is to store fields.",[51,295],{"code":296,"filename":297,"lang":55},"public static class ShipOrderHandler\n{\n    public static async Task\u003COrder> LoadAsync(ShipOrder command, IDocumentSession session)\n    {\n        var order = await session.LoadAsync\u003COrder>(command.OrderId);\n        if (order is null)\n            throw new InvalidOperationException($\"Order {command.OrderId} does not exist.\");\n\n        return order;\n    }\n\n    public static OrderShipped Handle(ShipOrder command, Order order)\n    {\n        if (order.Status != OrderStatus.Open)\n            throw new InvalidOperationException(\n                $\"Order {order.Id} is {order.Status}.\");\n\n        return new OrderShipped(order.Id, command.ShippedAt);\n    }\n}\n","ShipOrderHandler.cs",[14,299,300,301,303,304,307,308,310,311,314,315,317],{},"Wolverine does not take ",[38,302,275],{}," from the DI container. ",[38,305,306],{},"LoadAsync"," on the same class is a compound handler: Wolverine calls it first and passes the result into ",[38,309,247],{},". The session in that method is Marten. A stream aggregate loaded by ",[38,312,313],{},"[AggregateHandler]"," is the other way, and it is in the event sourcing guide. ",[38,316,247],{}," itself only decides.",[14,319,320,321,323,324,327],{},"The refusal is the point. An order that is not open does not return ",[38,322,210],{},", so the warehouse is not told and the customer is not billed. ",[38,325,326],{},"InvalidOperationException"," is enough here. A custom exception hierarchy does not make the rule clearer.",[14,329,330,331,334],{},"Return the result when the caller is waiting. Return nothing when the command is fire-and-forget. Take a ",[38,332,333],{},"CancellationToken"," when you call something that honours it. Do not take one so the signature looks like MediatR.",[14,336,337,338,341],{},"A query is the same shape with a name that reads. ",[38,339,340],{},"Handle(FindOpenOrders query)"," returns the list. It does not append events and it does not publish. If a \"query\" changes state, it is a command with a dishonest name.",[14,343,344],{},"The same exception means two different things once a transport is involved. Invoked in-process, it fails the HTTP request and nothing else runs. Sitting on a queue, Wolverine retries it and then moves the envelope to the error queue. Write the handler for the transport it will actually use. That choice is later in this guide.",[30,346,348],{"id":347},"cascading-messages","Cascading messages",[14,350,351,352,355],{},"A cascading message is a message the handler returns. Wolverine publishes it after the handler succeeds. You do not call ",[38,353,354],{},"IMessageBus.Publish"," for the ordinary next step. The return value is the next step, and it is visible in the method signature.",[51,357],{"code":358,"filename":359,"lang":55},"public static class OrderShippedHandler\n{\n    public static IEnumerable\u003Cobject> Handle(OrderShipped shipped)\n    {\n        yield return new NotifyWarehouse(shipped.OrderId);\n        yield return new BillCustomer(shipped.OrderId);\n    }\n}\n","Cascade.cs",[14,361,362,363,365,366,368,369,365,371,374,375,378],{},"Read the pipeline off the types. ",[38,364,206],{}," returns ",[38,367,210],{},". ",[38,370,210],{},[38,372,373],{},"NotifyWarehouse"," and ",[38,376,377],{},"BillCustomer",". Those two stop. A handler that returns a message of a type it also handles will spin: shipped, then shipped, then shipped. I treat a cycle in that graph as a bug I can see in review, not as a surprise I want at 2 a.m.",[14,380,381,382,384,385,387,388,391],{},"Publish and return is a double send. If ",[38,383,247],{}," yields ",[38,386,373],{}," and also calls ",[38,389,390],{},"bus.PublishAsync"," with the same message, the warehouse is told twice. The return is the publish. The bus is for the case the next message is not known until you are inside a branch the signature cannot show. That case is rarer than the habit of calling the bus anyway.",[14,393,394],{},"When the cascade has to be in the same database transaction as the decision, it is no longer \"publish and hope.\" That is the outbox, and it has its own guide. This guide only decides that the message exists.",[30,396,398],{"id":397},"in-memory-or-a-broker","In memory, or a broker",[14,400,401],{},"The handler does not change when the transport changes. The configuration does.",[14,403,404],{},"An in-memory local queue runs in the Wolverine process. It is the default for work that must happen here and can be retried here. A durable inbox writes the envelope so a process restart does not drop it. Use that for commands the user is not waiting on, when you still cannot afford to lose them.",[14,406,407],{},"A broker is a different process. RabbitMQ, Azure Service Bus, SQS, Kafka. The handler is the same method. The message crosses a boundary, so latency, ordering, and duplicates are now properties of the broker, not of a function call.",[51,409],{"code":410,"filename":411,"lang":55},"opts.LocalQueue(\"warehouse\")\n    .UseDurableInbox()\n    .MaximumParallelMessages(5);\n\nopts.PublishMessage\u003CNotifyWarehouse>()\n    .ToLocalQueue(\"warehouse\");\n\nopts.PublishMessage\u003CBillCustomer>()\n    .ToRabbitQueue(\"billing\");\n","Transport.cs",[14,413,414],{},"I keep the command the user is waiting on in-process. I put the warehouse notice on a local queue: it is our process, and a second later is fine. I put billing on a broker when another service owns it. Publishing everything to RabbitMQ because \"we might scale\" adds a broker you now operate, for a call that used to be a method.",[14,416,417,420],{},[38,418,419],{},"MaximumParallelMessages"," is the cap on how many warehouse notices run at once. The default local queue uses one slot per processor and does not keep order. A handler that calls a slow downstream system will fill the queue if you leave that cap at the size of the machine. Five is a number I set from the downstream limit, not from a hope about throughput.",[14,422,423],{},"The failure mode changes with the transport. An in-memory handler throws and the HTTP request fails. A queued message throws, Wolverine retries, then moves it to the error queue. The queued handler must be safe to run twice. The business rule for that second run is the outbox guide's idempotence section. The decision to queue at all is this one.",[30,425,427],{"id":426},"the-day-the-message-changes","The day the message changes",[14,429,430,431,433],{},"A year later ",[38,432,210],{}," needs the time it was shipped, or a carrier code, or a new name. The envelopes already in a durable queue and on the broker still carry the old shape and the old message type string.",[14,435,436],{},"Three cases, and they are not the same:",[438,439,440,450,456],"ul",{},[441,442,443,444,447,448,117],"li",{},"A new business fact. ",[38,445,446],{},"OrderCancelled"," is a new type. It is not version 2 of ",[38,449,210],{},[441,451,452,453,455],{},"A rename of the CLR type, or a move to another namespace, with no ",[38,454,271],{},". The string on the envelope changes. In-flight messages have no handler. Pin the alias before the first production publish, and leave it there.",[441,457,458],{},"A shape that replaces the old one, while old envelopes are still out there. That is a new version of the same alias.",[51,460],{"code":461,"filename":462,"lang":55},"[MessageIdentity(\"order-shipped\", Version = 2)]\npublic record OrderShippedV2(\n    Guid OrderId,\n    DateTimeOffset ShippedAt,\n    string Carrier);\n","OrderShippedV2.cs",[14,464,465,466,469,470,473],{},"The envelope's message type stays ",[38,467,468],{},"order-shipped",". That is what routing reads. Wolverine publishes version 2 with the content type ",[38,471,472],{},"application\u002Fvnd.order-shipped.v2+json",". Old v1 envelopes are still v1. I keep a handler for v1 until the queues that can contain it are empty. I do not delete the old type in the same deploy that introduces v2. Adding a property with a sensible default is usually a smaller change than a new version. I still pin the alias, so a later rename does not become an outage.",[14,475,476],{},"The order document and the event stream have their own versioning. An upcaster on a Marten event is not this. This is the message on the wire.",[30,478,480],{"id":479},"where-it-jams","Where it jams",[14,482,483],{},"Four ways this pipeline fails in production. Each one is a choice you can see before the incident.",[14,485,486,487,365,489,491,492,494,495,117],{},"A cascade cycle. ",[38,488,210],{},[38,490,373],{},", and the warehouse handler returns ",[38,493,210],{}," because someone reused the type. The local queue never drains. CPU climbs. The durable inbox table climbs with it. The fix is a type that means \"warehouse was told,\" not a second ",[38,496,210],{},[14,498,499,500,502,503,506,507,509],{},"An unbounded queue. The default local queue will accept work as fast as publishers send it. If ",[38,501,373],{}," calls a carrier API that takes two seconds, and checkout ships fifty orders a second, the channel grows in memory. ",[38,504,505],{},"UseDurableInbox"," moves that growth into PostgreSQL, which is better than a process death and worse than a cap. ",[38,508,419],{}," is that cap. I set it from the carrier's limit. I also watch the queue depth. A depth that grows for an hour is a handler that is down or slower than the publishers, not a reason to raise the cap.",[14,511,512,513,516],{},"A double send. Return the message and also call ",[38,514,515],{},"PublishAsync",". The tracked session shows two envelopes. The warehouse picks the order twice. One of those lines has to go. I keep the return.",[14,518,519,520,522],{},"A handler written for a function call and then moved onto a queue. In-process, a throw rolls the request back and the user sees the error. On the queue, the same throw is a retry, and the second delivery runs the side effect again. Moving ",[38,521,377],{}," to RabbitMQ is a change to the handler's contract, even though the method did not change. If billing the customer twice charges the card twice, the handler is not ready for the broker. Idempotence of that charge is the outbox guide. Knowing that the move created the requirement is this one.",[14,524,525],{},"The error queue is the end of the retry, not a log line. I want its depth next to the local queue depth. A growing error queue means a handler is throwing on data that will not succeed if you retry it. Replaying it without reading the exception replays the incident.",[30,527,529],{"id":528},"the-test-is-the-cascade","The test is the cascade",[14,531,532,533,536,537,539],{},"A unit test of ",[38,534,535],{},"ShipOrderHandler.Handle"," checks the refusal: an order that is not open throws, an open order returns ",[38,538,210],{},". That test does not need Wolverine, and it does not need a database.",[14,541,542,543,546],{},"The test that justifies the framework is the one that follows the return values. ",[38,544,545],{},"InvokeMessageAndWaitAsync"," does not return until the local cascade has finished, or the timeout fires. If any handler in that activity throws, the session throws.",[51,548],{"code":549,"filename":550,"lang":55},"public async Task shipping_an_open_order_notifies_and_bills()\n{\n    var orderId = Guid.NewGuid();\n    var shippedAt = DateTimeOffset.UtcNow;\n\n    using var host = await Host.CreateDefaultBuilder()\n        .UseWolverine(opts =>\n        {\n            opts.PublishMessage\u003CNotifyWarehouse>()\n                .ToLocalQueue(\"warehouse\");\n            opts.PublishMessage\u003CBillCustomer>()\n                .ToLocalQueue(\"billing\");\n        })\n        .StartAsync();\n\n    var session = await host.InvokeMessageAndWaitAsync(\n        new ShipOrder(orderId, shippedAt));\n\n    session.Sent.SingleMessage\u003CNotifyWarehouse>()\n        .OrderId.ShouldBe(orderId);\n    session.Sent.SingleMessage\u003CBillCustomer>()\n        .OrderId.ShouldBe(orderId);\n}\n","ShipOrderTests.cs",[14,552,553,554,556],{},"The host in a real fixture is the application host, so ",[38,555,306],{}," has a document session. The snippet shows the routing and the wait. It is not a second, empty host.",[14,558,559,562,563,566],{},[38,560,561],{},"IHost"," is expensive. I build it once for the fixture, not once per fact. ",[38,564,565],{},"IncludeExternalTransports()"," makes the session wait on a broker. I turn that on only for the test that actually has a broker. The cascade above is local, so the default is the right wait.",[14,568,569],{},"Testcontainers belongs in the test that needs PostgreSQL: the outbox row, the document, the stream. A cascade of in-memory messages does not. Pulling a database into this test hides whether the return values are the design, and spends a minute of CI on a fact a tracked session already proved.",[30,571,573],{"id":572},"what-i-want-on-the-trace","What I want on the trace",[14,575,576],{},"Wolverine already writes a span for the message. I want three things on it, and I do not add a custom counter per handler to get them.",[14,578,579,580,582,583,374,585,587,588,590],{},"The message type, the alias from ",[38,581,271],{}," when there is one. The outcome: the handler finished, or the envelope landed in the error queue. The children: ",[38,584,373],{},[38,586,377],{}," hanging off ",[38,589,210],{},", so a slow bill is visible as its own span and not as a vague slowness of \"shipping.\"",[14,592,593,594,596],{},"Queue depth and error-queue depth are the other two numbers. They are gauges over time, not a log line inside ",[38,595,247],{},". A trace tells me why one order was slow. The depth tells me the pipeline is falling behind. I page on depth that grows, not on a single slow span.",[30,598,600],{"id":599},"where-mediatr-still-shows-up","Where MediatR still shows up",[14,602,603,604,606,607,609],{},"Teams already have ",[38,605,254],{}," through the codebase. Wolverine replaces it at a boundary. New commands go to Wolverine. Old handlers stay until a change touches them. A big-bang rewrite of every ",[38,608,292],{}," is a large diff and the same behaviour.",[14,611,612],{},"If the system is in-process, the handlers are simple, and nobody is asking for a cascade, a retry, or a broker, I leave MediatR in place. The comparison of the two shapes stays on the blog post linked above.",{"title":165,"searchDepth":166,"depth":166,"links":614},[615,616,617,618,619,620,621,622,623,624],{"id":237,"depth":166,"text":238},{"id":258,"depth":166,"text":259},{"id":279,"depth":166,"text":280},{"id":347,"depth":166,"text":348},{"id":397,"depth":166,"text":398},{"id":426,"depth":166,"text":427},{"id":479,"depth":166,"text":480},{"id":528,"depth":166,"text":529},{"id":572,"depth":166,"text":573},{"id":599,"depth":166,"text":600},"CQRS in .NET with Wolverine: handlers as functions, cascading messages, and in-memory or broker dispatch. A MediatR alternative without the boilerplate.",{"category":176,"author":627,"readTime":628,"relatedArticles":629},{"name":178,"avatar":179},"18",[115,227],"\u002Fguides\u002Fcqrs-dotnet-wolverine",{"title":193,"description":625,"keywords":632},"cqrs net, wolverine net, mediatr alternative",{"loc":630},"guides\u002Fcqrs-dotnet-wolverine","e8UZSl1H9w8vDq2N474HWXUR9Xp6dzSRuhG6Pc9RRM8",{"id":637,"title":27,"body":638,"date":172,"description":1049,"extension":174,"meta":1050,"navigation":184,"path":26,"seo":1054,"sitemap":1056,"stem":1057,"__hash__":1058},"content\u002Fguides\u002Fmarten-document-database.md",{"type":8,"value":639,"toc":1039},[640,645,648,663,667,674,678,688,702,706,722,742,746,761,767,774,778,781,785,791,818,825,832,836,839,843,857,861,871,887,890,900,904,907,914,917,921,924,927,940,944,947,994,1007,1011,1017,1020,1024],[11,641,642],{},[14,643,644],{},"Marten as a document database stores one JSON document per identity in PostgreSQL. I use it when that document is the consistency boundary, and EF Core or MongoDB would be carrying the wrong kind of model.",[14,646,647],{},"This guide is the data layer: identity, the write, the query, and what happens when two requests save the same document. It is the current state. It is not a history of how the state was reached.",[14,649,650,651,653,654,656,657,229,659,232,661,117],{},"A stream of facts, aggregates, and projections is ",[24,652,116],{"href":115},". A handler that loads this document and decides is in ",[24,655,193],{"href":630},". A save that must also emit a message is ",[24,658,228],{"href":227},[24,660,158],{"href":157},[24,662,163],{"href":162},[30,664,666],{"id":665},"the-document-is-the-record","The document is the record",[14,668,669,670,673],{},"A document is the thing you load and save as one unit. An order with its lines. A user profile. A piece of configuration that changes together. The identity is a public ",[38,671,672],{},"Id",". Guid, string, int, and long all work. I set the id in the application when the caller already knows it. Marten assigns a Guid when the value is empty, and the default algorithm is a sequential Comb guid so the primary key does not fragment.",[51,675],{"code":676,"filename":677,"lang":55},"public enum OrderStatus { Open, Shipped, Cancelled }\n\npublic sealed record OrderLine(string Sku, int Quantity);\n\npublic sealed class Order\n{\n    public Guid Id { get; set; }\n    public string CustomerId { get; set; }\n    public OrderStatus Status { get; set; }\n    public DateTimeOffset PlacedAt { get; set; }\n    public List\u003COrderLine> Lines { get; set; } = [];\n}\n","Order.cs",[14,679,680,681,683,684,687],{},"Marten stores that type as JSONB in a table named ",[38,682,93],{},". The id is the primary key. The document is the ",[38,685,686],{},"data"," column. There is no line-item table unless you create one. A property that must be constrained, queried by SQL, or shown to a reporting tool as a column is a decision you make on purpose. The default is the document.",[14,689,690,691,693,694,697,698,701],{},"If the id on an existing type is not called ",[38,692,672],{},", map it once with ",[38,695,696],{},"[Identity]"," or ",[38,699,700],{},"Schema.For\u003COrder>().Identity(x => x.Number)",". Do that when you are moving a document that already has a key. Keep the key the business already uses.",[30,703,705],{"id":704},"write-the-document-you-mean","Write the document you mean",[14,707,708,709,711,712,714,715,717,718,721],{},"A lightweight session does not watch the objects you loaded. Nothing is saved because you mutated a property. ",[38,710,107],{}," stages a new document. ",[38,713,111],{}," stages a replacement of one that already exists. ",[38,716,103],{}," stages an upsert. All three wait for ",[38,719,720],{},"SaveChangesAsync",". That call is the commit, and that call is where Marten throws.",[14,723,724,726,727,368,730,732,733,736,737,697,739,741],{},[38,725,107],{}," of an id that exists fails the commit with ",[38,728,729],{},"DocumentAlreadyExistsException",[38,731,111],{}," of an id that does not exist fails the commit with ",[38,734,735],{},"NonExistentDocumentException",". A catch around ",[38,738,107],{},[38,740,111],{}," never sees those exceptions. The same timing is why the outbox guide discards a duplicate insert on the handler chain, after the method has returned.",[51,743],{"code":744,"filename":745,"lang":55},"await using var session = store.LightweightSession();\n\nvar order = await session.LoadAsync\u003COrder>(orderId);\nif (order is null)\n    throw new InvalidOperationException($\"Order {orderId} does not exist.\");\n\norder.Status = OrderStatus.Shipped;\nsession.Update(order);\nawait session.SaveChangesAsync();\n","SaveOrder.cs",[14,747,748,749,751,752,754,755,757,758,760],{},"I use ",[38,750,107],{}," for the first write and ",[38,753,111],{}," after a load. ",[38,756,103],{}," is for the case where replace-or-create is actually the rule, such as a settings document keyed by a known id. Using ",[38,759,103],{}," for every save hides a lost update: the last writer replaces the document, including fields they never read.",[14,762,763,766],{},[38,764,765],{},"DirtyTrackedSession"," is the session that saves a mutation by itself. I keep it off the request path. A document loaded for a read, then touched while building a response, gets written back. The lightweight session makes the write a line I can see.",[14,768,769,770,773],{},"Load with ",[38,771,772],{},"LoadAsync\u003COrder>(id)"," when you have the id. A missing id returns null. Use a query session for a request that is only allowed to read. A query session cannot commit. That is the constraint I want on a GET.",[30,775,777],{"id":776},"query-a-field-then-index-it","Query a field, then index it",[14,779,780],{},"LINQ queries one document type. Marten writes SQL against the JSONB, or against an index you declared.",[51,782],{"code":783,"filename":784,"lang":55},"opts.Schema.For\u003COrder>().Index(x => x.CustomerId);\nopts.Schema.For\u003COrder>().Duplicate(x => x.PlacedAt);\n\nvar open = await session.Query\u003COrder>()\n    .Where(x => x.CustomerId == customerId && x.Status == OrderStatus.Open)\n    .ToListAsync();\n","IndexAndQuery.cs",[14,786,787,790],{},[38,788,789],{},"Index"," is a computed index on the JSON path. PostgreSQL can use it, and you do not add a second column that has to stay in sync. I add an index for a predicate the application actually runs. I leave the other properties inside the document.",[14,792,793,794,796,797,697,799,802,803,806,807,810,811,814,815,817],{},"Marten's ",[38,795,789],{}," does not emit a computed index for ",[38,798,128],{},[38,800,801],{},"DateTimeOffset",". PostgreSQL accepts an expression index only when the expression is immutable, and a cast of that JSON value to ",[38,804,805],{},"timestamptz"," is not: it depends on the session time zone. A hot filter on ",[38,808,809],{},"PlacedAt"," is a ",[38,812,813],{},"Duplicate",": a real column Marten keeps beside ",[38,816,686],{},", and a migration when you add it to a large table. A column a report already queries with SQL is the other reason I duplicate. Everything else stays in the JSON.",[14,819,820,821,824],{},"A value that must be unique, such as an order number the customer can see, is an index with ",[38,822,823],{},"IsUnique = true",". A duplicate insert then fails the commit. I use that for a business key. I do not unique the customer id, because a customer has many orders.",[14,826,827,828,831],{},"A query that wants five joins is telling you the data is relational. Marten can include another document type. I treat that include as a sign the included data belongs inside this document, or the screen is a reporting query and belongs in SQL. ",[38,829,830],{},"ToListAsync"," on an unfiltered table loads every document into the process. That works once.",[30,833,835],{"id":834},"two-updates-at-once","Two updates at once",[14,837,838],{},"Two requests will load the same order, change it, and save. Without a revision check, the second save wins and the first change disappears. I turn on numeric revisions for any document that more than one actor writes.",[51,840],{"code":841,"filename":842,"lang":55},"public sealed class Order : IRevisioned\n{\n    public Guid Id { get; set; }\n    public int Version { get; set; }\n    public OrderStatus Status { get; set; }\n    public List\u003COrderLine> Lines { get; set; } = [];\n}\n","Concurrency.cs",[14,844,845,848,849,852,853,856],{},[38,846,847],{},"IRevisioned"," stores an integer in ",[38,850,851],{},"mt_version"," and copies it onto ",[38,854,855],{},"Version",". The load brings that number back. The save I send is the next one.",[51,858],{"code":859,"filename":860,"lang":55},"session.UpdateRevision(order, order.Version + 1);\nawait session.SaveChangesAsync();\n","UpdateRevision.cs",[14,862,863,864,867,868,870],{},"Marten rejects the commit when the database revision is already that number or higher. The exception is ",[38,865,866],{},"ConcurrencyException",", and it comes from ",[38,869,720],{},". The request fails. The caller reloads and decides again. Swallowing the exception and retrying the same in-memory order writes the older intent over the winner.",[14,872,873,876,877,879,880,882,883,886],{},[38,874,875],{},"UseOptimisticConcurrency(true)"," is the other mechanism. It stores a Guid in the same ",[38,878,851],{}," column. A type that has both, including an ",[38,881,847],{}," document with that call added later, fails at startup with ",[38,884,885],{},"InvalidDocumentException",". I use the integer. A support query can read it. Aggregate projections are already revisioned; that path stays in the event sourcing guide.",[14,888,889],{},"If conflicts are constant, the document is too wide. An order that also contains the customer's profile, the invoice, and the shipment will collide all day. Split it until a conflict means two decisions about the same thing. A document nobody else writes does not need the revision.",[14,891,892,893,895,896,899],{},"Calling ",[38,894,103],{}," on the same revisioned instance twice in one session does not keep producing the next number. One decision, one ",[38,897,898],{},"UpdateRevision",". A loop that saves the same order again in that session passes the next number itself.",[30,901,903],{"id":902},"where-ef-core-still-fits","Where EF Core still fits",[14,905,906],{},"EF Core remains the tool when the model is rows. Foreign keys, a line that is queried without its parent, a report that joins five tables, a schema other services already read with SQL. Mapping that to documents means copying the same fact into several JSON blobs and hoping they stay aligned.",[14,908,909,910,913],{},"I leave an existing EF model on EF. The document table is a new schema, ",[38,911,912],{},"mt_doc_*",", and the application has to mean the document. Pointing EF at those tables to get a change tracker back gives you two models of one JSON column. The Marten configuration is the one that knows the identity, the revision, and the indexes.",[14,915,916],{},"The split I use in one database: Marten for the aggregates that are loaded and saved whole, EF Core or SQL for the relational remainder. One PostgreSQL transaction can cover both when a single request truly writes both. That case is rarer than the diagram suggests. If every command needs both, the boundary is wrong.",[30,918,920],{"id":919},"where-mongodb-still-fits","Where MongoDB still fits",[14,922,923],{},"The document shape is the same idea. One aggregate, one JSON document, load by id, query by fields. The operational home is different. Marten is the PostgreSQL you already back up, restore, and connect to from the rest of the application. A save of several documents in one session is one PostgreSQL transaction. There is no second cluster to upgrade because one service wanted documents.",[14,925,926],{},"MongoDB still fits when the access pattern is a large document collection with an aggregation pipeline, sharding, and operators who already run it. Moving that workload onto Marten to \"be on Postgres\" trades a database your team knows for a JSONB schema that will not behave like the aggregation framework. I make that move when the service already depends on PostgreSQL and the document is the aggregate. I leave a dedicated document cluster where the cluster is the product.",[14,928,929,930,933,934,936,937,939],{},"Id names survive the move. A Mongo ",[38,931,932],{},"_id"," that is not ",[38,935,672],{}," gets an ",[38,938,696],{}," map. The query does not survive unchanged. An aggregation pipeline becomes LINQ, or it becomes SQL. Plan that as a rewrite of the query, not as a connection-string change.",[30,941,943],{"id":942},"postgresql-is-still-the-database","PostgreSQL is still the database",[14,945,946],{},"The documents live in the same instance as everything else unless the write rate says otherwise. Vacuum, indexes, and a backup you have restored once still apply. A document table grows when you keep every historical copy yourself. Marten is storing the current document. If you needed the history, that is the event store, and it has its own guide.",[14,948,949,950,953,954,957,958,961,962,374,965,968,969,972,973,976,977,980,981,983,984,986,987,990,991,993],{},"In development I set ",[38,951,952],{},"AutoCreateSchemaObjects"," to ",[38,955,956],{},"AutoCreate.CreateOrUpdate",". Marten adds what is missing and does not drop a table to do it. In production the setting is ",[38,959,960],{},"AutoCreate.None",". The process serves traffic. It does not migrate. ",[38,963,964],{},"db-apply",[38,966,967],{},"db-assert"," come from JasperFx: ",[38,970,971],{},"ApplyJasperFxExtensions()"," on the host, and ",[38,974,975],{},"RunJasperFxCommands(args)"," where the process would otherwise call ",[38,978,979],{},"Run()",". A host that only calls ",[38,982,979],{}," reports ",[38,985,964],{}," as an unknown argument. ",[38,988,989],{},"dotnet run -- db-apply"," runs before the new code is in rotation. ",[38,992,967],{}," fails the deploy when the database does not match the configuration. Several nodes creating indexes at startup is how a deploy gets stuck. A computed index is the cheap change. A duplicated column on a large table is a migration, and it belongs in that apply step.",[14,995,996,997,1000,1001,1003,1004,1006],{},"JSONB is still a column. You can read it with SQL when you are diagnosing a production row. The application keeps going through Marten so the identity, the revision, and the index configuration stay in one place. A hand-written ",[38,998,999],{},"UPDATE"," of the ",[38,1002,686],{}," column skips the ",[38,1005,851],{}," check. I treat that as an incident, the same way I would treat an ad hoc update of a relational row someone else is editing.",[30,1008,1010],{"id":1009},"the-test-is-the-stale-write","The test is the stale write",[14,1012,1013,1014,1016],{},"A test that calls ",[38,1015,111],{}," and does not commit has not tested the document store. The fact I want is that the second writer keeps the order, and the first writer's commit throws.",[14,1018,1019],{},"The store in the fixture is the application store, against PostgreSQL. I build it once.",[51,1021],{"code":1022,"filename":1023,"lang":55},"public async Task a_stale_save_leaves_the_first_write()\n{\n    var orderId = Guid.NewGuid();\n\n    await using (var setup = store.LightweightSession())\n    {\n        setup.Store(new Order { Id = orderId, Status = OrderStatus.Open });\n        await setup.SaveChangesAsync();\n    }\n\n    await using var first = store.LightweightSession();\n    await using var second = store.LightweightSession();\n\n    var byFirst = await first.LoadAsync\u003COrder>(orderId);\n    var bySecond = await second.LoadAsync\u003COrder>(orderId);\n\n    byFirst.Status = OrderStatus.Shipped;\n    bySecond.Status = OrderStatus.Cancelled;\n\n    second.UpdateRevision(bySecond, bySecond.Version + 1);\n    await second.SaveChangesAsync();\n\n    first.UpdateRevision(byFirst, byFirst.Version + 1);\n    await Should.ThrowAsync\u003CConcurrencyException>(\n        () => first.SaveChangesAsync());\n}\n","StaleOrderTests.cs",[14,1025,1026,1027,1030,1031,1034,1035,1038],{},"Testcontainers belongs in this test. After the throw I reload through a query session and expect ",[38,1028,1029],{},"Cancelled",". The in-memory ",[38,1032,1033],{},"byFirst"," is still ",[38,1036,1037],{},"Shipped",". That object is the stale decision, and it is not what PostgreSQL kept.",{"title":165,"searchDepth":166,"depth":166,"links":1040},[1041,1042,1043,1044,1045,1046,1047,1048],{"id":665,"depth":166,"text":666},{"id":704,"depth":166,"text":705},{"id":776,"depth":166,"text":777},{"id":834,"depth":166,"text":835},{"id":902,"depth":166,"text":903},{"id":919,"depth":166,"text":920},{"id":942,"depth":166,"text":943},{"id":1009,"depth":166,"text":1010},"Marten as a document database on PostgreSQL. Store the current document when an EF Core row graph or a separate MongoDB is the wrong tool.",{"category":176,"author":1051,"readTime":1052,"relatedArticles":1053},{"name":178,"avatar":179},"16",[115,630,227],{"title":27,"description":1049,"keywords":1055},"marten document database, ef core, mongodb",{"loc":26},"guides\u002Fmarten-document-database","HHlC75o6LT9CEG7KQo9FBMlzjUkE4VkeB7SgfwU6jkw",{"id":1060,"title":1061,"body":1062,"date":172,"description":1447,"extension":174,"meta":1448,"navigation":184,"path":115,"seo":1451,"sitemap":1453,"stem":1454,"__hash__":1455},"content\u002Fguides\u002Fmarten-event-sourcing.md","Marten Event Sourcing in Production: Streams, Aggregates & Projections",{"type":8,"value":1063,"toc":1436},[1064,1069,1072,1087,1091,1098,1108,1112,1123,1127,1138,1141,1147,1151,1154,1157,1161,1168,1172,1188,1199,1202,1206,1209,1235,1239,1242,1252,1260,1263,1267,1270,1273,1277,1280,1284,1294,1307,1317,1321,1342,1345,1349,1355,1369,1379,1385,1398,1400,1408,1411,1415,1423,1426,1430],[11,1065,1066],{},[14,1067,1068],{},"Marten event sourcing is a stream of facts in PostgreSQL. Marten projections are the read model built from that stream. Both have to be operable the morning after the first deploy.",[14,1070,1071],{},"This guide is the write side and the read side of one system. A stream is identified, an aggregate decides, two writers collide, and an old event has to change shape without a rewritten history. On the other side, a projection is either inside the commit or behind it, and a rebuild is an operation, not a button you hope nobody presses.",[14,1073,1074,1075,1077,1078,1080,1081,229,1083,232,1085,117],{},"Storing the current document, with no event stream, is ",[24,1076,27],{"href":26},". Handlers that publish the next message are in ",[24,1079,193],{"href":630},". The commit that must include that message is in ",[24,1082,228],{"href":227},[24,1084,158],{"href":157},[24,1086,163],{"href":162},[30,1088,1090],{"id":1089},"the-stream-is-the-identity","The stream is the identity",[14,1092,1093,1094,1097],{},"A stream is the sequence of events for one thing. The stream id is the identity of that thing. I use one stream per aggregate instance, and I decide the id before the first event is stored. A Guid the caller already has is a fine id. A string such as ",[38,1095,1096],{},"order\u002F{id}"," is also fine. Inventing the id inside the database is how two requests create two streams for one order.",[14,1099,1100,1103,1104,1107],{},[38,1101,1102],{},"StartStream"," is the first write. A later decision appends. Starting the same id twice fails the commit with ",[38,1105,1106],{},"ExistingStreamIdCollisionException",". That failure is the one I want. A second history for the same order is not.",[51,1109],{"code":1110,"filename":1111,"lang":55},"session.Events.StartStream\u003COrder>(\n    orderId,\n    new OrderPlaced(orderId, customerId));\n\nawait session.SaveChangesAsync();\n","StartOrderStream.cs",[14,1113,1114,1115,1118,1119,1122],{},"The events land in ",[38,1116,1117],{},"mt_events",". The stream row is in ",[38,1120,1121],{},"mt_streams",". Both only grow. A projection can be dropped and built again. These two tables cannot.",[30,1124,1126],{"id":1125},"the-aggregate-decides","The aggregate decides",[14,1128,1129,1130,1133,1134,1137],{},"The aggregate is a function of the stream. ",[38,1131,1132],{},"Create"," folds the first event. ",[38,1135,1136],{},"Apply"," folds each event after that. A command is allowed to say no before any event is appended. After the event is stored, the decision has already happened.",[51,1139],{"code":1140,"filename":677,"lang":55},"public class Order\n{\n    public Guid Id { get; set; }\n    public bool Shipped { get; private set; }\n\n    public static Order Create(OrderPlaced placed) =>\n        new() { Id = placed.OrderId };\n\n    public void Apply(OrderShipped _) => Shipped = true;\n}\n",[14,1142,1143,1146],{},[38,1144,1145],{},"FetchForWriting\u003COrder>"," needs that type registered as a single-stream snapshot. Without it, the call has no aggregate to load. I keep the write snapshot inline, so the version I decide against is the version just committed.",[51,1148],{"code":1149,"filename":1150,"lang":55},"opts.Projections.Snapshot\u003COrder>(SnapshotLifecycle.Inline);\n","SnapshotOrder.cs",[14,1152,1153],{},"The aggregate may refuse a command. It may not reach into another stream to do it. If shipping an order depends on a reservation that lives elsewhere, that fact is already in this stream, or it is a different decision. Cross-stream business rules are where event sourcing turns into a distributed lock with extra steps.",[14,1155,1156],{},"The snapshot document is the current fold, stored so the next decision does not replay the stream. It is not a screen. A list of open orders is a projection further down. Editing the snapshot row by hand skips the stream, and the next rebuild puts the stream back.",[30,1158,1160],{"id":1159},"two-writes-at-once","Two writes at once",[14,1162,1163,1164,1167],{},"Two decisions will land on the same stream. The write carries the version ",[38,1165,1166],{},"FetchForWriting"," just read. Marten rejects the append when that version is no longer the tip. The caller loads again, decides again, and appends again. A lost update that silently sticks is worse than a failed request.",[51,1169],{"code":1170,"filename":1171,"lang":55},"var stream = await session.Events.FetchForWriting\u003COrder>(orderId);\n\nif (stream.Aggregate is null)\n    throw new InvalidOperationException($\"Order {orderId} does not exist.\");\n\nif (stream.Aggregate.Shipped)\n    throw new InvalidOperationException(\"Already shipped.\");\n\nstream.AppendOne(new OrderShipped(orderId));\nawait session.SaveChangesAsync();\n","AppendAtVersion.cs",[14,1173,1174,1175,1177,1178,1180,1181,1184,1185,1187],{},"The check happens in ",[38,1176,720],{},". The exception is ",[38,1179,866],{},". I read ",[38,1182,1183],{},"stream.Aggregate",". I do not mutate it. With an inline snapshot, a field changed on that object can be applied twice: once by the mutation, once by ",[38,1186,1136],{},". The event is the change.",[14,1189,1190,1192,1193,1196,1197,117],{},[38,1191,1166],{}," works in both append modes. Passing an expected version to ",[38,1194,1195],{},"Append"," yourself requires the rich append mode, and the current default is quick. I stay on ",[38,1198,1166],{},[14,1200,1201],{},"If the conflict is common, the stream is too wide. A stream that takes every click in a tenant will conflict all day. Split the identity until a conflict means two real decisions about the same thing.",[30,1203,1205],{"id":1204},"changing-the-event-without-rewriting-history","Changing the event without rewriting history",[14,1207,1208],{},"A bad event stays in the stream. I do not update the row. Four changes, and they are not the same:",[438,1210,1211,1214,1232],{},[441,1212,1213],{},"The decision was wrong. Append a compensating event. The history still shows the mistake.",[441,1215,1216,1217,1220,1221,368,1224,1227,1228,1231],{},"The class moved, or the class was renamed. Marten stores the event type name in ",[38,1218,1219],{},"mt_events.type",", separate from the CLR name in ",[38,1222,1223],{},"mt_dotnet_type",[38,1225,1226],{},"AddEventType"," covers a namespace move when the class name is unchanged. ",[38,1229,1230],{},"MapEventType"," covers a renamed class. Stored JSON stays. This is not an upcaster.",[441,1233,1234],{},"The JSON shape changed. An upcaster runs when the event is read and produces the CLR type the aggregate knows. Stored bytes stay. Old and new code can run a rebuild.",[51,1236],{"code":1237,"filename":1238,"lang":55},"opts.Events.Upcast\u003COrderPlaced, OrderPlacedWithCurrency>(\n    placed => new OrderPlacedWithCurrency(\n        placed.OrderId,\n        placed.CustomerId,\n        \"unknown\"));\n","UpcastOrderPlaced.cs",[14,1240,1241],{},"The default in that function is a business decision. An upcaster that calls a service on every read turns a rebuild into an outage. I keep it a pure function.",[14,1243,1244,1245,1248,1249,1251],{},"This overload deserializes the stored JSON into ",[38,1246,1247],{},"OrderPlaced",", then calls the function. ",[38,1250,1247],{}," stays in the codebase for as long as those bytes are read. A raw JSON upcast is the registration that does not keep the old CLR type.",[438,1253,1254],{},[441,1255,1256,1257,1259],{},"The event should never have been visible. That is an operational delete of a stream, with a reason, not a quiet ",[38,1258,999],{},". Treat it as an incident.",[14,1261,1262],{},"A new business fact is a new event. A rename of a property inside the JSON is the upcaster, or a serializer name map. I version the CLR type when readers must see a different shape. I do not version it because a property was renamed and the bytes still mean the same fact.",[30,1264,1266],{"id":1265},"the-read-side-is-a-projection","The read side is a projection",[14,1268,1269],{},"Marten projections are how anything except the aggregate gets a query. The aggregate answers what is true for this decision. The projection answers what this screen lists. A projection that is a copy of the aggregate, one row per stream, is a snapshot you already have.",[14,1271,1272],{},"A single-stream projection folds one stream into one document.",[51,1274],{"code":1275,"filename":1276,"lang":55},"public class OrderSummaryProjection\n    : SingleStreamProjection\u003COrderSummary, Guid>\n{\n    public OrderSummary Create(OrderPlaced placed) =>\n        new() { Id = placed.OrderId, CustomerId = placed.CustomerId };\n\n    public void Apply(OrderShipped shipped, OrderSummary summary) =>\n        summary.Shipped = true;\n}\n","OrderSummaryProjection.cs",[14,1278,1279],{},"A projection is allowed to ignore events. It is not allowed to invent facts that are not in the stream. If the screen needs a fact the stream does not have, the event is missing, and the write side is the place to add it.",[30,1281,1283],{"id":1282},"inline-or-the-async-daemon","Inline, or the async daemon",[14,1285,1286,1287,1290,1291,1293],{},"Three lifecycles. ",[38,1288,1289],{},"Live"," folds the stream when something reads it and stores nothing. I use it for a short stream and a rare read. A list screen that live-folds every order on the page will read ",[38,1292,1117],{}," for every row.",[14,1295,1296,1299,1300,1303,1304,1306],{},[38,1297,1298],{},"ProjectionLifecycle.Inline",", and ",[38,1301,1302],{},"SnapshotLifecycle.Inline"," for the aggregate, run in the same transaction as the append. The document is updated before ",[38,1305,720],{}," returns. The cost sits on the write. I use it when the caller cannot leave without that row, and when the fold is cheap. The order snapshot is this case.",[14,1308,1309,1312,1313,1316],{},[38,1310,1311],{},"ProjectionLifecycle.Async"," is the daemon. The append commits. The projection catches up from ",[38,1314,1315],{},"mt_event_progression",". Lag is normal. A screen that treats lag as a bug will page you for ordinary load. The order list is this case.",[51,1318],{"code":1319,"filename":1320,"lang":55},"builder.Services.AddMarten(opts =>\n{\n    opts.Connection(connectionString);\n    opts.Projections.Snapshot\u003COrder>(SnapshotLifecycle.Inline);\n    opts.Projections.Add\u003COrderSummaryProjection>(ProjectionLifecycle.Async);\n})\n.AddAsyncDaemon(DaemonMode.HotCold);\n","RegisterProjections.cs",[14,1322,1323,1326,1327,1330,1331,1334,1335,1338,1339,1341],{},[38,1324,1325],{},"AddAsyncDaemon"," is what starts the daemon. Registering a projection as ",[38,1328,1329],{},"Async"," and never calling it leaves the list frozen at its last rebuild. ",[38,1332,1333],{},"HotCold"," elects one leader per projection. ",[38,1336,1337],{},"Solo"," runs every shard on that process, which is right when you already know the process is the only one. ",[38,1340,1337],{}," on two nodes means two daemons write the same progression.",[14,1343,1344],{},"If a stale row changes a decision, the projection is inline, or the screen reads the stream. If the screen can be a few seconds late, it is async, and the lateness is shown.",[30,1346,1348],{"id":1347},"rebuilds-and-lag","Rebuilds and lag",[14,1350,1351,1352,1354],{},"A rebuild replays the stream into a projection after ",[38,1353,1136],{}," changes. While it runs, that read model is behind the write model on purpose.",[14,1356,1357,1360,1361,1364,1365,1368],{},[38,1358,1359],{},"RebuildProjectionAsync"," rebuilds the projection you already serve. The default teardown runs ",[38,1362,1363],{},"TRUNCATE"," on that document table, then the replay fills it. Until the replay finishes, a query reads an empty table or a partial one. I do that when the screen can be late, or when I can take it offline. ",[38,1366,1367],{},"UseOptimizedProjectionRebuilds"," rebuilds one stream at a time and is not this from-zero truncate. I fix a projection that throws before I rebuild it. A rebuild of a projection that still throws fails louder.",[14,1370,1371,1372,1375,1376,1378],{},"A shape change that has to stay up uses a new projection version. ",[38,1373,1374],{},"[ProjectionVersion(2)]"," makes Marten write different tables. The new version runs ",[38,1377,1329],{}," while it catches up, even when the previous version was inline. The old table keeps serving. The cutover is the moment the query uses the new document. It is not the moment the replay starts.",[14,1380,1381,1382,1384],{},"Know three numbers before you start: how far behind ",[38,1383,1315],{}," is, how long a full replay takes on this database, and which queries are allowed to read the old shape during that window. A rebuild of a projection that walks every event in a large tenant is a maintenance window.",[14,1386,1387,1388,1391,1392,1394,1395,1397],{},"Lag after a deploy is not a failed projection. Lag that grows for an hour is. If the progression mark does not move, the daemon is down or the projection is throwing. The ",[38,1389,1390],{},"projections"," commands are the same JasperFx host as ",[38,1393,964],{}," in the document guide. A process that only calls ",[38,1396,979],{}," will not rebuild anything.",[30,1399,943],{"id":942},[14,1401,1402,1403,374,1405,1407],{},"Event sourcing does not remove vacuum, indexes, or a backup you have restored once. Back up the database, and test a restore, before you argue about snapshot frequency. The event tables are still Marten schema. Production applies them with ",[38,1404,960],{},[38,1406,964],{},", the same step as the document tables.",[14,1409,1410],{},"Inline projections add write latency. Async projections add a process you operate. Either way the events and the documents are in the same PostgreSQL instance unless the write rate says otherwise. A second database for the read model is a replication problem you do not have yet.",[30,1412,1414],{"id":1413},"the-test-is-the-conflict","The test is the conflict",[14,1416,1013,1417,1420,1421,117],{},[38,1418,1419],{},"AppendOne"," and does not commit has not tested the stream. The fact I want is that the second writer keeps the event, and the first writer's commit throws ",[38,1422,866],{},[14,1424,1425],{},"The store in the fixture is the application store, against PostgreSQL, with the inline order snapshot. I build it once.",[51,1427],{"code":1428,"filename":1429,"lang":55},"public async Task a_stale_append_leaves_the_first_event()\n{\n    var orderId = Guid.NewGuid();\n    var customerId = \"cust-1\";\n\n    await using (var setup = store.LightweightSession())\n    {\n        setup.Events.StartStream\u003COrder>(\n            orderId,\n            new OrderPlaced(orderId, customerId));\n        await setup.SaveChangesAsync();\n    }\n\n    await using var first = store.LightweightSession();\n    await using var second = store.LightweightSession();\n\n    var byFirst = await first.Events.FetchForWriting\u003COrder>(orderId);\n    var bySecond = await second.Events.FetchForWriting\u003COrder>(orderId);\n\n    bySecond.AppendOne(new OrderShipped(orderId));\n    await second.SaveChangesAsync();\n\n    byFirst.AppendOne(new OrderShipped(orderId));\n    await Should.ThrowAsync\u003CConcurrencyException>(\n        () => first.SaveChangesAsync());\n}\n","StaleStreamTests.cs",[14,1431,1432,1433,1435],{},"Testcontainers belongs in this test. After the throw, the stream has one ",[38,1434,210],{},". The first session still holds the version it read, and that version is no longer the tip.",{"title":165,"searchDepth":166,"depth":166,"links":1437},[1438,1439,1440,1441,1442,1443,1444,1445,1446],{"id":1089,"depth":166,"text":1090},{"id":1125,"depth":166,"text":1126},{"id":1159,"depth":166,"text":1160},{"id":1204,"depth":166,"text":1205},{"id":1265,"depth":166,"text":1266},{"id":1282,"depth":166,"text":1283},{"id":1347,"depth":166,"text":1348},{"id":942,"depth":166,"text":943},{"id":1413,"depth":166,"text":1414},"Marten event sourcing in production: streams, aggregates, concurrency, and Marten projections on PostgreSQL, including the async daemon and rebuilds.",{"category":176,"author":1449,"readTime":628,"relatedArticles":1450},{"name":178,"avatar":179},[26,630,227],{"title":1061,"description":1447,"keywords":1452},"marten event sourcing, marten projections",{"loc":115},"guides\u002Fmarten-event-sourcing","MIr1a5mZU8QyeDsCiOLhHa-ZKJIdu07cJwQIFFdtpLg",{"id":1457,"title":228,"body":1458,"date":172,"description":1760,"extension":174,"meta":1761,"navigation":184,"path":227,"seo":1764,"sitemap":1766,"stem":1767,"__hash__":1768},"content\u002Fguides\u002Ftransactional-outbox-wolverine.md",{"type":8,"value":1459,"toc":1751},[1460,1465,1468,1483,1487,1493,1496,1500,1503,1514,1527,1530,1534,1545,1549,1555,1566,1570,1579,1586,1590,1605,1609,1612,1615,1647,1650,1654,1657,1663,1667,1684,1690,1693,1697,1703,1706,1717,1726,1730,1736,1748],[11,1461,1462],{},[14,1463,1464],{},"A transactional outbox exists so a committed fact and the message about that fact cannot drift apart.",[14,1466,1467],{},"This guide is that guarantee in Wolverine, with Marten on PostgreSQL. The message is written in the same transaction as the event or the document. A durability agent sends it after the commit. Consumers still see duplicates. The dead-letter table is something you operate.",[14,1469,1470,1471,1473,1474,1476,1477,229,1479,232,1481,117],{},"How the handler decides to emit the message is in ",[24,1472,193],{"href":630},". What the event means inside the stream is in ",[24,1475,116],{"href":115},". The same commit can hold a document instead of an event. That document is ",[24,1478,27],{"href":26},[24,1480,158],{"href":157},[24,1482,163],{"href":162},[30,1484,1486],{"id":1485},"the-failure-the-pattern-removes","The failure the pattern removes",[14,1488,1489,1490,1492],{},"Two writes want to succeed together: the database, and the broker. They will not. If you commit the order and then publish ",[38,1491,373],{},", the process can die in the gap. The order is shipped and the warehouse is never told. If you publish first and then commit, the warehouse starts work and the commit rolls back. The warehouse is picking an order that does not exist.",[14,1494,1495],{},"Neither order is safe. The transactional outbox makes them one write. The outgoing message is a row in PostgreSQL, inserted with the business change. The broker is a later step, and it is allowed to be down.",[30,1497,1499],{"id":1498},"one-postgresql-transaction","One PostgreSQL transaction",[14,1501,1502],{},"Three things have to be on, or you still have two writes.",[14,1504,1505,1506,1509,1510,1513],{},"Marten has to be the message store. ",[38,1507,1508],{},"IntegrateWithWolverine",", from the ",[38,1511,1512],{},"WolverineFx.Marten"," package, registers the inbox and outbox tables with Marten's schema management, starts the durability agent, and adds the transactional middleware. Marten and Wolverine without that package do not include the method. Pass a schema name when you want those tables under their own name.",[14,1515,1516,1517,697,1520,1522,1523,1526],{},"The endpoint has to be durable. Middleware stages a message only when the destination is enrolled. A local queue enrolls with ",[38,1518,1519],{},"UseDurableLocalQueues",[38,1521,505],{}," on that queue. A broker enrolls with ",[38,1524,1525],{},"UseDurableOutbox"," on the publishing endpoint. A RabbitMQ queue that is only buffered sends during the handler. The database can still roll back.",[14,1528,1529],{},"The handler has to use the session Wolverine owns. The middleware commits that session after the method returns. A session you open yourself, and save yourself, is a second transaction.",[51,1531],{"code":1532,"filename":1533,"lang":55},"builder.Services.AddMarten(opts =>\n{\n    opts.Connection(connectionString);\n})\n.IntegrateWithWolverine();\n\nbuilder.Host.UseWolverine(opts =>\n{\n    opts.Policies.UseDurableLocalQueues();\n\n    opts.PublishMessage\u003CNotifyWarehouse>()\n        .ToRabbitQueue(\"warehouse\")\n        .UseDurableOutbox();\n\n    opts.ListenToRabbitQueue(\"warehouse\")\n        .UseDurableInbox();\n});\n","OutboxSetup.cs",[14,1535,1536,1538,1539,1541,1542,1544],{},[38,1537,1519],{}," is the in-process half. The CQRS guide is where that queue is tuned. ",[38,1540,1525],{}," is the half that keeps ",[38,1543,373],{}," in PostgreSQL until RabbitMQ accepts it. I turn on the integration and the endpoint before I talk about retries.",[30,1546,1548],{"id":1547},"the-return-is-the-outbox","The return is the outbox",[14,1550,1551,1552,1554],{},"The handler returns the event and the message. It does not publish on its own, and it does not call ",[38,1553,720],{},". Wolverine appends the event, stages the message, and commits both.",[14,1556,1557,1558,1561,1562,1565],{},"On an aggregate handler the return value is an event unless you say otherwise. A tuple of ",[38,1559,1560],{},"(OrderShipped, NotifyWarehouse)"," appends both to the stream. The warehouse message never leaves. ",[38,1563,1564],{},"OutgoingMessages"," is the type that means \"publish this, do not append it.\"",[51,1567],{"code":1568,"filename":1569,"lang":55},"[AggregateHandler]\npublic static (Events, OutgoingMessages) Handle(ShipOrder command, Order order)\n{\n    if (order.Shipped)\n        throw new InvalidOperationException(\"Already shipped.\");\n\n    var events = new Events();\n    events += new OrderShipped(command.OrderId, command.ShippedAt);\n\n    var messages = new OutgoingMessages();\n    messages.Add(new NotifyWarehouse(command.OrderId));\n\n    return (events, messages);\n}\n","ShipWithOutbox.cs",[14,1571,1572,1573,1575,1576,1578],{},"Throw before the return and nothing is staged. That is the refusal. ",[38,1574,275],{}," here is the stream aggregate from the event sourcing guide. ",[38,1577,313],{}," loads it. This guide does not.",[14,1580,1581,1582,1585],{},"The same commit works for a document. There is no stream, so there is no ",[38,1583,1584],{},"Events"," collection. The returned message is an ordinary cascade, and the durable endpoint puts it in the outbox.",[51,1587],{"code":1588,"filename":1589,"lang":55},"public static NotifyWarehouse Handle(ShipOrder command, IDocumentSession session)\n{\n    session.Store(new Shipment(command.OrderId, command.ShippedAt));\n    return new NotifyWarehouse(command.OrderId);\n}\n","ShipDocument.cs",[14,1591,1592,1594,1595,1597,1598,1601,1602,1604],{},[38,1593,103],{}," versus ",[38,1596,107],{}," is the document guide. The outbox only cares that this session is the one the middleware commits. In a controller, where there is no middleware, the longhand is ",[38,1599,1600],{},"IMartenOutbox.PublishAsync"," and then your own ",[38,1603,720],{},". Inside a handler, that second save commits early, and a message returned afterwards can miss it.",[30,1606,1608],{"id":1607},"what-is-stored","What is stored",[14,1610,1611],{},"Before the commit, the envelope is intent. After the commit, it is a row that says the message must leave. The agent marks it sent and deletes it, or it leaves it for another attempt. You do not delete that row from application code because the HTTP request finished. The request finishing is not the send.",[14,1613,1614],{},"Three tables, created with the Marten schema:",[438,1616,1617,1631,1641],{},[441,1618,1619,1622,1623,1626,1627,1630],{},[38,1620,1621],{},"wolverine_outgoing_envelopes",". Waiting to be sent. A growing pile means the agent is down, the broker is refusing, or a row is stuck on a node that died. ",[38,1624,1625],{},"owner_id"," of 0 means any node may send it. ",[38,1628,1629],{},"OutboxStaleTime"," bumps old owned rows back to 0. It is opt-in, because it changes the schema. I set it in hours, from the longest send I am willing to wait, and I do not set it in seconds.",[441,1632,1633,1636,1637,1640],{},[38,1634,1635],{},"wolverine_incoming_envelopes",". The inbox. A handled row stays so a redelivery of the same envelope id is recognized, then the agent deletes it. The window is ",[38,1638,1639],{},"Durability.KeepAfterMessageHandling",", and the default is five minutes. Shorter than the broker's redelivery and the handler runs twice. Longer and the table is a log. I leave five minutes until I have seen a duplicate arrive later than that.",[441,1642,1643,1646],{},[38,1644,1645],{},"wolverine_dead_letters",". Will not be sent again until a person acts.",[14,1648,1649],{},"I alert on the first and the third. A handled inbox row inside its five minutes is the design.",[30,1651,1653],{"id":1652},"the-consumer-still-sees-duplicates","The consumer still sees duplicates",[14,1655,1656],{},"The outbox gives you at-least-once delivery. The agent can send, crash before it records the send, and send again. The broker can deliver twice. The inbox records the envelope id and drops a duplicate it has already executed. That covers the transport duplicate. It does not cover two different messages that mean the same business fact.",[14,1658,1659,1660,1662],{},"The handler has to tolerate both. A second ",[38,1661,373],{}," for an order that was already told is a no-op. Charging a customer twice is the error. The envelope id saves you from the retry. The order id saves you from a second command.",[51,1664],{"code":1665,"filename":1666,"lang":55},"public static class NotifyWarehouseHandler\n{\n    public static void Configure(HandlerChain chain)\n    {\n        chain.OnException\u003CDocumentAlreadyExistsException>()\n            .Discard();\n    }\n\n    public static async Task Handle(NotifyWarehouse message, IDocumentSession session)\n    {\n        var notice = await session.LoadAsync\u003CWarehouseNotice>(message.OrderId);\n        if (notice is not null)\n            return;\n\n        session.Insert(new WarehouseNotice(message.OrderId));\n    }\n}\n","IdempotentNotify.cs",[14,1668,1669,1672,1673,1675,1676,1678,1679,736,1681,1683],{},[38,1670,1671],{},"WarehouseNotice"," uses the order id as its document id. The load covers the ordinary retry. Two deliveries at the same moment can both see nothing. ",[38,1674,107],{}," only stages the write. The middleware commits after ",[38,1677,247],{}," returns, and the second commit fails with ",[38,1680,729],{},[38,1682,107],{}," never sees that exception.",[14,1685,1686,1687,1689],{},"The other delivery already stored the notice. I discard the exception on this handler chain, so the default policy does not move the message to ",[38,1688,1645],{},". A process-wide discard would also swallow a first insert that failed because the document was already there for a real reason.",[14,1691,1692],{},"If this handler also sends mail, the send is a second system. Record the notice in this transaction, and let a later message do the mail. The mailer has to be idempotent on the order id as well. A send that happens before the commit, and a commit that then rolls back, is the original bug one level down.",[30,1694,1696],{"id":1695},"dead-letters-and-cleanup","Dead letters and cleanup",[14,1698,1699,1700,1702],{},"Retries are for a broker that blinked. A handler that rejects the same message every time is a poison message. It will fill the log and the CPU. After the retry policy is exhausted, Wolverine moves it to the error queue. With Marten as the message store, that queue is ",[38,1701,1645],{},". Someone replays it after the bug is fixed, or discards it. Nobody should learn about it from a customer.",[14,1704,1705],{},"I keep the retry count small. A message that fails the same way three times will fail a fourth. Move it, page, and fix the handler.",[14,1707,1708,1709,1712,1713,1716],{},"Expiration of that table is off by default. When you turn on ",[38,1710,1711],{},"Durability.DeadLetterQueueExpirationEnabled",", the retention is ",[38,1714,1715],{},"DeadLetterQueueExpiration",", and the default of that value is ten days. I enable it once someone is actually reading the table. Until then, expiration hides messages nobody replayed. I still do not keep the table as the audit log. Audit belongs in the Marten stream, or in the document you already decided to keep. The outbox is a queue. A queue that never shrinks will slow the agent that is supposed to drain it.",[14,1718,1719,1720,1722,1723,1725],{},"Two checks I put on a dashboard, and only these: depth of ",[38,1721,1621],{},", and depth of ",[38,1724,1645],{},". Depth of the event stream is a different page.",[30,1727,1729],{"id":1728},"the-test-is-the-rollback","The test is the rollback",[14,1731,1732,1733,1735],{},"The tracked session in the CQRS guide proves that a handler returned ",[38,1734,373],{},". It does not prove the outbox. A host with no PostgreSQL has nothing to roll back.",[14,1737,1738,1739,1741,1742,1744,1745,1747],{},"The test I add here has two facts, and the host is one fixture with Marten, built once. An order that is already shipped: ",[38,1740,206],{}," throws, and ",[38,1743,1621],{}," has no row for that attempt. An open order: the handler returns, and the same table has the envelope until the agent sends it. ",[38,1746,565],{}," waits on RabbitMQ. I turn that on only for the test that has a broker. The rollback fact is local to PostgreSQL.",[14,1749,1750],{},"Testcontainers belongs in this test. The cascade of return values does not need it, and that test stays in the other guide.",{"title":165,"searchDepth":166,"depth":166,"links":1752},[1753,1754,1755,1756,1757,1758,1759],{"id":1485,"depth":166,"text":1486},{"id":1498,"depth":166,"text":1499},{"id":1547,"depth":166,"text":1548},{"id":1607,"depth":166,"text":1608},{"id":1652,"depth":166,"text":1653},{"id":1695,"depth":166,"text":1696},{"id":1728,"depth":166,"text":1729},"The transactional outbox with Wolverine and Marten: one PostgreSQL transaction for the event and the outgoing message, then idempotent consumers and the dead-letter table.",{"category":176,"author":1762,"readTime":1052,"relatedArticles":1763},{"name":178,"avatar":179},[630,115,26],{"title":228,"description":1760,"keywords":1765},"transactional outbox",{"loc":227},"guides\u002Ftransactional-outbox-wolverine","jPCPZwyziig4RvqI-CAubgEYayvEYKM_i1Ek2HragTA",{"id":1770,"title":1771,"body":1772,"date":1985,"description":1986,"extension":174,"meta":1987,"navigation":184,"path":214,"seo":1992,"sitemap":1993,"stem":1994,"__hash__":1995},"content\u002Fblog\u002Fmarten-wolverine\u002Fmediatr-vs-wolverine.md","MediatR vs. Wolverine: Architecture, Ergonomics, and Feature Matrix",{"type":8,"value":1773,"toc":1974},[1774,1779,1782,1785,1788,1792,1795,1800,1803,1807,1810,1847,1851,1854,1857,1860,1886,1890,1893,1897,1901,1904,1907,1931,1935,1938,1958,1962,1968],[11,1775,1776],{},[14,1777,1778],{},"MediatR taught .NET teams to keep HTTP away from business logic. Wolverine asks whether that boundary still needs a marker interface.",[14,1780,1781],{},"For years, MediatR has served as the default standard for implementing CQRS and in-process decoupling in .NET applications. It introduced clean boundary separation between HTTP controllers and business logic, providing an accessible, predictable structure for monolithic codebases.",[14,1783,1784],{},"As distributed requirements, event-driven architectures, and performance constraints have matured, the limitations of simple in-memory mediator patterns have become clearer. Wolverine, part of the Critter Stack ecosystem, approaches this challenge from a fundamentally different perspective: replacing interface ceremony with code generation and integrating messaging capabilities directly into the framework core.",[14,1786,1787],{},"This article breaks down the architectural, ergonomic, and operational differences between MediatR and Wolverine to evaluate when each tool makes sense.",[30,1789,1791],{"id":1790},"ergonomics-interface-ceremony-vs-pure-pocos","Ergonomics: interface ceremony vs. pure POCOs",[14,1793,1794],{},"The structural difference is evident in handler and message definitions. MediatR enforces strict generic interfaces, whereas Wolverine relies on conventions and dynamic code generation to eliminate boilerplate.",[1796,1797,1799],"h3",{"id":1798},"mediatr-marker-interfaces-and-rigid-signatures","MediatR: marker interfaces and rigid signatures",[14,1801,1802],{},"In MediatR, every command, query, and handler must implement specific interfaces:",[51,1804],{"code":1805,"filename":1806,"lang":55},"\u002F\u002F 1. Command requires marker interface\npublic record CreateOrder(string CustomerId, decimal Amount)\n    : IRequest\u003COrderResult>;\n\n\u002F\u002F 2. Handler ceremony and constructor injection boilerplate\npublic class CreateOrderHandler : IRequestHandler\u003CCreateOrder, OrderResult>\n{\n    private readonly ILogger\u003CCreateOrderHandler> _logger;\n\n    public CreateOrderHandler(ILogger\u003CCreateOrderHandler> logger)\n    {\n        _logger = logger;\n    }\n\n    public Task\u003COrderResult> Handle(CreateOrder request, CancellationToken ct)\n    {\n        _logger.LogInformation(\"Processing order for {CustomerId}...\", request.CustomerId);\n        return Task.FromResult(new OrderResult(Guid.NewGuid()));\n    }\n}\n","CreateOrderHandler.cs",[14,1808,1809],{},"This pattern introduces several architectural constraints:",[438,1811,1812,1825,1831],{},[441,1813,1814,1818,1819,374,1822,117],{},[1815,1816,1817],"strong",{},"Interface coupling."," Commands and handlers are coupled to ",[38,1820,1821],{},"IRequest\u003CT>",[38,1823,1824],{},"IRequestHandler\u003CTRequest, TResponse>",[441,1826,1827,1830],{},[1815,1828,1829],{},"Constructor clutter."," Dependencies require explicit constructor injection and private field assignments.",[441,1832,1833,1836,1837,1840,1841,1843,1844,117],{},[1815,1834,1835],{},"Forced asynchrony."," Handlers must return ",[38,1838,1839],{},"Task\u003CT>"," and accept a ",[38,1842,333],{},", even when handling pure in-memory, synchronous computations. This forces artificial state machines and allocations via ",[38,1845,1846],{},"Task.FromResult",[1796,1848,1850],{"id":1849},"wolverine-convention-over-ceremony","Wolverine: convention over ceremony",[14,1852,1853],{},"Wolverine strips away external interface constraints entirely:",[51,1855],{"code":1856,"filename":1806,"lang":55},"\u002F\u002F 1. Clean, zero-interface C# POCO\npublic record CreateOrder(string CustomerId, decimal Amount);\n\n\u002F\u002F 2. Static class and direct method injection (no constructor DI)\npublic static class CreateOrderHandler\n{\n    public static OrderResult Handle(CreateOrder cmd, ILogger logger)\n    {\n        logger.LogInformation(\"Processing order for {CustomerId}...\", cmd.CustomerId);\n        return new OrderResult(Guid.NewGuid());\n    }\n}\n",[14,1858,1859],{},"Key ergonomic differences:",[438,1861,1862,1868,1877],{},[441,1863,1864,1867],{},[1815,1865,1866],{},"Zero-interface messaging."," Messages remain pure POCOs without framework references.",[441,1869,1870,1873,1874,1876],{},[1815,1871,1872],{},"Method-level dependency injection."," Handlers can be declared as static classes, with dependencies passed directly into the ",[38,1875,247],{}," method parameters alongside the command.",[441,1878,1879,1882,1883,1885],{},[1815,1880,1881],{},"Flexible signatures."," If an operation is synchronous, return the raw type directly. If a ",[38,1884,333],{}," is needed, declare it; if not, omit it.",[30,1887,1889],{"id":1888},"comprehensive-feature-matrix","Comprehensive feature matrix",[14,1891,1892],{},"While MediatR focuses solely on in-process dispatching, Wolverine acts as both an in-memory mediator and an integration bus.",[119,1894],{":columns":1895,":rows":1896},"[\"Feature\",\"MediatR\",\"Wolverine\"]","[[\"Scope and complexity\",{\"text\":\"Minimal in-process mediator\"},{\"text\":\"Full messaging and integration bus\",\"weight\":\"semibold\"}],[\"Team onboarding\",{\"text\":\"De-facto industry standard\",\"icon\":\"good\"},{\"text\":\"Emerging, convention-heavy\",\"icon\":\"caution\"}],[\"Coding style\",{\"text\":\"IRequest and handlers\"},{\"text\":\"Zero-interface POCO\",\"weight\":\"semibold\"}],[\"HTTP endpoints\",{\"text\":\"Minimal API or MVC only\",\"icon\":\"cross\"},{\"text\":\"Wolverine.Http, no MVC\",\"icon\":\"check\",\"weight\":\"semibold\"}],[\"Cascading messages\",{\"text\":\"Explicit dispatch calls\",\"icon\":\"cross\"},{\"text\":\"Native return types\",\"icon\":\"check\",\"weight\":\"semibold\"}],[\"Transactional outbox and inbox\",{\"text\":\"External libraries\",\"icon\":\"cross\"},{\"text\":\"EF Core, PostgreSQL, SQL Server, Marten\",\"icon\":\"check\",\"weight\":\"semibold\"}],[\"Async brokers\",{\"text\":\"In-process only\",\"icon\":\"cross\"},{\"text\":\"RabbitMQ, Kafka, Azure Service Bus, SQS\",\"icon\":\"check\",\"weight\":\"semibold\"}],[\"Retry and dead letter\",{\"text\":\"Built by hand in behaviors\",\"icon\":\"cross\"},{\"text\":\"Per exception, plus circuit breakers\",\"icon\":\"check\",\"weight\":\"semibold\"}],[\"Sagas\",{\"text\":\"Not supported\",\"icon\":\"cross\"},{\"text\":\"First-class stateful sagas\",\"icon\":\"check\",\"weight\":\"semibold\"}],[\"OpenTelemetry\",{\"text\":\"Custom pipeline\",\"icon\":\"cross\"},{\"text\":\"Built in\",\"icon\":\"check\",\"weight\":\"semibold\"}],[\"Native AOT and cold start\",{\"text\":\"Zero overhead out of the box\",\"icon\":\"good\"},{\"text\":\"Needs code generation\",\"icon\":\"caution\"}],[\"In-memory dispatch\",{\"text\":\"Direct delegate invoke\",\"icon\":\"bolt\"},{\"text\":\"Full message envelope\"}]]",[30,1898,1900],{"id":1899},"architectural-scope-beyond-in-process-dispatch","Architectural scope: beyond in-process dispatch",[14,1902,1903],{},"MediatR stops at the process boundary. When requirements involve asynchronous background processing, external message queues, or reliable transaction boundaries, developers must build or integrate secondary frameworks (such as MassTransit or custom outbox implementations).",[14,1905,1906],{},"Wolverine bridges this gap by unifying local invocation and external messaging into a single abstraction model:",[438,1908,1909,1919,1925],{},[441,1910,1911,1914,1915,1918],{},[1815,1912,1913],{},"Cascading messages via return types."," Handlers can return tuples, for example ",[38,1916,1917],{},"(OrderResult Result, OrderPlaced Event)",". Wolverine automatically sends the event downstream to message queues or outbox storage while returning the result to the direct caller, avoiding manual dispatcher calls.",[441,1920,1921,1924],{},[1815,1922,1923],{},"Native transactional outbox."," Ensures consistency between database writes and message broker dispatching natively, without third-party plugins.",[441,1926,1927,1930],{},[1815,1928,1929],{},"Resilience and error handling."," Wolverine provides declarative retry policies, fallback logic, and dead-letter queues configured per message or exception type.",[30,1932,1934],{"id":1933},"operational-trade-offs","Operational trade-offs",[14,1936,1937],{},"Choosing between these frameworks requires weighing specific architectural compromises:",[438,1939,1940,1946,1952],{},[441,1941,1942,1945],{},[1815,1943,1944],{},"Conventions vs. explicit types."," MediatR’s explicit interface hierarchies make code navigation straightforward via standard IDE tools. Wolverine’s convention-based discovery requires developers to understand its method naming and parameter binding rules.",[441,1947,1948,1951],{},[1815,1949,1950],{},"Startup performance and AOT."," MediatR relies on standard DI service registration. Wolverine uses Lamar and runtime Roslyn compilation by default to maximize throughput. For environments requiring minimal cold-start latencies or Native AOT, teams must incorporate ahead-of-time code pre-generation into their build pipelines.",[441,1953,1954,1957],{},[1815,1955,1956],{},"Adoption and maintenance."," MediatR has an established track record and extensive documentation across the enterprise ecosystem. Wolverine offers modern ergonomics and broader built-in capabilities, but onboarding requires familiarity with convention-driven patterns.",[30,1959,1961],{"id":1960},"which-should-you-choose","Which should you choose?",[14,1963,1964,1967],{},[1815,1965,1966],{},"Choose MediatR"," if your application is a modular monolith requiring strict in-memory command dispatching, explicit interface-driven navigation, and zero additional infrastructure dependencies.",[14,1969,1970,1973],{},[1815,1971,1972],{},"Choose Wolverine"," if your application needs an integrated messaging pipeline with transactional outbox support, message broker integration, minimal ceremony, and declarative resilience built directly into the runtime.",{"title":165,"searchDepth":166,"depth":166,"links":1975},[1976,1981,1982,1983,1984],{"id":1790,"depth":166,"text":1791,"children":1977},[1978,1980],{"id":1798,"depth":1979,"text":1799},3,{"id":1849,"depth":1979,"text":1850},{"id":1888,"depth":166,"text":1889},{"id":1899,"depth":166,"text":1900},{"id":1933,"depth":166,"text":1934},{"id":1960,"depth":166,"text":1961},"2026-09-25","MediatR is the default for in-process CQRS in .NET. Wolverine drops the interface ceremony and puts messaging, outbox, and brokers in the framework. Here is when each one fits.",{"category":176,"author":1988,"readTime":1989,"image":1990,"imageAlt":1991,"imageShow":183},{"name":178,"avatar":179},"8","blog\u002Fmarten-wolverine\u002Fmediatr-vs-wolverine\u002Fmediatr-vs-wolverine.png","A handler is a method. Messaging stays in the framework.",{"title":1771,"description":1986},{"loc":214},"blog\u002Fmarten-wolverine\u002Fmediatr-vs-wolverine","Byo8_hFw_7CiUbMe-cdKtKnfKmS79efnJZKUnY7shi4",{"id":1997,"title":1998,"body":1999,"date":2131,"description":2132,"extension":174,"meta":2133,"navigation":184,"path":2139,"seo":2140,"sitemap":2141,"stem":2142,"__hash__":2143},"content\u002Fblog\u002Fpipelines\u002Fwhy-wall-clock-time-lies.md","Why wall-clock time lies in CI\u002FCD",{"type":8,"value":2000,"toc":2125},[2001,2006,2009,2012,2015,2029,2036,2040,2047,2050,2064,2067,2071,2078,2084,2088,2091,2094,2097,2100,2103,2109,2112,2116,2119,2122],[11,2002,2003],{},[14,2004,2005],{},"Five seconds on the clock. Ninety percent less disk I\u002FO. A third of the CPU work gone. When you only watch the stopwatch, you miss the build.",[14,2007,2008],{},"I measured a nopCommerce monorepo — 40+ projects, modular plugins — and changed how MSBuild copies references.",[14,2010,2011],{},"On a clean build the clock moved 93s → 88s. Five seconds. Easy to call it a failure and walk away.",[14,2013,2014],{},"Look under the hood:",[438,2016,2017,2020,2023,2026],{},[441,2018,2019],{},"Total CPU work: 307s → 202s (−34%)",[441,2021,2022],{},"Artifacts written: 1,101 MB → 109 MB (−90%)",[441,2024,2025],{},"Disk I\u002FO phase: 16.8% → 0%",[441,2027,2028],{},"TFM handshake target: −86%",[14,2030,2031,2032,2035],{},"That is 105 vCPU-seconds and a gigabyte of disk I\u002FO off ",[1815,2033,2034],{},"one"," build. The stopwatch barely noticed.",[30,2037,2039],{"id":2038},"the-copylocal-cascade","The CopyLocal cascade",[14,2041,2042,2043,2046],{},"nopCommerce plugins each referenced the shared core projects. With Copy Local left on, each of the 30+ plugins copied about 35 MB of those assemblies into its own ",[38,2044,2045],{},"bin\u002F"," folder. Over a gigabyte of duplicate binaries on every build. The web host already had one copy. The plugins did not need thirty more.",[14,2048,2049],{},"MSBuild also spent time negotiating identical target frameworks across those plugin references — the TFM handshake.",[14,2051,2052,2053,2056,2057,2059,2060,2063],{},"The change was in ",[38,2054,2055],{},"Directory.Build.targets",". For plugin projects, shared project references stopped copying into each plugin ",[38,2058,2045],{}," (",[38,2061,2062],{},"Private=false","), and the TFM handshake on those references was skipped. Lock-file assemblies were no longer copied the same way. The host already had the core; the plugins stopped duplicating it.",[14,2065,2066],{},"That is the mechanism, not a recipe. The interesting part is what the clock did next.",[30,2068,2070],{"id":2069},"why-the-clock-barely-moved","Why the clock barely moved",[14,2072,2073,2074,2077],{},"MSBuild is a DAG. Clearing the I\u002FO and TFM bottleneck did not make the graph shorter. It exposed the next serial blocker: nopCommerce’s custom plugin cleanup target (",[38,2075,2076],{},"NopTarget",").",[14,2079,2080,2081,2083],{},"The wait moved. It did not disappear. If you only read wall-clock, you conclude “5 seconds, not worth it.” If you read CPU work, I\u002FO, and the critical path, you see the copies are gone — and ",[38,2082,2076],{}," was always behind them.",[30,2085,2087],{"id":2086},"developers-do-not-live-on-clean-builds","Developers do not live on clean builds",[14,2089,2090],{},"A 5-second clean-build gain is a side effect. The inner loop is where people work.",[14,2092,2093],{},"Before the change, touching a single core file triggered the same CopyLocal cascade — 1,101 MB of duplicate binaries on every rebuild. After: 109 MB. That I\u002FO number is measured. The hours below are not: they only ask what happens if you treat the avoided write as the same cautious 5 seconds the clean build showed on the clock.",[14,2095,2096],{},"For a team of 15, at 15 incremental builds a day:",[14,2098,2099],{},"15 × 15 × 5s ≈ 19 minutes\u002Fday ≈ 6.3 hours\u002Fmonth.",[14,2101,2102],{},"Nearly a workday a month, with no change to business logic. Harder inner loops scale from there. I am not going to invent a 15s → 2s TDD figure for a latency I did not measure on this repo.",[14,2104,2105,2106,2108],{},"The claim that does not need a model: 1,101 MB → 109 MB, every time that cascade used to run. The 5-second clean build was the side effect. The win was killing the disk bottleneck so the next serial wait (",[38,2107,2076],{},") became visible — and so a rebuild no longer wrote a gigabyte of copies.",[14,2110,2111],{},"This is rush-hour traffic inside the machine. Clearing it does not make the compiler faster. It means the verdict can arrive while the thought is still intact.",[30,2113,2115],{"id":2114},"the-build-was-the-easy-part","The build was the easy part",[14,2117,2118],{},"This was just compile and copy. Test suites on this nopCommerce tree often outlast the build — flaky retries, suites that get slower every sprint. I have not measured that on this repo yet.",[14,2120,2121],{},"If that much came back from copy and handshake alone, it is worth asking what is hiding in the test pipeline too.",[14,2123,2124],{},"Stop measuring this build only by the clock. Track CPU work, I\u002FO footprint, and the critical path. The next serial blocker was already in the graph. The stopwatch was not going to introduce you.",{"title":165,"searchDepth":166,"depth":166,"links":2126},[2127,2128,2129,2130],{"id":2038,"depth":166,"text":2039},{"id":2069,"depth":166,"text":2070},{"id":2086,"depth":166,"text":2087},{"id":2114,"depth":166,"text":2115},"2026-09-19","A nopCommerce monorepo looked like a 5-second win. Under the hood it was 90% less disk I\u002FO and a third less CPU. The clock is the wrong instrument.",{"category":2134,"author":2135,"readTime":2136,"image":2137,"imageAlt":2138},"pipelines",{"name":178,"avatar":179},"7","blog\u002Fpipelines\u002Fwhy-wall-clock-time-lies\u002Fwhy-wall-clock-time-lies.png","Codebase Pulse before and after: build 93s to 89s, CPU work 307s to 202s, artifacts 1101 MB to 110 MB","\u002Fblog\u002Fpipelines\u002Fwhy-wall-clock-time-lies",{"title":1998,"description":2132},{"loc":2139},"blog\u002Fpipelines\u002Fwhy-wall-clock-time-lies","shTH83LHqW_Bv4V2HEaWIJr0UYfS0ldKMYSrfsq8VQE",1791889475602]