The same billing flow written two ways, both runnable, both hitting the same MySQL server.
01-before/ is the red box: an Invoice() written the normal way, with the three domains
(Warehouse / Sales / Accounting) and the I/O living inside one method body.
02-puppet/ is that same flow taken apart with a Puppeteer puppet — same amounts, same
invoice, a domain without a single insert, response or output format.
This is not an example of what to do. It is an example of what is done today, written so it can be run, poked at and measured — and then put side by side with the other half, so the comparison needs no diagram in between.
The red box is written with the full ceremony of a commercial .NET application: dependency
injection, interfaces for infrastructure, validated typed options, async/await, structured
logging, wrapped exceptions, the repository pattern, an explicit transaction, and two hosts
(console and web API) over one composition.
That is on purpose, and it is the argument. The more textbook-correct that code is, the clearer it becomes that the coupling and the I/O contamination were not a lack of discipline. The layers took nothing away — they put an interface in front of it.
Build:
dotnet build CashierExample.slnxDatabases — and the asymmetry starts here, before anything runs.
The puppet has no database to define. There is not a single CREATE TABLE for it in this
repository: the engine creates its own journal on first run, and nobody had to declare its shape.
Run it with --journal file and there is no database at all — the journal is a readable .txt.
Against MySQL it asks for exactly one thing: that an empty database named cashier_puppet exists.
The red box cannot start without tables written by hand — a repository inside the domain
writes the INSERTs, so somebody had to declare their shape first. That setup lives in
01-before/setup/: the schema, the one command that applies it
(which also creates the puppet's empty database), credentials, and the queries to check
afterwards.
The red box, console — the while (!exit) from the drawing:
dotnet run --project 01-before/Cashier.CliThe puppet, console:
dotnet run --project 02-puppet/Cashier.Puppet.CliCommands, in both: invoice, invoice tax, exit. The puppet adds journal and stock.
The web hosts — same domain, real HttpContext:
dotnet run --project 01-before/Cashier.Api --urls http://127.0.0.1:5199curl -X POST "http://127.0.0.1:5199/api/invoicing?destination=tax"dotnet run --project 02-puppet/Cashier.Puppet.Api --urls http://127.0.0.1:5200curl -X POST "http://127.0.0.1:5200/api/invoicing?destination=tax"One at a time for the puppet: console and API host the same actor over the same journal, and a journal has a single writer.
The three that make the point without any explanation:
printf 'invoice\ninvoice tax\njournal\nexit\n' | dotnet run --project 02-puppet/Cashier.Puppet.CliTwo sales, two formats, one journal that cannot tell them apart.
dotnet run --project 02-puppet/Cashier.Puppet.Cli -- --authorized 3000The bank authorizes less than the items are worth. Nobody said "no" — the Check computed it. And
the journal does not move.
dotnet run --project 02-puppet/Cashier.Puppet.Cli -- --journal fileThe journal moves from MySQL to a readable .txt. One argument, and not one line of the domain,
the verb, the Check or the print changes.
Other flags: --connection "<string>" for a different MySQL, --authorization-url <url> to make a
real REST call (point it at a dead port to see the rejection path).
01-before/Cashier.Domain/Sales/InvoicingService.cs
public async Task InvoiceAsync(InvoiceDestination destination, CancellationToken ct = default)
{
var c = CreateCart();
c.AddProducts(1, 2, 3);
w.CommitStock(c);
bool Successful = await authorizer.RequestAuthorizationAsync(c.Amount, ct); // I/O
if (Successful)
{
await c.SaveAsync(repository, ct); acc.Post(c); // I/O
}
else
{
web.Respond(400); // I/O
return;
}
w.DeductStock(c);
if (destination == InvoiceDestination.TaxAuthority)
await xmlGenerator.GenerateInvoiceXmlAsync(c, ct); // I/O
else
await printer.GenerateInvoiceAsync(c, ct); // I/O
}Three different things live in there:
| Domain | CreateCart, AddProducts, CommitStock, Save, Post, DeductStock |
| I/O | RequestAuthorization (REST), Save (MySQL), Respond(400) (web), two file writers |
| Control | the if/else over Successful, the return halfway through, the destination branch |
Cashier.Cli and Cashier.Api host the same InvoicingService, with the same composition
(AddCashier). The only difference is who is running it. And it shows:
# API — there is an HttpContext
info WebResponse HTTP/1.1 400 Bad Request — written on the real response
→ curl returns HTTP 400
# Console — there is no HttpContext
warn WebResponse HTTP/1.1 400 Bad Request — there is no HttpContext.
This domain is written to live inside a web pipeline.
The domain decides the HTTP status code on its own. The controller — which ought to be the one
deciding the response — is left with no choice but to ask the Response object what the domain
did behind its back:
if (Response.StatusCode == StatusCodes.Status400BadRequest)
return new EmptyResult();And there is an even more literal trace: Cashier.Domain.csproj declares
<FrameworkReference Include="Microsoft.AspNetCore.App" />. The domain depends on ASP.NET
Core, which is why even the console has to drag the web shared framework along.
01-before/
├─ Cashier.Domain/
│ ├─ Sales/ Cart · CartLine · PriceList · InvoiceDestination
│ │ InvoicingService · IInvoicingRepository · InvoicingRepository
│ ├─ Warehousing/ Warehouse
│ ├─ Accounting/ LedgerEntries
│ ├─ Infrastructure/ Authorizer · InvoicePrinter · InvoiceXmlGenerator · WebResponse
│ ├─ Configuration/ MySqlOptions · AuthorizationOptions · ServiceCollectionExtensions
│ └─ Exceptions/ PersistenceException
├─ Cashier.Cli/ Program (while !exit) · CashierFormatter
├─ Cashier.Api/ Program · Controllers/InvoicingController
└─ setup/ schema.sql · README — the tables the red box cannot run without
| File | What it shows |
|---|---|
Sales/InvoicingService.cs |
The red box. The Sales→Warehouse and Sales→Accounting arrows are born here, in two lines. |
Sales/Cart.cs |
The domain object knows how to persist itself: it cannot complete its life cycle without a database on the other side. |
Sales/InvoicingRepository.cs |
The real SQL, with an interface, a transaction and a wrapped exception. It was not taken out of the domain — it moved one file down. |
Sales/PriceList.cs |
The sale price lives in Sales. When it gets stored "next to the product" in the warehouse, that is where everything starts to blend. |
Warehousing/Warehouse.cs |
Takes the whole Cart and iterates its products from the inside. The warehouse ended up talking about sales. |
Accounting/LedgerEntries.cs |
Deliberately hollow. All that matters is that somebody calls it from InvoiceAsync(). |
Infrastructure/Authorizer.cs |
A typed HTTP client, with timeout and cancellation. Impeccable — and still I/O in the middle of the domain. |
Infrastructure/WebResponse.cs |
The Response(400), written on the real HTTP response. |
Infrastructure/InvoicePrinter.cs |
Presentation format decided inside the domain. Another destination = another case. |
Configuration/ServiceCollectionExtensions.cs |
The composition. This is where you can see Warehouse and Accounting registered concrete, on purpose. |
A deliberate detail in that composition: there are interfaces for infrastructure
(IInvoicingRepository, IAuthorizer, IInvoicePrinter, IInvoiceXmlGenerator, IWebResponse)
but none for Warehouse or Accounting. An IWarehouse would invert the dependency and erase
precisely the two arrows that are the point of the example.
The Infrastructure/ folder is cosmetic separation: it is in the same assembly and
InvoicingService calls it directly. Nothing was removed — it was hidden.
The drawing shows three boxes. Splitting it into three projects does not compile.
w.CommitStock(c) takes the Cart, which is Sales vocabulary ⇒ Warehouse depends on Sales. And
InvoicingService lives in Sales and calls Warehouse ⇒ Sales depends on Warehouse. A cycle.
99-attempt-3-dlls/ has that attempt written out, with the MSBuild error that proves it. The
coupling is not a design opinion: the tooling states it, before the C# compiler is even reached.
It is the expected objection, and it is worth answering before it interrupts.
Yes — a Domain that references Microsoft.AspNetCore.App and MySqlConnector would not pass a
modern review. That is deliberate, and it is the exhibit, not an oversight.
Now, three things:
1. It is not an invented strawman. The same diagnosis applied to dotnet-eShop — Microsoft's
own reference application, unrelated to the authors — finds the same manifestations:
orders.Description conditioned by OrderStatusId, an explicit Ignore(b => b.DomainEvents), and
IntegrationEventLog.Content stored as a blob of the received DTO. If the vendor's own canonical
example has them, it is not a man of straw.
2. Clean Architecture separates layers, not domains. In the standard template, Cart,
Warehouse and LedgerEntries all three live in the same Domain project. Moving the I/O into an
Infrastructure assembly does not touch the Warehouse↔Sales coupling — which is what this
example is about. The objection, applied, would leave the problem exactly where it is.
3. What does attack the separation between domains is the modular monolith, or microservices.
And there something revealing happens: those architectures forbid passing the Cart to
Warehouse and force a flat contract instead — which is the same refactor to items that shows up
on the other side of the drawing. On that part the industry has already reached the same
conclusion. What it does not solve is the I/O contamination.
In short: the layout of this example is deliberately the one of the code that exists, not the one of the code that is recommended. And the recommended architecture does not fix what the example points at.
cashier> invoice
info InvoicingService cart with 3 products, amount 4,000.00
info Warehouse committed 1 unit of product 1
info Warehouse committed 1 unit of product 2
info Warehouse committed 1 unit of product 3
info Authorizer RequestAuthorization(4,000.00) -> simulated, successful
info InvoicingRepository cart saved to MySQL as invoice #1 (3 lines)
info LedgerEntries here it would post the entry for 4,520.00
info Warehouse deducted 1 unit of product 1 (99 left)
info Warehouse deducted 1 unit of product 2 (99 left)
info Warehouse deducted 1 unit of product 3 (99 left)
info InvoicePrinter invoice printed to ...\invoices\invoice-1.txt
Four outputs in four different formats, every one of them decided from inside the domain:
- MySQL — rows in
invoiceandinvoice_line - text file —
invoices/invoice-N.txt, with the 3 lines, 13% VAT and total - XML file —
invoices/invoice-N.xml, for the tax authority - HTTP — the
400when authorization fails
COMPARACION.md puts the two halves side by side — both Invoice bodies, each
domain's dependencies, what each console needs in order to start, what happens on a rejection, the
journal, and the two audiences over one single act. That is the document for the talk.
Three slides live next to it, generated from this code:
comparacion-vs.svg |
the numbers facing each other — 22 vs 5 files, 5 vs 0 I/O calls |
comparacion-clases.svg |
the 22 files of the red box's domain and where each one ended up |
comparacion.svg |
both method bodies side by side, colour-coded by domain |
Still missing (later phases): the tell to the packer, the Items concept as a shared box between
the three domains, and the materialized view.