Skip to content

Feature/application layer - #9

Merged
finn-abel merged 3 commits into
mainfrom
feature/application-layer
Aug 10, 2026
Merged

Feature/application layer#9
finn-abel merged 3 commits into
mainfrom
feature/application-layer

Conversation

@finn-abel

Copy link
Copy Markdown
Contributor

Phase 6 — Application pipeline and event dispatch (build plan steps 30–31)

The two halves of how a request travels through this system. A Result that models expected
failures as values and three behaviors that wrap every handler (30), and the mechanism that turns
what an aggregate says it did into something anything else can subscribe to (31).

369 tests pass (331 unit, 38 integration); the solution builds under nullable + analyzers +
warnings-as-errors with zero warnings in Debug and Release, and make check-contracts is clean.

Application (30) Results/Result, Result<T>, Error, ErrorCategory, ValidationError, IResult<TSelf>
Application (30) Messaging/ICommandBase, ICommand, ICommand<T>, IQuery<T>
Application (30) Behaviors/LoggingBehavior, ValidationBehavior, TransactionBehavior, PipelineLog
Application (30) ApplicationRegistration.AddApplication; MediatR 12.5.0 + FluentValidation 12.1.1
Application (31) Messaging/DomainEventNotification<T>, IDomainEventHandler<T>
Application (19→30) IUnitOfWork.BeginTransactionAsync; a new IUnitOfWorkTransaction port
Infrastructure (28→30) UnitOfWorkTransaction over EF's IDbContextTransaction
Infrastructure (31) Events/DomainEventQueue, DomainEventDispatcher, DomainEventInterceptor
Infrastructure (31) AddPersistence attaches the interceptor and now needs a publisher
Api (30) one line in the composition root
Tests (31) Fixtures/TestHost — the host's own composition, shared by the classes that need it
Logging      ── sees everything, times everything, logs failures it did not produce
  Validation ── refuses a malformed request; the handler and the transaction never happen
    Transaction ── begin · handler · save · commit · publish, or roll back and say nothing
      handler ─ raises domain events on its aggregates

Decisions a reviewer should weigh in on

MediatR is pinned to 12.5.0, and that is a licence decision — the one I most want a second opinion on

Doc 2 names MediatR in the stack, so this is not a library choice. But 13.0.0 changed the
licence
: everything from there on is RPL-1.5 (verified from the package's own LICENSE.md)
or a paid one from Lucky Penny Software. RPL is reciprocal including over a network — it
obliges anyone deploying software built on it to publish their source.

That lands squarely on the people Doc 1 says this project is for: "a developer, a small software
shop, or a founder building a product for the trades". OpenDispatch is Apache-2.0 precisely so
that person can build on it; a transitive dependency that either bills them or forces their
source open would take back most of what the licence gives. 12.5.0 is the last Apache-2.0
release and works unchanged on .NET 10.

The cost is real and worth stating: no upstream fixes. If that ever bites, the honest answer
is an in-house mediator, not a version bump that changes the terms this project is distributed
under — but step 31 made that escape hatch bigger than the request half. Application names
MediatR in eight files and Infrastructure's DomainEventDispatcher calls IPublisher, so a
replacement has to supply the notification seam as well, including the INotificationHandler<T>
that IDomainEventHandler<TEvent> satisfies through a default interface method.

Events are published on the commit, not on the save — which is not quite what step 31 says

Step 31's build note says "after a successful save"; its done-when says "after commit". Those
were the same moment when the note was written and stopped being the same moment in step 30,
because TransactionBehavior now saves inside a transaction it may still roll back. Publishing
on the save would push a board event, or email a customer, about a job whose completion the next
line takes back.

So the interceptor collects on the save and the transaction publishes on the commit:

  • The interceptor takes the events off the aggregates while the change tracker still holds them,
    clears them, and queues them.
  • UnitOfWorkTransaction.CommitAsync drains the queue after the commit returns.
  • A save with no transaction around it was its own transaction — EF opened one, committed it,
    and only then called the interceptor — so there the interceptor publishes immediately. That is
    what Database.CurrentTransaction is null is asking.
  • Disposal discards whatever is left, so a rollback, a thrown handler or a cancelled request
    announces nothing.

PublishesOnlyOnceTheWorkIsVisibleToEverybodyElse is the test that pins it: the subscriber, at
the moment it is called, opens a separate connection and finds the job already Completed.
Published on the save, that connection would still read Dispatched.

Handlers run in the request, and there is no outbox

A subscriber that throws stops the remaining events and fails the request — "failures surface",
as the step asks. But the work is already committed, so the request failing does not undo it:
the reaction failed, not the thing it was reacting to.
ASubscriberThatThrowsFailsTheRequestAndKeepsTheCommittedWork asserts exactly that pair, because
it is the sort of thing that is much better written down than discovered.

The alternative is a durable outbox, and this is deliberately not one. Nothing in v1 has a side
effect that must not be lost — the SignalR board (51) is a live view that reconnects and
refetches, and invoicing (40) is command-driven rather than event-driven. The first handler that
genuinely cannot afford to be dropped is the moment to have that conversation, and it will be a
new adapter behind the same seam rather than a change to it.

A failed Result rolls back, exactly like an exception

The behavior commits when the handler returns success and rolls back when it returns a failure.
So "the command failed" and "the database is untouched" are one statement rather than two a
caller has to check separately — and, with step 31, "and nothing was announced" is the same
statement again.

The integration tests are built to prove this rather than something weaker: both sample handlers
save before they decide, so the rows are physically in Postgres when the failure is reported,
and gone afterwards. A handler that only staged would let a pipeline that simply forgot to save
pass the same test.

What this means for step 42: a sync push that applies four ops and rejects the fifth must
return success carrying conflicts, not a failure — otherwise it discards the four it applied.
Step 21 already chose that shape (SyncPushResponse has both Applied and Conflicts), so the
two agree; this is the pipeline making that a rule rather than a habit.

IDomainEventHandler<T> hides the wrapper behind a default interface method

IDomainEvent lives in the Domain, which depends on nothing and so cannot implement
INotification — hence DomainEventNotification<TEvent>. But Doc 2 §12 and CLAUDE.md both
promise the seam is IDomainEventHandler<T>, and making every future subscriber write
Handle(DomainEventNotification<JobCompleted> notification, …) would leak the plumbing into
every one of them.

So IDomainEventHandler<TEvent> extends INotificationHandler<DomainEventNotification<TEvent>>
and satisfies it with a default interface method that unwraps. An implementer writes
Handle(JobCompleted domainEvent, CancellationToken) and nothing else, and the registration is
an assembly scan — which is the whole cost the architecture promises for a new reaction.
DomainEventDispatchTests registers nothing by name for exactly that reason.

AddPersistence now needs a publisher, and three test providers had to say so

Saving is what announces what an aggregate did, so a persistence layer with nowhere to announce
it is not a working one. AddPersistence therefore resolves a dispatcher that needs
MediatR's IPublisher, and a container without AddApplication fails at the first resolve
rather than at the first event.

Three test sites built providers from AddPersistence alone and now compose both. PostgresFixture
was the interesting one: it claims to be "the real registration the host uses", which since step
30 means two registrations rather than one, so it now goes through the same TestHost.Over(...)
that the pipeline and event tests use. A side effect worth having is that every existing
persistence test now runs with the interceptor live.

Smaller calls

  • Commands are declared, not detected. "Around command handlers" needs a way to tell a
    command from a query. Doc 2 §5 sketches commands as IRequest<Result> directly, which leaves
    only the type name to go on — so ICommand, ICommand<T> and IQuery<T> sit over an empty
    ICommandBase that the two command shapes share (neither can inherit the other; each declares
    a different IRequest<>). Fixing the response to Result makes "handlers return a Result"
    structural, and the container cannot give a query a transaction because it skips an
    open-generic behavior whose constraints the request does not satisfy.
  • AddApplication lives in Application; only the call is in the composition root. The Api
    calls it as it calls AddPersistence; the order is stated next to the behaviors it orders,
    with a reason per position.
  • IResult<TSelf> is a static abstract interface member, not reflection. A short-circuiting
    behavior has to produce a failure of a type it knows only as TResponse. It is the most exotic
    thing in the diff and it exists for one call site.
  • ErrorCategory has three members and none of them is "something went wrong." An unexpected
    failure is an exception. Roles arrive with auth at step 44.
  • There is no Error.Validation factory, so an error of that category always says which
    fields were wrong.
  • Validators run one at a time, not Task.WhenAll — an async rule reaches for a repository,
    and a repository shares the request's one DbContext.
  • The exception path rolls back by disposal, not by a catch. An explicit
    RollbackAsync(cancellationToken) in a catch block fails when the token is what cancelled the
    request, masking the original exception.
  • A synchronous save with pending events is refused before it happens. Publishing is
    asynchronous, so that path cannot dispatch; the failure it would otherwise cause is an invoice
    that is never raised with nothing pointing at why. Every port is async, so nothing takes it.
  • The dispatcher drains until the queue is empty, so a handler that itself changes an
    aggregate and saves does not leave events waiting for a dispatch that never comes.
  • Ordering within an aggregate is the order it raised them; across aggregates it is change-tracker
    order.
    The first is the one that carries meaning and the one the tests assert.
  • The logging behavior logs the request's type and never its contents. A command carries
    customer names, addresses and phone numbers.

Testing

Six pipeline tests through a real container and a real mediator, not the behaviors called
directly: what step 30 decides is the order they wrap a handler in, and a behavior invoked on its
own cannot be wrong about that. The handler and the fake unit of work both write to one journal,
so a single assertion is the whole story — [begun, handler ran, changes saved, committed] for a
success, [begun, handler ran, rolled back] for a failure, [] for a validation failure. The
journal records what a database would notice rather than which method was called, so a rollback
by disposal and a rollback by request are the same entry.

Eight integration tests carry the claims only Postgres can settle — two for the transaction,
six for dispatch.

Mutation-checked, each failing only what it should:

Mutation Fails
Register Validation outside Logging the failure-is-still-logged test
Register Transaction outside Validation the short-circuit test
A failed command commits the rollback test, and the integration rollback
Commit without saving the success test
where TRequest : ICommandBasenotnull the query test
Validation reports but does not short-circuit the short-circuit and logging tests
Open no transaction at all the integration rollback
Publish on the save, not the commit 4 of the 6 dispatch tests
Collected events are not cleared delivered-once, and the carry-over test
A rolled-back transaction keeps its events queued the carry-over test
Commit does not dispatch 4 of the 6 dispatch tests
The interceptor is not attached to the context 5 of the 6 dispatch tests
A synchronous save may drop events the synchronous-save test

Three things the table cannot show:

  • The sample commands live in the test projects, not in Application. A do-nothing command
    shipped in the application layer would be the temporary code the build plan asks to avoid.
    Step 32 exercises the pipeline for real one step later, and step 35 is the real version of
    CompleteJobCommand.
  • AddApplication's validator scan is untested, because there is no validator in
    Application yet to scan. Step 32's CreateCustomerCommand validator is the first thing that
    proves it, and it will fail loudly if it does not.
  • Nothing tests the logging behavior's wording, deliberately. The one log assertion is that a
    validation failure is logged at all, which can only be true if logging wraps validation.

What is deliberately not here

No feature slices — step 32 is the first, and the first real validator and the first handler
taking its OrgId from ITenantContext. No Result-to-HTTP mapping: ErrorCategory is written
to make step 46 a single table, and nothing maps it yet because no controller exists. No
subscribers: automatic invoicing on completion is called out in step 40 as a deliberate future
handler, and it can now be written without touching anything that exists. No outbox, and no
IClock implementation — the port has had no caller since step 19 and still has none.

The step-25 question of whether the version stamp should move beside an interceptor is settled by
having written one: they want different moments. The stamp mutates the row on the way into the
save (SavingChanges); the events are taken off after it succeeds and published later still.
It stays in AppDbContext.SaveChanges.

make test        # 369 tests; integration applies migrations to a throwaway container
make test-fast   # 331 unit
make check-contracts

@finn-abel
finn-abel merged commit bcf9c1a into main Aug 10, 2026
2 checks passed
@finn-abel
finn-abel deleted the feature/application-layer branch August 10, 2026 17:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant