Skip to content

Errors

API services distinguish between two failure categories: user errors (the caller did something wrong) and domain errors (a server-side or infrastructure condition). User errors flow as Either values; domain errors are thrown.

User errors

User errors are failures the caller can resolve by changing input or waiting for business state to change.

Examples: RoundNotActive, MessageTooShort, MemberAlreadyEnrolled

These belong in the core domain and are safe to present as part of a normal application response.

The UserError interface

UserError<TCode> from @repo/error is the shared shape for all user-facing errors. It carries three fields:

FieldTypePurpose
codeTCodeMachine-readable error code, uniquely identifies the error kind
fieldstring[]Which input fields the error relates to (empty for general errors)
messagestringHuman-readable description suitable for display
ts
import type { UserError } from "@repo/error";

// For a field-level error:
const err: UserError<"ROUND_NOT_ACTIVE"> = {
	code: "ROUND_NOT_ACTIVE",
	field: [],
	message: "This round is no longer accepting submissions.",
};

Defining error codes

Each domain defines its error codes as a const map. This keeps codes as plain string values while giving TypeScript a narrow type to work with. Define them in the core layer of the domain, they are not tied to any transport mechanism and can be reused across GraphQL and REST.

ts
// domains/round/core/errors.ts

export const RoundErrorCode = {
	RoundNotFound: "ROUND_NOT_FOUND",
	RoundNotActive: "ROUND_NOT_ACTIVE",
	SubmissionLimitReached: "SUBMISSION_LIMIT_REACHED",
} as const;

export type RoundErrorCode = (typeof RoundErrorCode)[keyof typeof RoundErrorCode];
ts
// domains/feedback/core/errors.ts

export const FeedbackErrorCode = {
	MessageTooShort: "MESSAGE_TOO_SHORT",
	EmailInvalid: "EMAIL_INVALID",
} as const;

export type FeedbackErrorCode = (typeof FeedbackErrorCode)[keyof typeof FeedbackErrorCode];

Use the code type as the TCode generic when typing a UserError:

ts
export type RoundUserError = UserError<RoundErrorCode>;

Returning user errors

Domain validation returns an Either where the left side carries a UserError:

ts
// domains/round/core/round.ts

import type { UserError } from "@repo/error";
import { Either } from "@repo/core";
import { RoundErrorCode } from "./errors";

export const Round = {
	validateSubmission: (
		round: Round,
		input: SubmitInput,
	): Either<UserError<RoundErrorCode>, SubmitInput> => {
		if (!round.isActive) {
			return Either.Left({
				code: RoundErrorCode.RoundNotActive,
				field: [],
				message: "This round is no longer accepting submissions.",
			});
		}

		if (round.submissionCount >= round.limit) {
			return Either.Left({
				code: RoundErrorCode.SubmissionLimitReached,
				field: [],
				message: "You have reached the maximum number of submissions.",
			});
		}

		return Either.Right(input);
	},
};

Compose multiple error code types into a workflow-specific union when a use case can fail in multiple ways:

ts
export type SubmitRoundEntryError = RoundErrorCode | FeedbackErrorCode;

Domain errors

Domain errors represent structured server-side failures and are thrown, not returned. They live in @repo/error and are handled centrally by the error middleware in @repo/core-server.

ts
import { NotFound, Unavailable, Misconfigured } from "@repo/error";

// External service is down
throw new Unavailable({ cause: error });

// Missing secret or configuration
throw new Misconfigured({ cause: error });

See Reference - Domain Errors for the full list of error types, their HTTP status mappings, and usage guidance.

Throw sparingly, prefer null returns from adapters

Domain errors should be thrown sparingly. The key principle is: adapters and repositories should not decide what is an error for the caller. The same repository method might be called from two different use cases, one where a missing record is a hard failure, another where it is a perfectly normal empty state.

Prefer returning null (or an empty array) from repositories and let the CQRS handler decide whether the absence warrants a NotFound throw:

ts
// shell/infra/database/round/repository.ts
// ✓ Returns null, does not assume the caller needs to throw

export class PostgresRoundRepository implements RoundRepository {
	async findById(id: string): Promise<Round | null> {
		const row = await this.db.query("SELECT * FROM rounds WHERE id = $1", [id]);
		return row ?? null;
	}
}
ts
// shell/cqrs/queries/get-round.query.ts
// ✓ Handler decides what null means in this specific use case

export class GetRoundQuery extends Query<Input, Output> {
	execute = async ({ roundId }: Input) => {
		const round = await this.rounds.findById(roundId);

		if (!round) {
			throw new NotFound("Round", roundId);
		}

		return round;
	};
}
ts
// shell/cqrs/queries/list-rounds.query.ts
// ✓ Different use case, null/empty is fine, no throw needed

export class ListRoundsQuery extends Query<Input, Output> {
	execute = async ({ memberId }: Input) => {
		const rounds = await this.rounds.findByMember(memberId);
		return rounds ?? [];
	};
}

The only domain errors that belong directly in an adapter are infrastructure failures, the external call itself could not be completed:

ts
// shell/infra/payments/payment-gateway.ts
// ✓ Infrastructure failure, throw is appropriate here

try {
	const result = await this.gateway.charge(payload);
	return result;
} catch (error) {
	throw new Unavailable({ cause: error });
}

Where they belong

User errors originate in the core domain. Infrastructure failures are thrown at the adapter level. The CQRS handler is the decision point for whether an absent result warrants a domain error.

Entrypoint mapping

Error code const maps are defined in the core layer and can be consumed by both GraphQL and REST transport handlers directly, no duplication needed.

GraphQL

User errors map into userErrors on the mutation payload. Domain errors thrown during resolution are caught by the GraphQL format-error middleware in @repo/core-server.

ts
import { RoundErrorCode } from "#/domains/round/core/errors";

const result = await Round.validateSubmission(round, input);

if (Either.isLeft(result)) {
	return { userErrors: [result.left] };
}

return { entry: result.right, userErrors: [] };

Domain errors are thrown and handled globally, no per-resolver catch needed:

ts
// In a resolver or workflow, just throw
throw new Unavailable({ cause: error });

// GraphQL response:
// {
//   "errors": [{ "message": "An unexpected error occurred", "extensions": { "code": "UNAVAILABLE" } }]
// }

Internal error details are redacted from the message surfaced to clients.

ADR

See ADR-004: GraphQL Mutation Error Handling for the full decision record on per-mutation error types and userErrors.

HTTP

User errors are mapped to 4xx responses manually by the route handler using validationProblem from @repo/core-server. Domain errors are caught automatically by the Fastify error handler and serialized as RFC 9457 application/problem+json, no per-route catch needed.

Responding with user errors

Use validationProblem to construct the response body. It produces a ValidationProblemDetails with type, title, status, instance, and an errors array of UserError objects:

ts
import { validationProblem } from "@repo/core-server";
import { RoundErrorCode } from "#/domains/round/core/errors";

const result = await Round.validateSubmission(round, input);

if (Either.isLeft(result)) {
	return reply
		.status(400)
		.header("content-type", "application/problem+json")
		.send(validationProblem(request, [result.left]));
}

return reply.status(200).send({ entry: result.right });

Documenting responses with TypeBox schemas

Use problemSchema and validationProblemSchema from @repo/core-server to declare response shapes in Fastify route schemas. validationProblemSchema accepts the list of possible error codes so the schema is narrowly typed:

ts
import { problemSchema, validationProblemSchema } from "@repo/core-server";
import { RoundErrorCode } from "#/domains/round/core/errors";

export const submitRoundEntrySchema = {
	response: {
		200: Type.Object({ entry: EntrySchema }),
		400: validationProblemSchema(Object.values(RoundErrorCode)),
		404: problemSchema(404),
	},
};

Automatic domain error handling

DomainError instances thrown anywhere in a route handler are caught by the global error handler and mapped to the appropriate status code and problem+json body automatically:

DomainErrorHTTP status
NotFound404
Conflict409
Forbidden403
Unauthorized401
RateLimited429
Unavailable503
TimedOut504
InvalidResponse502
Misconfigured500
Interrupted500

Internal errors (Misconfigured, Interrupted) have their detail and title redacted so implementation details are never leaked to the client.

TypeBox request validation errors (malformed body, missing required fields) are also caught automatically and returned as a ValidationProblemDetails with field-level issues, no explicit handling needed in the route.