Skip to content

Commit 61978fc

Browse files
committed
docs(examples)!: give the order-processing domain real error classes
The example used `expected: Error` at every `qualifyFailure` site, which matches essentially every throw — the pre-v8 catch-all spelled differently. It was accurate (the domain only threw plain `Error`s) but it neutralized the feature the flagship example is meant to teach, and a reader landing on one of the bare sites had nothing to go on. Adds `domain/errors.ts` with one class per port under a shared `OrderProcessingError` base, and throws those from the use cases and the payment adapter. Each activity now names the failure it actually anticipates (`expected: ShippingError`, `expected: PaymentError`, ...), so the triage is visible: a provider fault is a modeled activity failure, a `TypeError` from a bug stays a defect and re-throws at the edge with its original stack.
1 parent 0208548 commit 61978fc

10 files changed

Lines changed: 89 additions & 23 deletions

File tree

examples/order-processing-worker/src/application/activities.ts

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ import {
1111
createShipmentUseCase,
1212
refundPaymentUseCase,
1313
} from "../dependencies.js";
14+
import {
15+
InventoryError,
16+
NotificationError,
17+
OrderRepositoryError,
18+
PaymentError,
19+
ShippingError,
20+
} from "../domain/errors.js";
1421

1522
/**
1623
* Activity implementations using unthrown's `AsyncResult` pattern.
@@ -53,18 +60,25 @@ export const activities = declareActivitiesHandler({
5360
fromPromise(
5461
sendNotificationUseCase.execute(customerId, subject, message),
5562
qualifyFailure("NOTIFICATION_FAILED", {
56-
// Anticipated failure shape: the domain layer signals technical
57-
// faults by throwing plain `Error`s. Anything else (a TypeError
58-
// from a bug, ...) stays a defect and re-throws at the edge.
59-
expected: Error,
63+
// `expected` names the failures this activity *anticipates*. The
64+
// notification port signals its faults with `NotificationError`, so
65+
// only those become the modeled `NOTIFICATION_FAILED` failure.
66+
// Anything else — a `TypeError` from a bug, a null deref — is not a
67+
// business failure: it rides unthrown's defect channel and re-throws
68+
// at the activity edge with its original stack. Use
69+
// `expected: "any"` if you deliberately want the old catch-all.
70+
expected: NotificationError,
6071
message: "Failed to send notification",
6172
}),
6273
),
6374

6475
purgeExpiredOrders: ({ olderThanDays }) =>
6576
fromPromise(
6677
purgeExpiredOrdersUseCase.execute(olderThanDays),
67-
qualifyFailure("ORDER_PURGE_FAILED", { expected: Error, message: "Order purge failed" }),
78+
qualifyFailure("ORDER_PURGE_FAILED", {
79+
expected: OrderRepositoryError,
80+
message: "Order purge failed",
81+
}),
6882
).map((purgedCount) => ({ purgedCount })),
6983

7084
processOrder: {
@@ -79,7 +93,7 @@ export const activities = declareActivitiesHandler({
7993
fromPromise(
8094
processPaymentUseCase.execute(customerId, amount),
8195
qualifyFailure("PAYMENT_GATEWAY_ERROR", {
82-
expected: Error,
96+
expected: PaymentError,
8397
message: "Payment gateway call failed",
8498
}),
8599
).flatMap((outcome) => {
@@ -93,7 +107,7 @@ export const activities = declareActivitiesHandler({
93107
fromPromise(
94108
reserveInventoryUseCase.execute(items),
95109
qualifyFailure("INVENTORY_RESERVATION_FAILED", {
96-
expected: Error,
110+
expected: InventoryError,
97111
message: "Inventory reservation failed",
98112
}),
99113
),
@@ -102,7 +116,7 @@ export const activities = declareActivitiesHandler({
102116
fromPromise(
103117
releaseInventoryUseCase.execute(reservationId),
104118
qualifyFailure("INVENTORY_RELEASE_FAILED", {
105-
expected: Error,
119+
expected: InventoryError,
106120
message: "Inventory release failed",
107121
}),
108122
),
@@ -111,15 +125,15 @@ export const activities = declareActivitiesHandler({
111125
fromPromise(
112126
createShipmentUseCase.execute(orderId, customerId),
113127
qualifyFailure("SHIPMENT_CREATION_FAILED", {
114-
expected: Error,
128+
expected: ShippingError,
115129
message: "Shipment creation failed",
116130
}),
117131
),
118132

119133
refundPayment: (transactionId) =>
120134
fromPromise(
121135
refundPaymentUseCase.execute(transactionId),
122-
qualifyFailure("REFUND_FAILED", { expected: Error, message: "Refund failed" }),
136+
qualifyFailure("REFUND_FAILED", { expected: PaymentError, message: "Refund failed" }),
123137
),
124138
},
125139
},
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/**
2+
* Technical failure shapes for the order-processing domain.
3+
*
4+
* These exist so each activity boundary can *triage* rather than blanket-wrap.
5+
* `qualifyFailure(type, { expected })` names the failures an activity
6+
* anticipates: a matching cause becomes the modeled `ApplicationFailure`, and
7+
* anything else — a `TypeError` from a bug, a null deref — rides unthrown's
8+
* **defect** channel and re-throws at the activity edge with its original
9+
* stack, instead of being mislabelled as a business failure.
10+
*
11+
* Naming one class per port is what makes that triage meaningful. A blanket
12+
* `expected: Error` would match essentially every throw, which is the
13+
* pre-v8 catch-all behavior spelled differently — if you genuinely want it,
14+
* write `expected: "any"` so the intent is explicit and greppable.
15+
*
16+
* Distinct from the contract's **declared** `errors` (`PaymentDeclined`):
17+
* those model expected *business* outcomes, are returned as
18+
* `Err(errors.PaymentDeclined(...))` rather than thrown, and cross the wire
19+
* as typed `ContractError`s. The classes here are plain technical faults.
20+
*/
21+
22+
/** Base class for every technical fault raised by this example's domain. */
23+
export class OrderProcessingError extends Error {
24+
constructor(message: string, options?: ErrorOptions) {
25+
super(message, options);
26+
// Keep `Error.name` the concrete subclass name for readable logs.
27+
this.name = new.target.name;
28+
}
29+
}
30+
31+
/** The notification provider could not accept the message. */
32+
export class NotificationError extends OrderProcessingError {}
33+
34+
/** The payment gateway could not process the charge or refund. */
35+
export class PaymentError extends OrderProcessingError {}
36+
37+
/** The inventory service could not reserve or release stock. */
38+
export class InventoryError extends OrderProcessingError {}
39+
40+
/** The shipping provider could not create the shipment. */
41+
export class ShippingError extends OrderProcessingError {}
42+
43+
/** The order store could not complete the requested operation. */
44+
export class OrderRepositoryError extends OrderProcessingError {}

examples/order-processing-worker/src/domain/usecases/create-shipment.usecase.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { ShippingResult } from "../entities/order.schema.js";
2+
import { ShippingError } from "../errors.js";
23
import type { ShippingPort } from "../ports/shipping.port.js";
34

45
/**
@@ -13,12 +14,12 @@ export class CreateShipmentUseCase {
1314
// Business validation
1415
if (!orderId || orderId.trim() === "") {
1516
// oxlint-disable-next-line unthrown/no-throw -- known-technical precondition throw in a plain (non-Result) domain helper, wrapped once at the activity boundary via fromPromise(..., qualifyFailure(...))
16-
throw new Error("Order ID is required");
17+
throw new ShippingError("Order ID is required");
1718
}
1819

1920
if (!customerId || customerId.trim() === "") {
2021
// oxlint-disable-next-line unthrown/no-throw -- known-technical precondition throw in a plain (non-Result) domain helper, wrapped once at the activity boundary via fromPromise(..., qualifyFailure(...))
21-
throw new Error("Customer ID is required");
22+
throw new ShippingError("Customer ID is required");
2223
}
2324

2425
// Delegate to shipping port

examples/order-processing-worker/src/domain/usecases/process-payment.usecase.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { PaymentOutcome } from "../entities/order.schema.js";
2+
import { PaymentError } from "../errors.js";
23
import type { PaymentPort } from "../ports/payment.port.js";
34

45
/**
@@ -13,12 +14,12 @@ export class ProcessPaymentUseCase {
1314
// Business validation
1415
if (amount <= 0) {
1516
// oxlint-disable-next-line unthrown/no-throw -- known-technical precondition throw in a plain (non-Result) domain helper, wrapped once at the activity boundary via fromPromise(..., qualifyFailure(...))
16-
throw new Error("Payment amount must be positive");
17+
throw new PaymentError("Payment amount must be positive");
1718
}
1819

1920
if (!customerId || customerId.trim() === "") {
2021
// oxlint-disable-next-line unthrown/no-throw -- known-technical precondition throw in a plain (non-Result) domain helper, wrapped once at the activity boundary via fromPromise(..., qualifyFailure(...))
21-
throw new Error("Customer ID is required");
22+
throw new PaymentError("Customer ID is required");
2223
}
2324

2425
// Delegate to payment port

examples/order-processing-worker/src/domain/usecases/purge-expired-orders.usecase.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { OrderRepositoryError } from "../errors.js";
12
import type { OrderRepositoryPort } from "../ports/order-repository.port.js";
23

34
/**
@@ -12,7 +13,7 @@ export class PurgeExpiredOrdersUseCase {
1213
// Business validation
1314
if (!Number.isInteger(olderThanDays) || olderThanDays <= 0) {
1415
// oxlint-disable-next-line unthrown/no-throw -- known-technical precondition throw in a plain (non-Result) domain helper, wrapped once at the activity boundary via fromPromise(..., qualifyFailure(...))
15-
throw new Error("olderThanDays must be a positive integer");
16+
throw new OrderRepositoryError("olderThanDays must be a positive integer");
1617
}
1718

1819
// Delegate to order repository port

examples/order-processing-worker/src/domain/usecases/refund-payment.usecase.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { PaymentError } from "../errors.js";
12
import type { PaymentPort } from "../ports/payment.port.js";
23

34
/**
@@ -12,7 +13,7 @@ export class RefundPaymentUseCase {
1213
// Business validation
1314
if (!transactionId || transactionId.trim() === "") {
1415
// oxlint-disable-next-line unthrown/no-throw -- known-technical precondition throw in a plain (non-Result) domain helper, wrapped once at the activity boundary via fromPromise(..., qualifyFailure(...))
15-
throw new Error("Transaction ID is required");
16+
throw new PaymentError("Transaction ID is required");
1617
}
1718

1819
// Delegate to payment port

examples/order-processing-worker/src/domain/usecases/release-inventory.usecase.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { InventoryError } from "../errors.js";
12
import type { InventoryPort } from "../ports/inventory.port.js";
23

34
/**
@@ -12,7 +13,7 @@ export class ReleaseInventoryUseCase {
1213
// Business validation
1314
if (!reservationId || reservationId.trim() === "") {
1415
// oxlint-disable-next-line unthrown/no-throw -- known-technical precondition throw in a plain (non-Result) domain helper, wrapped once at the activity boundary via fromPromise(..., qualifyFailure(...))
15-
throw new Error("Reservation ID is required");
16+
throw new InventoryError("Reservation ID is required");
1617
}
1718

1819
// Delegate to inventory port

examples/order-processing-worker/src/domain/usecases/reserve-inventory.usecase.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { InventoryReservation, OrderItem } from "../entities/order.schema.js";
2+
import { InventoryError } from "../errors.js";
23
import type { InventoryPort } from "../ports/inventory.port.js";
34

45
/**
@@ -13,13 +14,13 @@ export class ReserveInventoryUseCase {
1314
// Business validation
1415
if (!items || items.length === 0) {
1516
// oxlint-disable-next-line unthrown/no-throw -- known-technical precondition throw in a plain (non-Result) domain helper, wrapped once at the activity boundary via fromPromise(..., qualifyFailure(...))
16-
throw new Error("At least one item is required");
17+
throw new InventoryError("At least one item is required");
1718
}
1819

1920
for (const item of items) {
2021
if (item.quantity <= 0) {
2122
// oxlint-disable-next-line unthrown/no-throw -- known-technical precondition throw in a plain (non-Result) domain helper, wrapped once at the activity boundary via fromPromise(..., qualifyFailure(...))
22-
throw new Error(`Invalid quantity for product ${item.productId}`);
23+
throw new InventoryError(`Invalid quantity for product ${item.productId}`);
2324
}
2425
}
2526

examples/order-processing-worker/src/domain/usecases/send-notification.usecase.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { NotificationError } from "../errors.js";
12
import type { NotificationPort } from "../ports/notification.port.js";
23

34
/**
@@ -12,17 +13,17 @@ export class SendNotificationUseCase {
1213
// Business validation
1314
if (!customerId || customerId.trim() === "") {
1415
// oxlint-disable-next-line unthrown/no-throw -- known-technical precondition throw in a plain (non-Result) domain helper, wrapped once at the activity boundary via fromPromise(..., qualifyFailure(...))
15-
throw new Error("Customer ID is required");
16+
throw new NotificationError("Customer ID is required");
1617
}
1718

1819
if (!subject || subject.trim() === "") {
1920
// oxlint-disable-next-line unthrown/no-throw -- known-technical precondition throw in a plain (non-Result) domain helper, wrapped once at the activity boundary via fromPromise(..., qualifyFailure(...))
20-
throw new Error("Subject is required");
21+
throw new NotificationError("Subject is required");
2122
}
2223

2324
if (!message || message.trim() === "") {
2425
// oxlint-disable-next-line unthrown/no-throw -- known-technical precondition throw in a plain (non-Result) domain helper, wrapped once at the activity boundary via fromPromise(..., qualifyFailure(...))
25-
throw new Error("Message is required");
26+
throw new NotificationError("Message is required");
2627
}
2728

2829
// Delegate to notification port

examples/order-processing-worker/src/infrastructure/adapters/payment.adapter.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { PaymentOutcome } from "../../domain/entities/order.schema.js";
2+
import { PaymentError } from "../../domain/errors.js";
23
import type { PaymentPort } from "../../domain/ports/payment.port.js";
34
import { logger } from "../../logger.js";
45

@@ -57,7 +58,7 @@ export class MockPaymentAdapter implements PaymentPort {
5758
} else {
5859
logger.error(`❌ Refund failed`);
5960
// oxlint-disable-next-line unthrown/no-throw -- known-technical precondition throw in a plain (non-Result) domain helper, wrapped once at the activity boundary via fromPromise(..., qualifyFailure(...))
60-
throw new Error("Payment processor rejected refund request");
61+
throw new PaymentError("Payment processor rejected refund request");
6162
}
6263
}
6364
}

0 commit comments

Comments
 (0)