【PL/SQL】業務エラーコード設計|RAISE_APPLICATION_ERROR・メッセージテーブル・ログ連携まで

【PL/SQL】業務エラーコード設計|RAISE_APPLICATION_ERROR・メッセージテーブル・ログ連携まで PL/SQL

PL/SQLで業務処理を作っていると、例外を投げる場面は多くあります。在庫不足、締め済み期間への登録、権限不足、入力値の不備、外部連携の失敗など、単にRAISE_APPLICATION_ERROR(-20001, 'error')と書くだけでは、画面、API、バッチ、ログで扱いにくいエラーになります。

業務エラーコード設計では、エラー番号、業務コード、画面表示メッセージ、ログ用詳細、HTTPステータス、再試行可否などを整理します。これを共通パッケージ化しておくと、アプリケーションやバッチが同じルールでエラーを扱えます。例外処理の基本は PL/SQL例外処理完全ガイド、再試行や一時障害の考え方は PL/SQLの例外設計と再試行パターン も参考になります。

この記事で扱うこと

  • -20000番台の使い分け
  • 業務エラーとシステムエラーの分離
  • メッセージテーブルの設計
  • RAISE_APPLICATION_ERRORのラッパーパッケージ
  • 画面/API向けメッセージとログ用詳細の分離
  • バッチログ、JSONエラー、多言語対応との連携
スポンサーリンク

業務エラーコード設計で解決したい問題

エラー設計がないまま開発が進むと、同じ意味のエラーに別々の番号が割り当てられたり、逆に同じ番号が複数の意味で使われたりします。また、画面に出してよいメッセージと、ログにだけ残すべき詳細が混ざると、ユーザーに内部情報を見せてしまう危険もあります。

番号の意味が分からない-20001だけでは、入力エラーなのか権限エラーなのか判断できません。
文言変更に弱いコードにメッセージを直書きすると、文言修正のたびにPL/SQLのデプロイが必要になります。
APIで扱いにくい画面表示用の日本語文だけでは、クライアント側がエラー種別を機械的に判断できません。
ログが調査に使えないユーザー向けメッセージだけでは、どの入力値や処理で失敗したか追えません。

業務エラーコードは、単なる番号表ではありません。DB内の例外、APIレスポンス、バッチログ、監査ログ、運用問い合わせをつなぐ共通語彙です。最初に小さくても体系を決めておくと、後からエラーを増やしても破綻しにくくなります。

業務エラーとシステムエラーを分ける

まず、ユーザーや業務ルールに起因するエラーと、システム障害に近いエラーを分けます。在庫不足や締め済み期間への登録は、業務として想定できるエラーです。一方で、表が存在しない、権限がない、DB接続が切れた、予期しないNULL参照などは、利用者に細かい内部情報を見せるべきではありません。

業務エラー入力値や業務状態が理由で処理できないエラーです。ユーザーに修正方法を返せます。
認可エラー権限やロール不足で処理できないエラーです。画面表示と監査ログの両方を意識します。
一時障害ロック競合、外部APIタイムアウト、一時的なリソース不足など、再試行できる可能性があるエラーです。
システムエラー想定外の例外です。ユーザーには汎用メッセージを返し、詳細はログに残します。

PL/SQLの例外処理では、WHEN OTHERSで何でも業務エラーに変換しないことが重要です。想定済みの業務エラーだけを共通パッケージから投げ、想定外の例外はバックトレース付きでログに残します。

-20000番台の割り当て方

RAISE_APPLICATION_ERRORで使えるエラー番号は、基本的に-20000から-20999です。この範囲を無計画に使うとすぐに衝突するので、機能領域ごとに番号帯を分けます。

-20000〜-20099共通入力チェック、必須、形式、範囲などの汎用バリデーション。
-20100〜-20199受注、注文、在庫など販売系の業務エラー。
-20200〜-20299請求、入金、会計連携など金額系の業務エラー。
-20300〜-20399権限、承認、締め処理、状態遷移など運用ルール系のエラー。
-20900〜-20999共通基盤、想定外エラーのラップ、外部連携共通エラー。

番号帯は例です。大切なのは、番号を見たときにどの領域のエラーか分かること、そして新しいエラーを追加する場所が迷子にならないことです。既存システムに番号規約がある場合は、それを優先します。

メッセージテーブルを設計する

エラー文言をPL/SQLコードに直書きすると、文言変更のたびに再デプロイが必要になります。実務では、エラーコード、Oracleエラー番号、表示メッセージ、ログ用テンプレート、HTTPステータス、再試行可否をメッセージテーブルで管理すると扱いやすくなります。

app-error-message-ddl.sql
CREATE TABLE app_error_message (
  error_code        VARCHAR2(50)  NOT NULL,
  locale           VARCHAR2(10)  DEFAULT 'ja' NOT NULL,
  oracle_error_no   NUMBER       NOT NULL,
  severity          VARCHAR2(20) DEFAULT 'ERROR' NOT NULL,
  user_message      VARCHAR2(1000) NOT NULL,
  log_message       VARCHAR2(2000),
  http_status       NUMBER DEFAULT 400 NOT NULL,
  retryable         CHAR(1) DEFAULT 'N' NOT NULL,
  enabled           CHAR(1) DEFAULT 'Y' NOT NULL,
  updated_by        VARCHAR2(128) DEFAULT SYS_CONTEXT('USERENV','SESSION_USER') NOT NULL,
  updated_at        TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
  CONSTRAINT pk_app_error_message PRIMARY KEY (error_code, locale),
  CONSTRAINT ck_app_error_no CHECK (oracle_error_no BETWEEN -20999 AND -20000),
  CONSTRAINT ck_app_error_severity CHECK (severity IN ('INFO','WARN','ERROR','FATAL')),
  CONSTRAINT ck_app_error_retry CHECK (retryable IN ('Y','N')),
  CONSTRAINT ck_app_error_enabled CHECK (enabled IN ('Y','N'))
);

CREATE UNIQUE INDEX uq_app_error_message_no
  ON app_error_message(oracle_error_no, locale);

user_messageは画面やAPIレスポンスに返してよい文言です。log_messageは運用者や開発者向けの詳細です。DBの内部名、SQL、スタックトレース、個人情報などをユーザー向け文言に混ぜないよう、列を分けておくのがポイントです。

初期メッセージを登録する

メッセージテーブルは作っただけでは意味がないので、エラーコードの命名規則と初期データをセットで管理します。コードは英大文字とアンダースコアで、APIやログから機械的に扱いやすい名前にします。

app-error-message-seed.sql
INSERT INTO app_error_message (
  error_code, locale, oracle_error_no, severity,
  user_message, log_message, http_status, retryable
) VALUES (
  'ORDER_CLOSED_PERIOD', 'ja', -20101, 'ERROR',
  '締め済み期間のため登録できません。',
  'order_date={1}, closing_period={2}',
  409, 'N'
);

INSERT INTO app_error_message (
  error_code, locale, oracle_error_no, severity,
  user_message, log_message, http_status, retryable
) VALUES (
  'ORDER_STOCK_SHORTAGE', 'ja', -20102, 'ERROR',
  '在庫が不足しています。',
  'item_code={1}, requested_qty={2}, available_qty={3}',
  409, 'N'
);

INSERT INTO app_error_message (
  error_code, locale, oracle_error_no, severity,
  user_message, log_message, http_status, retryable
) VALUES (
  'EXTERNAL_TIMEOUT', 'ja', -20910, 'WARN',
  '外部システムとの通信に失敗しました。時間をおいて再実行してください。',
  'destination={1}, timeout_seconds={2}',
  503, 'Y'
);

番号とコードの両方を持つ理由は、PL/SQL例外としては番号が必要で、APIやアプリケーションでは文字列コードのほうが扱いやすいためです。たとえば-20101はOracle側の例外番号、ORDER_CLOSED_PERIODは画面やAPIで使う業務コードとして扱います。

RAISE_APPLICATION_ERRORを直接呼ばない

各プロシージャから直接RAISE_APPLICATION_ERRORを書くと、番号、文言、ログの形式がばらつきます。共通パッケージを作り、エラーコードを指定して投げる形に寄せると、メッセージ取得やログ連携を集中管理できます。

app-error-package.sql
CREATE OR REPLACE PACKAGE app_error AS
  PROCEDURE raise_error(
    p_error_code VARCHAR2,
    p_arg1       VARCHAR2 DEFAULT NULL,
    p_arg2       VARCHAR2 DEFAULT NULL,
    p_arg3       VARCHAR2 DEFAULT NULL,
    p_locale     VARCHAR2 DEFAULT 'ja'
  );

  FUNCTION message_of(
    p_error_code VARCHAR2,
    p_locale     VARCHAR2 DEFAULT 'ja'
  ) RETURN VARCHAR2;

  FUNCTION current_code RETURN VARCHAR2;
END;
/

CREATE OR REPLACE PACKAGE BODY app_error AS
  g_current_code VARCHAR2(50);

  FUNCTION replace_args(
    p_template VARCHAR2,
    p_arg1     VARCHAR2,
    p_arg2     VARCHAR2,
    p_arg3     VARCHAR2
  ) RETURN VARCHAR2 IS
  BEGIN
    RETURN REPLACE(
             REPLACE(
               REPLACE(p_template, '{1}', NVL(p_arg1, '')),
             '{2}', NVL(p_arg2, '')),
           '{3}', NVL(p_arg3, ''));
  END;

  FUNCTION message_of(
    p_error_code VARCHAR2,
    p_locale     VARCHAR2
  ) RETURN VARCHAR2 IS
    v_message app_error_message.user_message%TYPE;
  BEGIN
    SELECT user_message
      INTO v_message
      FROM app_error_message
     WHERE error_code = p_error_code
       AND locale = p_locale
       AND enabled = 'Y';

    RETURN v_message;
  EXCEPTION
    WHEN NO_DATA_FOUND THEN
      RETURN '処理を完了できませんでした。';
  END;

  PROCEDURE raise_error(
    p_error_code VARCHAR2,
    p_arg1       VARCHAR2,
    p_arg2       VARCHAR2,
    p_arg3       VARCHAR2,
    p_locale     VARCHAR2
  ) IS
    v_no      app_error_message.oracle_error_no%TYPE;
    v_message app_error_message.user_message%TYPE;
  BEGIN
    g_current_code := p_error_code;

    SELECT oracle_error_no, user_message
      INTO v_no, v_message
      FROM app_error_message
     WHERE error_code = p_error_code
       AND locale = p_locale
       AND enabled = 'Y';

    RAISE_APPLICATION_ERROR(
      v_no,
      p_error_code || ': ' || replace_args(v_message, p_arg1, p_arg2, p_arg3),
      TRUE
    );
  EXCEPTION
    WHEN NO_DATA_FOUND THEN
      g_current_code := 'UNKNOWN_ERROR_CODE';
      RAISE_APPLICATION_ERROR(-20999, 'UNKNOWN_ERROR_CODE: ' || p_error_code, TRUE);
  END;

  FUNCTION current_code RETURN VARCHAR2 IS
  BEGIN
    RETURN g_current_code;
  END;
END;
/

第三引数にTRUEを渡すと、既存のエラースタックに追加する動きになります。原因例外を完全に消したくない場合に有効です。一方、利用者へ見せる文言を完全に置き換えたい場合はFALSEを使う選択もあります。どちらの場合も、ユーザーへ返すメッセージに内部スタックをそのまま出さないよう、APIや画面側では別途整形します。

業務処理から使う

業務処理では、番号や文言を直接書かず、エラーコードだけを指定します。こうしておくと、文言変更や多言語対応をしても業務ロジックを変えずに済みます。

use-business-error.sql
CREATE OR REPLACE PROCEDURE register_order(
  p_order_date DATE,
  p_item_code  VARCHAR2,
  p_qty        NUMBER
) AS
  v_closing_period DATE;
  v_available_qty  NUMBER;
BEGIN
  v_closing_period := DATE '2026-05-31';

  IF p_order_date <= v_closing_period THEN
    app_error.raise_error(
      p_error_code => 'ORDER_CLOSED_PERIOD',
      p_arg1       => TO_CHAR(p_order_date, 'YYYY-MM-DD'),
      p_arg2       => TO_CHAR(v_closing_period, 'YYYY-MM-DD')
    );
  END IF;

  SELECT stock_qty
    INTO v_available_qty
    FROM item_stock
   WHERE item_code = p_item_code;

  IF v_available_qty < p_qty THEN
    app_error.raise_error(
      p_error_code => 'ORDER_STOCK_SHORTAGE',
      p_arg1       => p_item_code,
      p_arg2       => TO_CHAR(p_qty),
      p_arg3       => TO_CHAR(v_available_qty)
    );
  END IF;

  -- 注文登録処理
END;
/

業務ロジック側は、ORDER_CLOSED_PERIODORDER_STOCK_SHORTAGEという意味のあるコードだけを扱います。これにより、エラー番号や文言の管理を共通パッケージに閉じ込められます。

ログ用詳細を残す

ユーザー向けメッセージは短く安全にし、調査用の詳細はログへ残します。DBMS_APPLICATION_INFOやバッチログと組み合わせると、どの処理でどの業務エラーが発生したか追いやすくなります。処理名やアクションを残す考え方は インストゥルメンテーション設計 と相性がよいです。

app-error-log-ddl.sql
CREATE TABLE app_error_log (
  log_id          NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  error_code      VARCHAR2(50),
  oracle_error_no NUMBER,
  module_name     VARCHAR2(128),
  action_name     VARCHAR2(128),
  user_message    VARCHAR2(1000),
  log_detail      VARCHAR2(4000),
  backtrace       CLOB,
  created_by      VARCHAR2(128) DEFAULT SYS_CONTEXT('USERENV','SESSION_USER') NOT NULL,
  created_at      TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL
);

CREATE INDEX ix_app_error_log_created
  ON app_error_log(created_at, error_code);
app-error-log-procedure.sql
CREATE OR REPLACE PROCEDURE write_error_log(
  p_error_code      VARCHAR2,
  p_oracle_error_no NUMBER,
  p_user_message    VARCHAR2,
  p_detail          VARCHAR2,
  p_backtrace       CLOB
) AS
BEGIN
  INSERT INTO app_error_log(
    error_code,
    oracle_error_no,
    module_name,
    action_name,
    user_message,
    log_detail,
    backtrace
  ) VALUES (
    p_error_code,
    p_oracle_error_no,
    SYS_CONTEXT('USERENV','MODULE'),
    SYS_CONTEXT('USERENV','ACTION'),
    p_user_message,
    p_detail,
    p_backtrace
  );
END;
/

ログを必ず残したいからといって、安易にAUTONOMOUS_TRANSACTIONを使うと、本処理がロールバックされたのにログだけ残る設計になります。それが望ましい監査ログなのか、同一トランザクションで消えるべき処理ログなのかを決めて使い分けます。独立トランザクションの注意点は トランザクション設計パターン でも扱っています。

API向けJSONエラーに変換する

REST APIからPL/SQLを呼ぶ場合、Oracleのエラー番号や日本語文だけを返すより、構造化されたJSONエラーにすると扱いやすくなります。API設計の詳細は 例外をJSON形式で返すREST対応API設計 とつながります。

error-json-response.sql
CREATE OR REPLACE FUNCTION error_json(
  p_error_code VARCHAR2,
  p_locale     VARCHAR2 DEFAULT 'ja'
) RETURN CLOB IS
  v_msg app_error_message%ROWTYPE;
BEGIN
  SELECT *
    INTO v_msg
    FROM app_error_message
   WHERE error_code = p_error_code
     AND locale = p_locale
     AND enabled = 'Y';

  RETURN JSON_OBJECT(
    'ok' VALUE false,
    'error' VALUE JSON_OBJECT(
      'code' VALUE v_msg.error_code,
      'message' VALUE v_msg.user_message,
      'severity' VALUE v_msg.severity,
      'retryable' VALUE CASE v_msg.retryable WHEN 'Y' THEN true ELSE false END
    )
    RETURNING CLOB
  );
EXCEPTION
  WHEN NO_DATA_FOUND THEN
    RETURN JSON_OBJECT(
      'ok' VALUE false,
      'error' VALUE JSON_OBJECT(
        'code' VALUE 'UNKNOWN_ERROR',
        'message' VALUE '処理を完了できませんでした。',
        'retryable' VALUE false
      )
      RETURNING CLOB
    );
END;
/

APIではmessageだけでなく、coderetryableを返すとクライアント側が判断しやすくなります。たとえばORDER_STOCK_SHORTAGEなら画面で数量修正を促し、EXTERNAL_TIMEOUTなら再実行ボタンや時間をおいた再試行を案内できます。

バッチログと連携する

バッチ処理では、画面表示よりも再実行可否、処理件数、失敗理由の集計が重要になります。エラーコードをバッチ実行履歴に残しておくと、異常検知や運用通知で使いやすくなります。ジョブ実行履歴の考え方は ジョブ実行履歴の集中管理と異常検知 が参考になります。

batch-error-handling.sql
BEGIN
  DBMS_APPLICATION_INFO.SET_MODULE('ORDER_IMPORT_BATCH', 'load_orders');

  order_import_main.run;

EXCEPTION
  WHEN OTHERS THEN
    IF SQLCODE BETWEEN -20999 AND -20000 THEN
      write_error_log(
        p_error_code      => NVL(app_error.current_code, 'BUSINESS_ERROR'),
        p_oracle_error_no => SQLCODE,
        p_user_message    => SQLERRM,
        p_detail          => 'business error in order import',
        p_backtrace       => DBMS_UTILITY.FORMAT_ERROR_BACKTRACE
      );
    ELSE
      write_error_log(
        p_error_code      => 'UNEXPECTED_ERROR',
        p_oracle_error_no => SQLCODE,
        p_user_message    => '処理中に予期しないエラーが発生しました。',
        p_detail          => SQLERRM,
        p_backtrace       => DBMS_UTILITY.FORMAT_ERROR_BACKTRACE
      );
    END IF;

    RAISE;
END;
/

ログ連携では、SQLERRMを文字列解析してエラーコードを取り出す設計は避けます。メッセージ形式変更で壊れやすいため、業務エラーコードはパッケージ変数やログ引数として明示的に渡します。想定外エラーでは、ユーザー向け文言を汎用化し、詳細なSQLERRMとバックトレースはログ側に閉じ込めます。

多言語対応と文言変更

メッセージテーブルにlocaleを持たせておくと、日本語、英語などの文言を切り替えられます。ただし、業務エラーコード自体は言語に依存しない固定値にします。文言だけを変え、クライアントやログ分析で使うerror_codeは変えないのが基本です。

app-error-message-en.sql
INSERT INTO app_error_message (
  error_code, locale, oracle_error_no, severity,
  user_message, log_message, http_status, retryable
) VALUES (
  'ORDER_STOCK_SHORTAGE', 'en', -20102, 'ERROR',
  'Insufficient stock.',
  'item_code={1}, requested_qty={2}, available_qty={3}',
  409, 'N'
);

運用で文言を変えたい場合は、誰がいつ変更したかを残します。設定値やFeature Flagと同じく、メッセージも本番挙動に影響するデータです。変更履歴や権限設計は 設定値管理テーブルとFeature Flag設計 の考え方を流用できます。

やってはいけない設計

エラーコード設計は、細かくしすぎても雑すぎても運用しにくくなります。最初に避けたいパターンを決めておくと、開発者ごとのばらつきを減らせます。

全部-20001にするアプリや運用がエラー種別を判断できず、結局メッセージ文字列に依存します。
文言をコードに直書きする文言変更でPL/SQLのデプロイが必要になり、画面/API/バッチで表現がばらつきます。
内部情報をユーザーに返すテーブル名、SQL、スタックトレース、個人情報を画面やAPIに出すと危険です。
想定外例外を業務エラーにする本来調査すべき障害が、利用者の入力ミスのように見えてしまいます。
エラーコードを後から意味変更するログや外部API利用者に影響します。廃止して新コードを追加するほうが安全です。

設計チェックリスト

新しい業務エラーを追加するときは、次の観点で確認します。このチェックをレビュー観点にしておくと、エラー体系が崩れにくくなります。

コード名は意味が明確かERROR_001ではなく、ORDER_CLOSED_PERIODのように読める名前にします。
番号帯は正しいか機能領域やエラー種別に合った-20000番台を使います。
ユーザー向け文言は安全か内部名や調査用詳細を含めず、次に何をすればよいかを示します。
ログ用詳細は十分か調査に必要なキー、処理名、入力値の一部、バックトレースを残せるようにします。
APIやバッチが判断できるかHTTPステータス、再試行可否、業務コードが返せる設計にします。

まとめ

PL/SQLの業務エラーコード設計では、RAISE_APPLICATION_ERRORをただ使うだけでは不十分です。エラー番号、業務コード、ユーザー向け文言、ログ用詳細、HTTPステータス、再試行可否を整理し、共通パッケージから投げる形にすると、画面、API、バッチ、ログで一貫した扱いができます。

特に重要なのは、業務エラーとシステムエラーを分けること、-20000番台を機能領域ごとに割り当てること、文言をメッセージテーブルで管理すること、内部情報をユーザーに返さないことです。エラーコードは障害対応時の共通語彙になります。最初に小さな体系を決めておけば、機能追加やAPI化、バッチ運用が進んでも、エラーの意味を追いやすいPL/SQL基盤にできます。