Feature/application layer - #9
Merged
Merged
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Phase 6 — Application pipeline and event dispatch (build plan steps 30–31)
The two halves of how a request travels through this system. A
Resultthat models expectedfailures 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-contractsis clean.Results/—Result,Result<T>,Error,ErrorCategory,ValidationError,IResult<TSelf>Messaging/—ICommandBase,ICommand,ICommand<T>,IQuery<T>Behaviors/—LoggingBehavior,ValidationBehavior,TransactionBehavior,PipelineLogApplicationRegistration.AddApplication; MediatR 12.5.0 + FluentValidation 12.1.1Messaging/—DomainEventNotification<T>,IDomainEventHandler<T>IUnitOfWork.BeginTransactionAsync; a newIUnitOfWorkTransactionportUnitOfWorkTransactionover EF'sIDbContextTransactionEvents/—DomainEventQueue,DomainEventDispatcher,DomainEventInterceptorAddPersistenceattaches the interceptor and now needs a publisherFixtures/TestHost— the host's own composition, shared by the classes that need itDecisions 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.
ApplicationnamesMediatR in eight files and
Infrastructure'sDomainEventDispatchercallsIPublisher, so areplacement 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
TransactionBehaviornow saves inside a transaction it may still roll back. Publishingon 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:
clears them, and queues them.
UnitOfWorkTransaction.CommitAsyncdrains the queue after the commit returns.and only then called the interceptor — so there the interceptor publishes immediately. That is
what
Database.CurrentTransaction is nullis asking.announces nothing.
PublishesOnlyOnceTheWorkIsVisibleToEverybodyElseis the test that pins it: the subscriber, atthe 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.
ASubscriberThatThrowsFailsTheRequestAndKeepsTheCommittedWorkasserts exactly that pair, becauseit 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
Resultrolls back, exactly like an exceptionThe 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 (
SyncPushResponsehas bothAppliedandConflicts), so thetwo agree; this is the pipeline making that a rule rather than a habit.
IDomainEventHandler<T>hides the wrapper behind a default interface methodIDomainEventlives in the Domain, which depends on nothing and so cannot implementINotification— henceDomainEventNotification<TEvent>. But Doc 2 §12 andCLAUDE.mdbothpromise the seam is
IDomainEventHandler<T>, and making every future subscriber writeHandle(DomainEventNotification<JobCompleted> notification, …)would leak the plumbing intoevery one of them.
So
IDomainEventHandler<TEvent>extendsINotificationHandler<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 isan assembly scan — which is the whole cost the architecture promises for a new reaction.
DomainEventDispatchTestsregisters nothing by name for exactly that reason.AddPersistencenow needs a publisher, and three test providers had to say soSaving is what announces what an aggregate did, so a persistence layer with nowhere to announce
it is not a working one.
AddPersistencetherefore resolves a dispatcher that needsMediatR's
IPublisher, and a container withoutAddApplicationfails at the first resolverather than at the first event.
Three test sites built providers from
AddPersistencealone and now compose both.PostgresFixturewas 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
command from a query. Doc 2 §5 sketches commands as
IRequest<Result>directly, which leavesonly the type name to go on — so
ICommand,ICommand<T>andIQuery<T>sit over an emptyICommandBasethat the two command shapes share (neither can inherit the other; each declaresa different
IRequest<>). Fixing the response toResultmakes "handlers return aResult"structural, and the container cannot give a query a transaction because it skips an
open-generic behavior whose constraints the request does not satisfy.
AddApplicationlives in Application; only the call is in the composition root. The Apicalls 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-circuitingbehavior has to produce a failure of a type it knows only as
TResponse. It is the most exoticthing in the diff and it exists for one call site.
ErrorCategoryhas three members and none of them is "something went wrong." An unexpectedfailure is an exception. Roles arrive with auth at step 44.
Error.Validationfactory, so an error of that category always says whichfields were wrong.
Task.WhenAll— an async rule reaches for a repository,and a repository shares the request's one
DbContext.catch. An explicitRollbackAsync(cancellationToken)in a catch block fails when the token is what cancelled therequest, masking the original exception.
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.
aggregate and saves does not leave events waiting for a dispatch that never comes.
order. The first is the one that carries meaning and the one the tests assert.
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 asuccess,
[begun, handler ran, rolled back]for a failure,[]for a validation failure. Thejournal 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:
where TRequest : ICommandBase→notnullThree things the table cannot show:
Application. A do-nothing commandshipped 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 inApplicationyet to scan. Step 32'sCreateCustomerCommandvalidator is the first thing thatproves it, and it will fail loudly if it does not.
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
OrgIdfromITenantContext. NoResult-to-HTTP mapping:ErrorCategoryis writtento 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
IClockimplementation — 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.