Creating a custom exception
When the built-in exceptions do not cover your domain scenario, you create a custom class by extending SpraxiumException. A custom exception defines its own code, a message with placeholders, and a typed props interface that carries runtime context for both logging and layout rendering. This gives the pipeline everything it needs to produce a meaningful user response and an operational signal without any additional wiring.
import { SpraxiumException } from '@spraxium/core';
interface InsufficientBalanceProps {
required: number;
current: number;
currency: string;
}
export class InsufficientBalanceException extends SpraxiumException {
constructor(props: InsufficientBalanceProps) {
super({
code: 'INSUFFICIENT_BALANCE',
message: 'You need {{required}} {{currency}} but only have {{current}}.',
props,
});
}
}The typed props interface is important because it gives layouts and logging access to structured data instead of an opaque string. Any key in the props object can be interpolated into the message using double-brace syntax, and layouts receive the entire props object so they can build rich embeds, format numbers, or display contextual guidance to the user.
Throwing the custom exception
Use the custom exception inside services, guards, or handlers exactly like any built-in exception. The pipeline resolves the layout, logs if configured, and sends the response automatically without any extra code at the throw site.
import { InsufficientBalanceException } from './exceptions/insufficient-balance.exception';
if (user.balance < item.price) {
throw new InsufficientBalanceException({
required: item.price,
current: user.balance,
currency: 'coins',
});
}Attaching a layout with @WithLayout
If the exception should ship with a default visual presentation, attach a layout using the @WithLayout decorator from @spraxium/common. The layout receives the exception instance with full props access and the execution context, and returns a Discord response payload with embeds, components, and ephemeral flags.
import { WithLayout } from '@spraxium/common';
import { defineExceptionLayout, SpraxiumException } from '@spraxium/core';
import { EmbedBuilder } from 'discord.js';
const BalanceLayout = defineExceptionLayout((exception) => ({
embeds: [
new EmbedBuilder()
.setColor(0xff6b6b)
.setTitle('Insufficient Balance')
.setDescription(
`You need **${exception.props.required} ${exception.props.currency}** but only have **${exception.props.current}**.`,
)
.setFooter({ text: 'Earn more by completing daily tasks.' }),
],
ephemeral: true,
}));
@WithLayout(BalanceLayout)
export class InsufficientBalanceException extends SpraxiumException {
constructor(props: { required: number; current: number; currency: string }) {
super({
code: 'INSUFFICIENT_BALANCE',
message: 'You need {{required}} {{currency}} but only have {{current}}.',
props,
});
}
}Controlling reply and logging behavior
Each custom exception can override whether the pipeline sends a Discord reply and whether it logs the error. The shouldReply flag defaults to true, which is correct for user-facing failures, and shouldLog defaults to false because most domain exceptions are expected behavior rather than incidents. Override these defaults when the exception represents a silent operation or an internal error that must appear in operational logs.
export class SilentAuditException extends SpraxiumException {
constructor(props: { action: string; actorId: string }) {
super({
code: 'SILENT_AUDIT',
message: 'Audit recorded for {{action}} by {{actorId}}.',
props,
shouldReply: false,
shouldLog: true,
});
}
}Core principle
Exception quality starts with intent clarity, meaning every exception should answer three architecture-level questions: what failed, why it failed, and what action should happen next for users or operators. When exception classes fail to communicate those dimensions clearly, the costs show up quickly in the form of vague user experiences, noisy logs that are hard to filter, and slower incident triage because the team cannot tell from the error alone what actually went wrong.
Recommended patterns
| Pattern | Why it works | Practical application |
|---|---|---|
| Specific class naming | Improves readability and layout mapping quality | Use PermissionDeniedException instead of CustomError |
| Typed props interface | Gives layouts and logging access to structured data | Define an interface with the exact fields the layout needs |
| Separate domain and technical failures | Prevents user-facing messaging from leaking internals | Technical failures use safe fallback plus strong logging |
| Explicit fallback strategy | Prevents empty or inconsistent responses on unmapped errors | Always define a default layout in configuration |
Anti-patterns to avoid
Throwing generic exceptions for every scenario may seem faster at first, but it hurts maintainability and observability over time because every error looks the same in logs and there is no way to target specific failure types with dedicated layouts or monitoring rules. Another frequent mistake is hiding key context inside free-form strings that cannot be queried, aggregated, or filtered in log analysis tools. A third anti-pattern is coupling error UI logic directly inside domain services, which makes the service responsible for both business decisions and response rendering when those concerns should be clearly separated.
Recommended operational flow
Model classes by business policy
Create exception classes aligned with real product rules and user-facing scenarios rather than file layout or temporary implementation details. Each class should represent a meaningful failure category that the team can reference consistently in code reviews, monitoring dashboards, and incident reports.
Map layouts in central config
Keep user-facing behavior stable for any command or listener that can emit the same failure family. Centralizing layout mappings in spraxium.config.ts ensures that the same exception class always renders the same way, regardless of which module throws it.
Standardize logging context
Include stable identifiers in exception payloads so operators can correlate errors with the specific command, actor, and environment that triggered them. Consistent metadata makes it possible to build dashboards and alerts that surface patterns rather than individual incidents.
Review after incidents
After each incident, evaluate whether the exception class, payload, or layout mapping should be updated to reduce the chance of similar issues being hard to diagnose in the future. This iterative refinement is what turns a reactive error system into a proactive one over time.
Architecture decision note
If you need to choose between fewer classes with generic messages and more specific exception classes, prefer specificity at business boundaries. The up-front modeling cost pays off in supportability and product evolution because each class becomes a stable reference point for the entire team.