Rejections and Error Codes
A command the aggregate refuses is a business outcome, not an error of the program. The aggregate returns a rejection with a stable code, nothing is written unless the rejection carries events, and the caller is told why. This page declares the codes and shows what each kind of caller receives.
Reject, or fail
A decide function refuses a command in one of three ways, and each is answered the same: nothing is written (a rejection may carry events, below), a compensation registered with onAppendFailure runs, the caller gets the code with its message, and sending the command again with the same request id decides it again, so it is rejected again while the state is unchanged; a rejection that carries events is answered as recorded. A rejection is never retried and never parks anything, whatever sent the command. The message is shown to the caller, so write it for them.
| The decide function | The caller gets |
|---|---|
returns Decision.reject(code, message) | the code, with its status (422 by default) |
calls reject(code, message) (me.ahoo.wow.api.decide, or its scope's), or throws RejectedException(Rejection.of(code, message)) (Java) | the same as returning it |
calls require, requireNotNull (an IllegalArgumentException) | wow.command.invalid (400) |
calls check, checkNotNull or error (an IllegalStateException) | wow.command.refused (409): the aggregate's current state refuses the command |
| throws anything else | the command fails with wow.command.failed (500): nothing is written, and a retry fails alike |
Only those two classes themselves are refusals: a subclass, such as NumberFormatException or a library's own exception, is a failure, and so is a cancellation. A failure is a defect of the program or its services, never an answer to the caller's request. Do not catch exceptions around reject, require or check with a catch (e: Exception): that swallows the refusal, and the command goes on.
Reject when the request cannot be granted in the aggregate's current state, or by its caller: a basket checked out empty, an unpaid order that is shipped, a short stock, a buyer who does not own the basket. Prefer a declared code (Decision.reject or reject) when a caller acts on the reason; require and check suit the guards whose message is enough.
A rule about the command's own shape, such as a quantity that is not positive, a blank name or an order of no items, is no rejection: it is a Jakarta constraint on the command class, and the gateway refuses the command with wow.command.invalid (400) before the aggregate decides (Write an Aggregate). The aggregate then declares codes only for what needs its state.
| The rule needs | Where it goes | The caller gets |
|---|---|---|
| The command alone | A Jakarta constraint on the command (@field:Positive, @field:NotBlank, @field:NotEmpty, @field:Valid) | wow.command.invalid (400) with its violations |
| The aggregate's state or the caller | Decision.reject with an @ErrorCode | The code, with its status (422 by default) |
Declare the codes
A code is a const val of type String annotated @ErrorCode, in an object nested in the root class:
/**
* The codes the aggregate rejects a command with, by their local names: the compiler and the kernel name them
* `<context>.order.<code>`, as its peers read them from the api's generated contract.
*/
object Rejections {
@ErrorCode(description = "The order's status does not allow the command.", status = 409)
const val WRONG_STATUS = "wrong-status"
@ErrorCode(description = "A payment pays the order's total amount.")
const val WRONG_AMOUNT = "wrong-amount"
}- The name. A code without a dot is a local name. The compiler and the kernel qualify it with the context and the aggregate, so callers, receipts, HTTP answers and the Manifest only ever see the full code:
eshop-ordering.order.wrong-status. A code with a dot is full as written, for a code the context's aggregates share, such aseshop-ordering.closed. - The status.
statusis the HTTP status a rejection with the code is answered with, from 400 to 599; 422 by default. Use 409 when the request conflicts with the aggregate's state, 404 when it names something the aggregate does not hold, and 403 when the caller may not ask it. - The description.
descriptionsays what the code means. The Manifest publishes it with the code. - The rules. The initializer is a string literal. A code may not start with
wow., which belongs to the framework, and a module declares each code once.Decision.rejectandRejection.ofrefuse awow.code too, and a command rejected with one anyway, through a model that builds its rejection by other means, fails withwow.command.failed(500): its message stays, and the code it chose is therejectedCodeextension.
Reject with a code
A command function returns the rejection with the code's local name and a message for people:
fun onCommand(command: ReserveStock, scope: DecideScope): StockReserved? = when {
command.orderId in state.reservations -> null
command.quantity > state.available -> reject(
Rejections.INSUFFICIENT,
"Only ${state.available} of ${scope.command.ref.id} are available, not ${command.quantity}.",
)
else -> StockReserved(command.orderId, command.quantity)
}The message may say anything that helps a person: amounts, ids, the state the aggregate is in. Callers decide on the code, never on the message.
Decision.reject(code, message, extensions = …) adds structured data a client reads instead of parsing the message: a map of names to JSON values, such as the ids of what blocks the command. The command's result carries them in its error, and an HTTP answer writes each one as a member of its problem.
Reject with events
A rejection may record what the refusal changed: a failed attempt that counts towards a lockout, a bid below the reserve that is kept for the auction's history. Pass the events with the rejection, returned or thrown alike:
fun onCommand(command: Unlock): Unlocked =
if (command.code == state.code) {
Unlocked(command.code)
} else {
reject(Rejections.WRONG_CODE, "Wrong code.", events = listOf(AttemptFailed(command.code)))
}- They are the aggregate's events. The aggregate sources them with its sourcing functions, and they are committed with the rejection in one stream, at the next version, as any decision's events: projections, sagas and the state read them. An event the aggregate has no sourcing function for fails the command, and nothing is written. There is no list of a rejection's events to declare: they are among the aggregate's events.
- The caller still gets the rejection, with its code and status. The result's
versionis the one the events reached. A rejection without events writes nothing, and itsversionis the one the decision saw. - What the refused decision set up is undone. Its schedules are not committed, and its reservations are released, with the other compensations registered by
onAppendFailure, as for a rejection without events. - A retry is answered with the same rejection. Sending the command again with the same request id answers the rejection the stream recorded, as a duplicate, at the same version.
- A wait ends at the rejection. A caller that waits for a snapshot or a saga is answered once the rejection is committed.
- A creation cannot carry events. Its aggregate does not exist, so a creation rejected with events writes nothing and has no version; the node warns once per command type, and a test of it fails, naming the command. Reject a creation without events.
Reject in a transformer
A CommandTransformer refuses the command it takes the same way, before any aggregate decides: it calls reject(code, message), or throws RejectedException(Rejection.of(code, message)) in Java. A callback that names no payment, for one:
override suspend fun transform(input: CommandInput): CommandTarget {
val chargeback = input.body as ChargebackNotified
if (chargeback.merchantReference.isBlank()) {
reject(Payment.Rejections.UNKNOWN_PAYMENT, "Chargeback ${chargeback.disputeId} names no payment.")
}
return CommandTarget.of(refundPayment, chargeback.merchantReference, RefundPayment(chargeback.reason))
}- The code is the aggregate's. A local name is qualified with the aggregate the request was sent to, the one a mounted command is mounted under:
eshop-payment.payment.unknown-payment. Declare it with@ErrorCodein that aggregate's root, as any of its codes, so the Manifest lists it with its status; a code its context does not list is answered 422. - The caller gets the rejection. Admission refuses the command with the code, its message and its extensions, as it answers a decide function's rejection, over HTTP with the code's status.
- Nothing is written. No aggregate decided, so the events a rejection carries are dropped, warned about once per command type.
Anything else a transformer throws fails the request: a 500 that says nothing of its cause, which the node logs.
What the caller receives
Over HTTP, a rejection is answered with the code's status and an application/problem+json body whose type is urn:wow:error:<code> and whose code member is the full code. Its version member is the aggregate's version the rejection answered: the one its events reached, else the one the decision saw; a creation's rejection has none. Wire Protocols describes the whole shape.
In Kotlin, the command client throws a CommandRejectedException whose code is the full code, for a rejection by the aggregate as for one at admission:
// An order of no items breaks SubmitOrder's constraints: the ordering refuses it at admission, typed.
val rejected = runCatching {
basket.bean<CommandClient>().of(alice).wait(SubmitOrder(emptyList()), "client-alice-2")
}.exceptionOrNull() as CommandRejectedException
rejected.code.assert().isEqualTo("wow.command.invalid")
rejected.result.error!!.violations.single().path.assert().isEqualTo("items")A peer compares the code with a constant instead of a string. The api module's generated contract holds every code of its context by its full name, EshopOrderingContract.Order.Errors.WRONG_STATUS, so a peer that depends on the ordering's api reads the same value the ordering rejects with.
A saga that sent the command gets the rejection in its @OnRejected handler and decides how to compensate: Write a Saga.
A test expects it with expectRejected(code), by the local or the full name, and checks a rejection's events and the state they left with the usual checks: Test Domain Code. aggregateSpec runs the kernel without admission, so a command's constraints are tested against the server: a request that breaks them answers 400 wow.command.invalid with the violation's path and constraint.
Where a command's failure is recorded
A rejection is an answer, never a failure: it is not retried or parked, whichever way the command came. A failure is recorded by what sent the command:
| The command came from | A failure |
|---|---|
| A saga (a derived command) | parks the saga's subscription on its aggregate, until it is retried or skipped |
| The Kafka command intake | parks the target aggregate under the intake, at the command's offset |
| A timer | parks the aggregate under wow.timers.sends, after its own retries |
An HTTP request, or a synchronous call (Wow-Call) | goes back to the caller as the problem; metrics, logs and traces record it, the failure ledger does not |
Rejections are counted apart from failures, by wow.command.rejections with their code, and logged at INFO with the aggregate, the command type, the code and the request id: Observability.
Remote calls in a decide function
A decide function runs again after a version conflict, so a remote call in it must be idempotent. Wow's clients derive the request id of a command they send from the deciding command's id and the call's order, so a repeated call is the same request as long as the calls keep their order (CommandRequest.requestId overrides it). Commands sent concurrently from one decision take their indices in no stable order, so send them one after another, or pass CommandRequest.requestId when a resend must be deduplicated; a JavaCommandClient future derives nothing, so pass the id explicitly there too; a third-party call needs a key of its own, and undoing it goes through onAppendFailure. Bringing the remote facts in before the decision is usually better: enrich the command with a CommandTransformer, or let a saga call and send the result. A CommandRejectedException of a Wow client call is answered as a rejection of this command with the target's code when that is a domain code, wow.command.invalid or wow.command.refused; the target's other framework codes are answered wow.command.failed (500), with the target's code in the rejectedCode extension; the target failing, timing out or answering an unknown outcome is a failure, as are I/O errors and timeouts of any client. Wrap a third-party call whose library throws a plain IllegalStateException for a transient error, so that it is not taken for a refusal.
Codes the framework rejects with
Some commands never reach the aggregate's function: the command is malformed, the caller may not send it, the aggregate does not exist, or the expected version does not match. Those are rejected with the framework's own codes, all of them under wow.. A command whose body breaks a Bean Validation constraint is rejected with wow.command.invalid (400), and the problem lists each violation with its property path, its constraint and its message; the host checks the constraints when the application has a Bean Validation provider on its class path.
Error Codes lists every framework code with its status.