Skip to content

Repository files navigation

CashierExample — the same invoice, twice

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.


Setup

Build:

dotnet build CashierExample.slnx

Databases — 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.


Running it

The red box, console — the while (!exit) from the drawing:

dotnet run --project 01-before/Cashier.Cli

The puppet, console:

dotnet run --project 02-puppet/Cashier.Puppet.Cli

Commands, 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:5199
curl -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:5200
curl -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.

Worth running

The three that make the point without any explanation:

printf 'invoice\ninvoice tax\njournal\nexit\n' | dotnet run --project 02-puppet/Cashier.Puppet.Cli

Two sales, two formats, one journal that cannot tell them apart.

dotnet run --project 02-puppet/Cashier.Puppet.Cli -- --authorized 3000

The 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 file

The 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).


The red box

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

The proof that the domain is tied to the web

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.


Where everything is

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.


Why it is one DLL and not three

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.


"This isn't Clean Architecture, we don't write like that"

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.


Expected output

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 invoice and invoice_line
  • text fileinvoices/invoice-N.txt, with the 3 lines, 13% VAT and total
  • XML fileinvoices/invoice-N.xml, for the tax authority
  • HTTP — the 400 when authorization fails

The comparison

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.

About

Side-by-side comparison: a conventional .NET invoice flow vs. the same domain written as a Puppeteer puppet.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages