Skip to content

no-thrown-failure ​

An expected business failure is a value: the aggregate returns it in a Result, and exceptions stay for bugs.

Rule
tactical/no-thrown-failure
Category
Tactical: how building blocks are written
Reports
A public method of an aggregate or entity that returns no Result, a thrown DomainError
Applies to
Classes that extend AggregateRoot or Entity; throw anywhere in the project
Turn off
"tactical/no-thrown-failure": "off"

Why ​

order.place() throws InvalidTotal when the total is zero. Nothing in its signature says so: the controller that calls it does not catch it, and a customer gets an error 500 for a mistake they could have fixed.

The fix

The method returns Result<void, InvalidTotal>. The failure is in the type, and TypeScript makes every caller deal with it before it reaches the value. A method that changes state says whether it worked; reads are getters.

What it checks ​

1Public methods return a ResultEvery public method of a class that extends AggregateRoot or Entity, even when its return type is inferred. Getters, static methods, toSnapshot and equals are left out.
2No DomainError is thrownAnywhere in the project: a domain error is returned, never thrown.

What it reports ​

src/ordering/domain/aggregates/order.aggregate.ts:2
  tactical/no-thrown-failure: Order.place must return a Result:
  expose reads as getters and return business failures as
  values.

src/ordering/domain/aggregates/order.aggregate.ts:4
  tactical/no-thrown-failure: A DomainError is thrown: return
  it in a Result instead.

Fix it ​

Return the failure in a Result ​

So that the caller sees what can go wrong, return the domain error with err(…) and success with ok(), and declare both in the return type.

❌ Avoid: src/ordering/domain/aggregates/order.aggregate.ts
ts
export class Order extends AggregateRoot<OrderId> {
	place(total: number): void {
		if (total <= 0) {
			throw new InvalidTotal({ total });
		}
	}
}
✅ Prefer: src/ordering/domain/aggregates/order.aggregate.ts
ts
export class Order extends AggregateRoot<OrderId> {
	place(total: number): Result<void, InvalidTotal> {
		if (total <= 0) {
			return err(new InvalidTotal({ total }));
		}
		return ok();
	}
}

Expose reads as getters ​

So that the public methods are the ones that change state, a read without parameters is a getter, which the rule leaves out.

❌ Avoid: src/ordering/domain/aggregates/order.aggregate.ts
ts
isPlaced(): boolean {
	return this.status === "placed";
}
✅ Prefer: src/ordering/domain/aggregates/order.aggregate.ts
ts
get isPlaced(): boolean {
	return this.status === "placed";
}

A read that needs parameters, such as canShip(date), returns ok(…), or moves to a DomainService when it involves more than the aggregate.

Turn it off ​

alveolus.config.ts
ts
rules: { "tactical/no-thrown-failure": "off" },

On an existing project, prefer a baseline: new methods return a Result while you convert the old ones.

See also ​

Released under the MIT License.