Skip to content

Domain errors ​

A domain error is an expected business failure: a total that is not positive, an order already placed. It is a value, returned in a Result, never thrown, so every caller sees in the signature what can go wrong.

ts
export class InvalidTotal extends DomainError<{ total: number }> {}

return err(new InvalidTotal({ total }));

When to use ​

Declare a domain error for every way a business operation can be refused by the rules. Keep exceptions for what should never happen: a bug, a broken invariant, a lost database connection. Those are thrown as plain Errors.

Usage ​

Declare an error ​

One class per failure, one file per class, extending DomainError with the type of its payload. The class has no body.

src/ordering/domain/errors/invalid-total.error.ts
ts
import { DomainError } from "@alveolus/core";

export class InvalidTotal extends DomainError<{ total: number }> {}

An error without data takes no type parameter, and no argument:

src/ordering/domain/errors/order-already-placed.error.ts
ts
import { DomainError } from "@alveolus/core";

export class OrderAlreadyPlaced extends DomainError {}
ts
new InvalidTotal({ total: 0 });
new OrderAlreadyPlaced();

Return it, never throw it ​

❌ Avoid: src/ordering/domain/aggregates/order.aggregate.ts
ts
place(total: number): void {
	if (total <= 0) {
		throw new InvalidTotal({ total });
	}
}
✅ Prefer: src/ordering/domain/aggregates/order.aggregate.ts
ts
place(total: number, eventId: string, now: Date): Result<void, InvalidTotal> {
	if (total <= 0) {
		return err(new InvalidTotal({ total }));
	}
	…
	return ok();
}

A DomainError is not an Error: it has no stack trace and is not meant to be thrown. Throwing one is reported by errors-as-values.

List the errors of a use case ​

A method returns the union of the errors it can produce. A command handler declares the union of everything it may return, and passes failures through unchanged.

ts
export type PlaceOrderError = OrderNotFound | InvalidTotal | OrderAlreadyPlaced;

export class PlaceOrderHandler extends CommandHandler<PlaceOrder, void, PlaceOrderError> { … }

Handle it at the edge ​

The driving adapter turns the error into a response. instanceof narrows the union; type gives the class name, and payload the data.

src/ordering/driving/http/controllers/orders.controller.ts
ts
if (!placed.ok) {
	const body = { error: placed.error.type, details: placed.error.payload };
	if (placed.error instanceof OrderNotFound) {
		return { status: 404, body };
	}
	return { status: 422, body };
}

Compare errors in tests ​

Errors are plain objects: compare them with toEqual.

ts
expect(order.place(0, "event_1", now)).toEqual(err(new InvalidTotal({ total: 0 })));

Reference ​

ts
abstract class DomainError<Payload = undefined>
Type parameterDescription
PayloadThe data describing the failure. Defaults to undefined: no data.
MemberTypeDescription
constructor(payload)publicTakes the payload, or no argument when Payload is undefined.
payloadPayloadThe data of the failure.
typestringThe class name, such as "InvalidTotal".

AnyDomainError is the type of any domain error; handlers only accept errors of this type.

Caveats

  • type is the class name: a bundler that minifies class names changes it. Keep class names in your build (keep_classnames in Terser, keepNames in esbuild), or narrow with instanceof.
  • A DomainError does not extend Error. In the domain and the application, class X extends Error is reported by building-blocks-only: a bug is thrown as new Error("…").

Import from @alveolus/core or @alveolus/core/domain-errors.

Troubleshooting ​

Expected 0 arguments, but got 1 or Expected 1 arguments, but got 0: the arguments do not match the payload type. An error declared without a type parameter takes no argument; one declared with a payload requires it.

See also ​

Released under the MIT License.