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.
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.
The aggregate became a join
The symptoms show up before anyone argues about databases. Loading one order walks every relationship the screen might need. Include chains grow. AsSplitQuery appears because one query returned a cartesian product. The Fluent API in OnModelCreating holds the mapping the class no longer shows.
LoadOrderEfCore.cs
var order = await db.Orders
.Include(o => o.Items)
.ThenInclude(i => i.Discounts)
.Include(o => o.ShippingAddress)
.Include(o => o.StatusHistory)
.AsSplitQuery()
.SingleAsync(o => o.Id == orderId);
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.
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.
Why the default stays
Three habits, and they reinforce each other.
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.
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 Include chain above and calls that design.
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.
One roundtrip, same engine
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 mt_doc_order. The body is JSONB, or JSON on SQL Server. There is no line-item table unless you create one.
LoadOrderDocument.cs
var order = await session.LoadAsync<Order>(orderId);
order.ApplyDiscount(coupon);
session.Store(order);
await session.SaveChangesAsync();
Store is the upsert. The rules for Insert, 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 Marten Event Sourcing in Production.
| EF Core | Marten / Polecat | |
|---|---|---|
| Model | Tables and JOINs | JSON document |
| Database | Your Postgres or SQL Server | Your Postgres or SQL Server |
| ACID across models | Yes. Relational only. | Yes. One transaction. |
| Aggregates | Includes and mapping | Natural and typed |
| Schema friction | Schema changes need a migration. | Low |
"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 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.
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.
Both models, one instance
I do not throw EF Core away to adopt the document. One PostgreSQL or SQL Server instance holds both.
On one side, Marten or Polecat: the aggregates that are loaded and saved whole, one roundtrip by id, mt_doc_order. 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.
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.
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.
Top comments (0)