Throw, Result, or neither?
There is one question I keep getting about my event-sourced code: why don’t I use Result?
It usually comes after someone sees a decision throwing an exception. They would put the failure in a Result, leave try/catch at the application boundary, and make the distinction explicit in the return type. That is a fair question, but the return type is only part of the answer.
I don’t mind using exceptions. I also return business failures as events. Which one I choose depends on what the failure means and what the application needs to do with it. What’s more, I don’t mind Result either, as long as I’m using a language where it’s a native and idiomatic way to use it. Stil…
Some failures are worth retaining as business data. An out-of-stock request can contribute to an unmet-demand report. A declined payment can start another process or be grouped by its reason. An item limit can explain why customers abandon an operation.
Gojko Adzic calls the systematic use of unexpected or marginal usage patterns to improve a product lizard optimization. Repeated out-of-stock requests can reveal demand we do not serve or a user experience that encourages impossible quantities. Turning every attempt into only a response or an exception log makes that pattern harder to find. An event gives the application data it can query, project, and use in another process.
Event Sourcing can help take advantage of unwanted and unexpected failures. We can model negative outcomes as events, just like regular ones. Our business logic informs us of what happened, not whether it’s a success or an error. It’s just an outcome of our decision.
So essentially we’re getting three options to deal with an unexpected scenario.
throw an error (e.g.
OutOfStockError)and catch it at the boundary;return a
Resultwith the failure on its error track;return an event (e.g.
ProductItemOutOfStock) and decide separately what we do with it.
Having that, my answer is rarely “just throw” or “just return Result.” Before choosing either, I need to know what should be persisted, what the caller should receive, and whether the failure belongs in the application’s exception path.
An error as a business fact
Let’s say that the customer asked for a quantity we cannot supply. Recording that outcome can be useful even though the cart did not change. The cart may also reach its item limit or fail payment authorisation. These are expected business outcomes, and the decision can describe each one with a distinct event:
const addProductItem = (
command: AddProductItem,
state: ShoppingCart,
): ProductItemAdded | ProductItemOutOfStock | ShoppingCartItemLimitReached => {
if (state.status === "Confirmed")
throw new IllegalStateError(
"Cannot add a product to a confirmed shopping cart",
);
const { shoppingCartId, productItem, availableQuantity, maximumItems, now } =
command.data;
if (productItem.quantity > availableQuantity)
return {
type: "ProductItemOutOfStock",
data: {
shoppingCartId,
productId: productItem.productId,
requestedQuantity: productItem.quantity,
availableQuantity,
attemptedAt: now,
},
};
if (state.productItems.length >= maximumItems)
return {
type: "ShoppingCartItemLimitReached",
data: {
maximumItems,
requestedProductId: productItem.productId,
},
};
return {
type: "ProductItemAdded",
data: { shoppingCartId, productItem },
};
};ProductItemOutOfStock carries the requested and available quantities. ShoppingCartItemLimitReached carries the configured limit and the product that crossed it.
We could also model a single ProductItemAddingFailed event instead. It could have a reason string, but then we’d use the granularity and precision of the information. With distinct event types, a projection, workflow, endpoint, or test can check the relevant outcome directly.
What’s more, we could also add a FailedToAddProductToClosedShoppingCart and get rid of throwing entirely. We’ll get to that, but for now…
Broken rules can throw
Not every failure needs a business event. A confirmed cart can no longer be modified, so AddProductItem is not a valid operation for that state. A command without required data is invalid as well. An unavailable event store, a stock-service timeout, or a serialisation error means the operation could not be completed reliably. I use exceptions for these cases.
The throw happens before an event is returned, so the handler has nothing to append. The exception retains its stack and reaches the application’s error boundary.
Some people prefer to use Result and hope for deterministic decision-making. Still, in most popular dev environments (TypeScript, Java, C#, Python, etc.), we can get exceptions. Even in strongly-typed Rust, we can get a panic or other cases. In the presented code loadShoppingCart can reject because of data unavailability, appendToStream can report a concurrency or connection error, and base code library code can throw on a random, stupid null pointer. The application still needs an error boundary somewhere:
try {
const result = await handleAddProductItem(command);
return mapResultToResponse(result);
} catch (error) {
return mapToErrorResponse(error);
}That boundary might be inside WebAPI route, a message handler callback, or wrapped with a conventional error middleware. The remaining choice is whether expected business outcomes should use the same path.
What Result would add
Result puts the success value and the expected failures on separate branches, visible in the type like:
type Result<Success, Failure> =
| { success: true; value: Success }
| { success: false; error: Failure };The cart decision becomes:
type AddProductItemFailure =
| ProductItemOutOfStock
| ShoppingCartItemLimitReached;
const addProductItem = (
command: AddProductItem,
state: ShoppingCart,
): Result<ProductItemAdded, AddProductItemFailure> => {
const { shoppingCartId, productItem, availableQuantity, maximumItems, now } =
command.data;
if (productItem.quantity > availableQuantity)
return {
success: false,
error: {
type: "ProductItemOutOfStock",
data: {
shoppingCartId,
productId: productItem.productId,
requestedQuantity: productItem.quantity,
availableQuantity,
attemptedAt: now,
},
},
};
if (state.productItems.length >= maximumItems)
return {
success: false,
error: {
type: "ShoppingCartItemLimitReached",
data: {
maximumItems,
requestedProductId: productItem.productId,
},
},
};
return {
success: true,
value: {
type: "ProductItemAdded",
data: { shoppingCartId, productItem },
},
};
};Technically it looks more or less the same: ProductItemAdded is returned on the success branch, while the other two errors are returned on the failure branch. The caller checks the branch before returning the event. In this application, adding the product changes the cart and produces a 204 response. Running out of stock or exceeding the limit leaves the cart unchanged and produces a 409 response. The handler appends only the success value:
const handleAddProductItem = async (
command: AddProductItem,
): Promise<Result<ProductItemAdded, AddProductItemFailure>> => {
const state = await loadShoppingCart(command.data.shoppingCartId);
const result = addProductItem(command, state);
if (result.success)
return PreconditionFailed();
await eventStore.appendToStream(
shoppingCartStreamName(command.data.shoppingCartId),
[result.value],
);
return NoContent();
};If we used event, it could look like that:
const handleAddProductItem = async (
command: AddProductItem,
): Promise<Result<ProductItemAdded, AddProductItemFailure>> => {
const state = await loadShoppingCart(command.data.shoppingCartId);
const event = addProductItem(command, state);
if (event.type === "ProductItemOutOfStock" || event.type === "ShoppingCartItemLimitReached")
return PreconditionFailed();
await eventStore.appendToStream(
shoppingCartStreamName(command.data.shoppingCartId),
[result.value],
);
return NoContent();
};Both versions preserve the same three event types. Result adds a success-or-failure classification around them. Which can be fine, but if you look again at the sample, it’s still pretty easy to ignore the different consequences of the events. We’re just one check on success further from it.
I saw many codebases where people were just blindly muting all errors by default, getting no benefit from it, but just polluting the codebase. Also keeping in mind that they still need to wrap the codebase with try/catch and have a conventional mapping layer.
We can also see that the Result wrapper in our case doesn’t add much besides success/error classification. If we’d like to return different response status based on the failure scenario, we’d need to add the same switch. So we’re not removing the decision; we’re just making it easier to ignore.
Of course, many languages made it streamlined by adding pipe operator etc. Yet, again, many popular environments don’t have it. TypeScript has no pipe operator; the TC39 proposal is not (yet?) part of the language. Repeating the branch adds checks throughout the call chain; introducing a helper library adds vocabulary that is not native to the language. Those libraries are extremely noisy and require tribal knowledge and onboarding.
Scott Wlaschin, who popularised railway-oriented programming, makes the same qualification: Result models expected alternatives, but it is not intended to wrap every function or replace exceptions.
Same for events; we should always discuss with the business if this information is important for them and if they want to keep it. We should also ask what should happen and if we can and should recover in our business application when this scenario happens.
ProductItemAdded, ProductItemOutOfStock, and ShoppingCartItemLimitReached just tell what has happened. Domain doesn’t need to care how the application handles it. It can mean failure for the current request and still be worth recording because a projection or workflow consumes it. Adding those rules to Result would move persistence concerns into the domain decision and add premature classification. When we change the behaviour in the application and record one of those scenarios, we would need to change domain code, even if business rules are the same. Just to formally move one case from failure to success.
Adding Result changes every calling layer without replacing the persistence rules, batch handling, or exception boundary. I do not see much of a gain here. Of course, a codebase that already uses Result as its error channel may judge it differently, especially when the alternatives are not events. Thus…
Returning an event doesn’t mean persisting it
ProductItemOutOfStock might be needed after the request. Measuring unmet demand, suggesting alternatives, or showing failed attempts to support staff requires a durable event. Still, if our business doesn’t care about the fact and we just want to log it, or forward to the HTTP Response, then we may not need to record it. Appending it would retain data the application does not need. Omitting it from the decision would instead force the endpoint to reread the outcome.
For this example, the decision returns ProductItemOutOfStock to the caller and the stream remains unchanged. I call this selective persistence: the decision describes the outcome, while the application decides whether it should be durable.
Handling choices in Emmett
A command handler reads a stream, rebuilds its state, runs a decision, applies the produced events, and appends them with an optimistic concurrency check. Emmett takes care of that flow. The application provides evolve to apply an event to state and initialState for a new stream.
Middleware can inspect the decision’s outcome before anything is appended. The decision still returns the same events; middleware only changes how the handler treats them.
For the add-product command, the handler should return ProductItemOutOfStock and ShoppingCartItemLimitReached without changing the stream. Emmett names this choice REJECT. The rejectOn helper selects it when an event matches a predicate:
import { DeciderCommandHandler, rejectOn } from "@event-driven-io/emmett";
const isRejectedAddProduct = (event: ShoppingCartEvent) =>
event.type === "ProductItemOutOfStock" ||
event.type === "ShoppingCartItemLimitReached";
const handleAddProductItem = DeciderCommandHandler<
ShoppingCart,
AddProductItem,
ShoppingCartEvent
>({
evolve,
initialState,
decide: addProductItem,
middleware: [rejectOn(isRejectedAddProduct)],
});
const result = await handleAddProductItem(eventStore, shoppingCartId, command);
For ProductItemOutOfStock, result.events contains the event and the cart remains at its previous state. The endpoint can read the returned quantities because no exception interrupted the normal return path. When no event matches, the handler appends the produced event and applies it to the state.
Several decisions on one stream
A batch import often contains records for many aggregates. Passing the complete file to one command handler would be the wrong boundary here: each cart has its own stream and its own concurrency check.
A single imported record can still require several operations on one aggregate. Suppose the shop imports draft carts from an external sales channel. One import file contains many carts, while each cart record contains several product lines. The importer handles the records separately, but all lines from one record target the same shopping-cart stream.
The integration contract requires the imported cart to match the source record. If one line is unavailable or the record exceeds the cart limit, storing the remaining lines would create a partial cart that exists in neither system. The importer therefore translates the lines into commands but handles them as one operation:
const importCartCommands: AddProductItem[] = importedCart.productItems.map(
(productItem) => ({
type: "AddProductItem",
data: {
shoppingCartId: importedCart.shoppingCartId,
productItem,
availableQuantity: stockByProductId[productItem.productId],
maximumItems,
now,
},
}),
);
Every command uses the existing addProductItem decision and sees the provisional state produced by the previous accepted line. That allows the cart-limit rule to work across the complete record. Persistence waits until every line has been accepted.
Implementing that directly requires holding accepted events in memory and using them to build the state for the next decision. Only after the last line has been accepted can the application append them together:
const aggregation = await eventStore.aggregateStream(streamName, {
evolve,
initialState,
});
const stateBeforeImport = aggregation.state;
let state = stateBeforeImport;
const events: ShoppingCartEvent[] = [];
const eventsToAppend: ShoppingCartEvent[] = [];
for (const command of importCartCommands) {
const event = addProductItem(command, state);
events.push(event);
if (
event.type === "ProductItemOutOfStock" ||
event.type === "ShoppingCartItemLimitReached"
)
return {
events,
appendedEvents: [],
newState: stateBeforeImport,
nextExpectedStreamVersion: aggregation.currentStreamVersion,
};
eventsToAppend.push(event);
state = evolve(state, event);
}
const appendResult = await eventStore.appendToStream(
streamName,
eventsToAppend,
{ expectedStreamVersion: aggregation.currentStreamVersion },
);
return {
...appendResult,
events,
appendedEvents: eventsToAppend,
newState: state,
};
The first rejected line ends the loop. Earlier additions existed only in eventsToAppend, so nothing from that cart record reaches the stream. The returned events show which lines passed before the rejection and which event stopped the import. The importer can report that outcome against the source record and continue with the next cart, which has a different stream.
For one product, rejecting meant leaving the stream unchanged. For an imported cart, it also means discarding the accepted events produced earlier for the same record. Emmett calls this handling REJECT. Its DeciderCommandHandler performs the aggregation, provisional evolution, and single append shown above. The application supplies the decision and the events that reject the operation:
const importDraftCart = DeciderCommandHandler<
ShoppingCart,
AddProductItem,
ShoppingCartEvent
>({
evolve,
initialState,
decide: addProductItem,
middleware: [rejectOn(isRejectedAddProduct)],
});
const result = await importDraftCart(
eventStore,
importedCart.shoppingCartId,
importCartCommands,
);
If every line is accepted, the handler appends their events together to that cart stream. If one decision returns ProductItemOutOfStock or ShoppingCartItemLimitReached, result.events contains the outcomes produced up to that point, the imported record makes no change to the cart, and later lines are not considered.
I would not throw just because an imported cart is rejected. The external system told us that its cart contains these product lines. That is true about the source system, but it does not mean our cart can accept them. The importer translates the external record into commands, and the returned events describe how the local model understood each line.
The import job maps those events to a record-level outcome. It can report the exact business issue to the source system and continue with the next cart:
const validatedProductIds = result.events.flatMap((event) =>
event.type === "ProductItemAdded" ? [event.data.productItem.productId] : [],
);
const failure = result.events.find(
(event) =>
event.type === "ProductItemOutOfStock" ||
event.type === "ShoppingCartItemLimitReached",
);
const importOutcome = (() => {
switch (failure?.type) {
case "ProductItemOutOfStock":
return {
status: "WaitingForStock" as const,
validatedProductIds,
productId: failure.data.productId,
requestedQuantity: failure.data.requestedQuantity,
availableQuantity: failure.data.availableQuantity,
};
case "ShoppingCartItemLimitReached":
return {
status: "Rejected" as const,
validatedProductIds,
productId: failure.data.requestedProductId,
maximumItems: failure.data.maximumItems,
};
default:
return {
status: "Imported" as const,
importedProductIds: validatedProductIds,
streamVersion: result.nextExpectedStreamVersion,
};
}
})();
await importStatusStore.record(importedCart.externalId, importOutcome);
switch (importOutcome.status) {
case "Imported":
await salesChannel.confirmImport(
importedCart.externalId,
importOutcome.streamVersion,
);
break;
case "WaitingForStock":
await pendingImports.waitForStock(
importedCart.externalId,
importOutcome.productId,
);
break;
case "Rejected":
await salesChannel.rejectImport(importedCart.externalId, {
productId: importOutcome.productId,
maximumItems: importOutcome.maximumItems,
});
break;
}
For an accepted record, the ProductItemAdded events identify the imported lines and the resulting stream version is recorded. If stock is missing, the importer records the checked lines and the missing quantity. It can continue with other records, then rebuild this cart’s commands with current availability after an inventory update. Retrying the unchanged commands immediately would only reproduce the same outcome. If the cart exceeds its item limit, waiting for stock cannot help; the source record needs correcting, so the importer marks it as rejected.
The importer still throws when the record cannot be evaluated: its schema is invalid, the source is not authorized, or the event store is unavailable. Those failures enter the import job’s retry or dead-letter policy because there is no reliable business outcome to report yet. The returned events let the application choose between continuing, waiting for a relevant business change, and rejecting one record. Exceptions remain for failures that prevented it from making that choice.
A saved-list restore uses different rules. Available products return to the cart, unavailable ones are reported, and reaching the cart limit ends the restore. Products added before either outcome remain valid, so this operation is not atomic.
The additions run in one call because every decision needs the cart state produced by the accepted products before it. Otherwise, each decision would evaluate the item limit against an outdated cart.
The loop stages and applies available products. It returns an unavailable product without staging it and continues. It also returns the item-limit event without staging it, then stops because the cart cannot accept another distinct product:
const aggregation = await eventStore.aggregateStream(streamName, {
evolve,
initialState,
});
let state = aggregation.state;
const events: ShoppingCartEvent[] = [];
const eventsToAppend: ShoppingCartEvent[] = [];
for (const command of restoreProductCommands) {
const event = addProductItem(command, state);
events.push(event);
if (event.type === "ProductItemOutOfStock") continue;
if (event.type === "ShoppingCartItemLimitReached") break;
eventsToAppend.push(event);
state = evolve(state, event);
}
const appendResult = await eventStore.appendToStream(
streamName,
eventsToAppend,
{ expectedStreamVersion: aggregation.currentStreamVersion },
);
return {
...appendResult,
events,
appendedEvents: eventsToAppend,
newState: state,
};
For ProductItemOutOfStock, the loop omits the current event and continues. Emmett names this handling SKIP and provides skipOn for it. For ShoppingCartItemLimitReached, the loop omits the current event, keeps earlier events, and ends the operation. Emmett names that STOP and provides stopOn. Both rules can be configured on the same handler:
import { skipOn, stopOn } from "@event-driven-io/emmett";
const restoreSavedProducts = DeciderCommandHandler<
ShoppingCart,
AddProductItem,
ShoppingCartEvent
>({
evolve,
initialState,
decide: addProductItem,
middleware: [
skipOn((event) => event.type === "ProductItemOutOfStock"),
stopOn((event) => event.type === "ShoppingCartItemLimitReached"),
],
});
The import and saved-list operations receive the same events. An import rejects the complete cart record after an unavailable product. A saved-list restore reports that product and continues, then keeps its earlier additions if it reaches the cart limit. A Result failure branch would identify each failed addition, but the application would still need to make these choices.
The shop may later decide to use ShoppingCartItemLimitReached in a report showing how often saved lists exceed the cart limit. That requires the limit event itself to be persisted. In the application service, the limit branch would stage and apply the event before stopping:
if (event.type === "ShoppingCartItemLimitReached") {
eventsToAppend.push(event);
state = evolve(state, event);
break;
}
Emmett calls this APPEND_AND_STOP; stopAfter selects it for a matching event:
import { skipOn, stopAfter } from "@event-driven-io/emmett";
const restoreSavedProductsAndRecordLimit = DeciderCommandHandler<
ShoppingCart,
AddProductItem,
ShoppingCartEvent
>({
evolve,
initialState,
decide: addProductItem,
middleware: [
skipOn((event) => event.type === "ProductItemOutOfStock"),
stopAfter((event) => event.type === "ShoppingCartItemLimitReached"),
],
});
The limit event is appended with the earlier additions, but products appearing later in the saved list are not processed. The difference from stopOn is the durability of the event that stopped the operation.
The four helpers differ in what they retain and whether processing continues. rejectOn retains neither the current events nor earlier ones. stopOn retains the earlier events but not the current ones. stopAfter retains both. skipOn omits the current events and continues. That is how the same ProductItemOutOfStock can reject an import but be skipped while restoring a saved list. If one decision returns several events and any of them matches, the handler applies the choice to all of them, so it never appends half of a decision’s outcome.
An application that already maps exceptions at its boundaries may not need the event in a normal handler result. Emmett’s throwOn middleware translates a matching event into an exception before the append:
import { CommandHandler, throwOn } from "@event-driven-io/emmett";
const handle = CommandHandler<ShoppingCart, ShoppingCartEvent>({
evolve,
initialState,
middleware: [
throwOn(
(event) => event.type === "ProductItemOutOfStock",
(event) => new ProductItemOutOfStockError(event.data),
),
],
});
Unlike rejectOn, throwOn does not return the matching event in the handler result. In an atomic batch, both prevent the staged additions from being appended. With rejectOn, the caller reads ProductItemOutOfStock from result.events. With throwOn, the error factory copies the event data into ProductItemOutOfStockError, which is handled by the application’s exception middleware. Runtime and infrastructure exceptions continue through that boundary as well.
Mapping Events to Error Response
The handler returns ProductItemOutOfStock and ShoppingCartItemLimitReached normally. Express error middleware never sees them because nothing was thrown. The endpoint must translate those events into the API’s failure responses.
This API maps both events to 409 Conflict. The server understood the request, but the cart cannot accept the change with the current stock or item limit. The response includes the data the client needs to adjust its next request: the available quantity or the maximum number of products. Another API could choose different status semantics without changing the cart decision.
ProductItemAdded produces 204 No Content with the new stream version in an ETag. None of the three events contains an HTTP status; the endpoint adds it.
The endpoint could inspect result.events and map each event itself. It should check all returned events rather than assume that the last one is the failure: a handler may return several decision outcomes, as the import example did. ResponseFromEvents from @event-driven-io/emmett-expressjs performs that selection and returns the same response types used by other on routes:
import {
Conflict,
NoContent,
on,
ResponseFromEvents,
toWeakETag,
} from "@event-driven-io/emmett-expressjs";
const addProductItemApi = (router: Router) => {
router.post(
"/shopping-carts/:shoppingCartId/product-items",
on(async (request: AddProductItemRequest) => {
const shoppingCartId = request.params.shoppingCartId;
const productId = String(request.body.productId);
const availableQuantity = await inventory.getAvailableQuantity(productId);
const result = await handleAddProductItem(eventStore, shoppingCartId, {
type: "AddProductItem",
data: {
shoppingCartId,
productItem: {
productId,
quantity: Number(request.body.quantity),
},
availableQuantity,
maximumItems: shoppingCartPolicy.maximumItems,
now: new Date(),
},
});
return ResponseFromEvents({
events: result,
success: (result) =>
NoContent({
eTag: toWeakETag(result.nextExpectedStreamVersion),
}),
failure: (event) => {
switch (event.type) {
case "ProductItemOutOfStock":
return Conflict({
problemDetails: `Only ${event.data.availableQuantity} items are available`,
});
case "ShoppingCartItemLimitReached":
return Conflict({
problemDetails: `A shopping cart can contain at most ${event.data.maximumItems} distinct products`,
});
}
return undefined;
},
});
}),
);
};
ResponseFromEvents checks the produced events from newest to oldest. The first response returned by failure wins; when none matches, success receives the handler result and adds its version as an ETag. Thrown exceptions bypass this mapping and continue to Express error middleware, which turns application errors into Problem Details.
Retries and asynchronous handlers
If another request changes the cart before our append, Emmett handles the concurrency conflict by loading the stream again and rerunning the decisions against its new state. Decision middleware runs again as well.
An out-of-stock import is not a concurrency conflict. The cart state did not become stale; the input said that only two items were available. Running the same command again still returns ProductItemOutOfStock. The importer waits for an inventory change and then builds a new command with the current availability.
A custom shouldRetryResult policy can inspect a completed attempt, but it runs after that attempt’s append phase. Retrying a result is safe only when nothing was appended, and useful only when state or input can change.
A decision and its middleware can therefore run several times during one handler call. Code that only reads the command and state can be rerun. Sending an email or charging a card inside that code would repeat the external effect. In these examples, required external data is fetched before invoking the handler and included in the command. Effects caused by appended events run later in processors, after persistence.
Middleware is also useful for authorization, logging, and measurements. Some of that work belongs around each decision; some should run only once for the complete handler call. Its position relative to the retry boundary determines whether it sees aggregate state and whether a retry repeats it:
await beforeAll(input);
const result = await retry(async () => {
const state = await aggregate(streamName);
for (const decision of decisions) {
await runWithDecisionMiddleware(decision, state);
}
return appendSelectedEvents();
});
await afterAll(result);
return result;
The callback before the retry boundary receives the complete input once. It can authorize the command batch without repeating that work after a concurrency conflict. Aggregate state is not available yet because the stream has not been loaded.
Middleware inside the boundary receives each decision and the state rebuilt for the current attempt. A concurrency retry runs it again.
The callback after the boundary receives the final result once, after persistence. It can record measurements based on appended events or the new stream version. If it throws, already appended events remain committed. Validation belongs earlier; notifications that must be delivered need a durable handler or outbox.
DeciderCommandHandler takes commands directly and runs the configured decide for each. Its middleware therefore receives the command and the state rebuilt for the current attempt. before and after adapt callbacks into decision middleware:
import { after, before } from "@event-driven-io/emmett";
type ShoppingCartCommand = AddProductItem | ConfirmShoppingCart;
const handle = DeciderCommandHandler<
ShoppingCart,
ShoppingCartCommand,
ShoppingCartEvent
>({
evolve,
initialState,
decide,
middleware: {
beforeAll: authorizeCommands,
decision: [
before(authorizeCommand),
after((result, command) => {
logResult(command, result);
return result;
}),
],
afterAll: recordInvocation,
},
});
authorizeCommands runs outside retries, authorizeCommand and logResult wrap each decision inside an attempt, recordInvocation sees the final result.
An HTTP endpoint can translate an exception into a response. In an asynchronous processor, the same exception stops processing and leaves later messages waiting.
A reactor can turn a declined payment into a durable PaymentFailed event for a compensating workflow, skip a message that should not block processing, or stop without advancing its checkpoint.
A projection cannot prevent the source event from being recorded because that event is already in the stream. Throwing only prevents the read model from advancing. Its error handling must account for delivery and recovery rather than reuse the rules of a command decision.
Conclusion
The decision returns ProductItemAdded, ProductItemOutOfStock, or ShoppingCartItemLimitReached because the application needs to know which one happened. Out of stock is an unfavourable outcome, but that alone says nothing about storage. During an import it cancels the current source record. During a saved-list restore it is skipped while earlier additions remain. At the HTTP endpoint it becomes a conflict response. The event stays the same; its handling depends on the use case.
I do not try to remove exceptions from this design. Adding a product to a confirmed cart is an invalid operation. An unavailable event store prevents the handler from producing a reliable result. Both go to the application’s error boundary.
Result could label some of the returned events as failures, but persistence, continuation, and retry still need their own decisions. The exception path remains as well. The event types already distinguish the business outcomes, so I prefer to leave them as they are.
So when someone asks me whether they should throw an error or use a Result type, I sometimes tell: actually, you can do neither.
Cheers!
Oskar
p.s. if you liked this one, check also:
Against Railway-Oriented Programming by Scott Wlaschin
p.s. Ukraine is still under brutal Russian invasion. A lot of Ukrainian people are hurt, without shelter and need help. You can help in various ways, for instance, directly helping refugees, spreading awareness, and putting pressure on your local government or companies. You can also support Ukraine by donating, e.g. to the Ukraine humanitarian organisation, Ambulances for Ukraine or Red Cross.


