Spraxium logoSpraxium

Error Best Practices

Create custom exception classes, attach layouts with decorators, and apply production-grade patterns. Avoid anti-patterns that degrade error UX and slow down incident response across your team.

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.

insufficient-balance.exception.ts
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.

shop.service.ts
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.

insufficient-balance.exception.ts
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.

silent-audit.exception.ts
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.

PatternWhy it worksPractical application
Specific class namingImproves readability and layout mapping quality

Use PermissionDeniedException instead of CustomError

Typed props interfaceGives 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.

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.