Digitization部Scan Engineeringグループの長尾です。2026年1月に入社し、もうすぐ半年が経ちます。
この記事では、最近稼働を開始した原本管理システム「taotie」を題材に、開発の背景と内容をご紹介します。
部の役割や向き合っている課題などの前提はコードの向こう側に事業と現場がある。Digitization部の魅力にまとまっていますので、先に目を通すと分かりやすいかと思います。
解こうとしている課題と、現場と地続きの難しさ
何をしているチームか
Digitization部は、お客様の名刺・請求書・契約書といった紙媒体をデータ化する業務を自社の拠点で支えており、多数のオペレーターによる作業が日々行われています。配送された紙媒体はスキャンされ、意味のあるデータに変換され、ビジネスデータベース「Sansan」、経理AXサービス「Bill One」、取引管理サービス「Contract One」などの各サービスに連携されます。
各サービスでのデータ化は業務フローや利用するシステムが異なり、それぞれが日々洗練されていっています。一方で「受領」「発送」「保管」「拠点内の移動」「外部倉庫との連携」といった横断的な作業も存在しており、これらをそれぞれの世界で別々に作ってしまうと個別最適化が進み、各業務に特化した教育やマニュアルなどの管理が必要になって属人性が高まってしまいます。
そこで、お預かりした書類、特にそれをまとめた箱を「原本」と呼び、原本に関わる物理的な業務を「原本管理業務」として切り出そうというプロジェクトが発足しました。箱の中身を意識せずに原本を横断的・一元的に管理するためのシステム、オペレーションを構築しようというのが私の所属するチームのミッションです。

「現場」がそのままモデルになる
原本管理業務の開発で特徴的なのは、相手にするものが画面とデータベースの中で完結しないという点です。拠点内の物理的な動線、保管棚、QRコード*1のラベル、伝票の貼り付けといった、手で触れる世界がそのまま設計対象になります。 例えばシンプルなケースでは、原本は次のような作業を通して物理的にも移動します。

データとしてのステータスは簡単に変更できますが、実際のオペレーションでは扱う原本を探す、作業場所へ移動させるなどは物理的なアクションを伴います。原本が「いま物理的にどこにあって、どういう状態か」を正しく追えないと、発送漏れ・発送ミス・取り違えというお客様の信頼に直結する事故につながってしまいます。
各サービスのオペレーションでは効率化やミスを防ぐための仕組みとしてさまざまな取り組みを行っており、クリアファイルの並べ方やスキャン漏れがないか卓上を確認する方法なども細かくルール化されています。システムの都合に合わないからと物理の業務を変更することはオペレーションに大きなインパクトを与えることになる一方で、現在のオペレーションが必ずしも最適というわけではありません。
例えばスキャン対象の原本を保管棚から取り出して作業エリアへ移動するという業務を考えた場合、システム設計の観点ではできるだけ正確にその状態をシステムに同期したいと考えます。棚から取り出した、台車に置いた、台車が移動した、作業エリアに置いたと細かくトラッキングしていけば、それだけ状態を正確にシステムに同期することができますが、その一方では作業の手間が増えてオペレーション全体のスループットは落ちてしまいます。
しかし、「棚から取り出した後の移動先を1つに限定」して「保管棚から原本を取り出して運ぶまでが作業の一連の流れで、中断することはない」という形でオペレーションが整理できれば、その中間の状態を追う必要がなくなります。

このようにコンピュータシステムだけではなく文字通りに仕組みという意味でのシステムが設計対象となります。現場の制約をシステムの工夫とオペレーションの工夫の両面から解決していくことが難しいところでもあり、面白いところでもあります。
AIを主戦力にするために整えたこと
そんな原本管理システムですが、構築にはもう1つ大きな難しさがありました。背景にあるのは、時間も人も限られていたことです。横断システムを早期に立ち上げ、その後も継続的に進化させられる状態にする必要がありました。
業務要件や必要な機能の整理はある程度できていましたが、システムの設計はまだゼロの状態。私自身も業務知識がほとんどない状態で、開発担当はマネジャーと私の2名のみ。それでも1カ月半後には最初のリリースをしたい。なかなか厳しい状況ではありますが、だからこそ戦略的に取り組む必要がありました。
段階的なリリース設計
前述の通り、私が参画した時点では「こういうものが欲しい」という大まかな要件は整理されていました。しかし、それはあくまで理想の姿であり、最初のリリースに合わせてすべての機能を作り切ることは明らかに不可能です。「原本の受領と保管はできるけど取り出しはできない」というシステムを作っても現場では何の役にも立ちません。
そこで重要になるのが現場の運用が成り立つスコープの設計です。現場での実際の作業手順、今後形にしていきたい理想の姿、現状の課題を整理した結果としてリリースを次のように設計しました。
- 1stリリース: 機能を最小限に絞った「まず動くもの」
- 2ndリリース: 現場の効率を向上させる「実際に使えるもの」
- 3rdリリース以降: 運用を進化させる「+αの理想的な姿」
各リリースでどのように機能を広げていったかは、次の図のようなイメージです。結果として1stリリースは予定通り1カ月半で現場投入でき、現在は2ndリリースまでが完了しています。

AIを生かすための仕組み
さて、段階的に機能を広げるとはいえ、少人数の開発チームで継続的にリリースを積み重ねていくにはスピードが必要です。Digitization部では、すでにテスト移行の事例や、ノンエンジニアが実装に参画する取り組みなど、AI駆動開発の文化が根づいています。このプロジェクトではその流れの中で、「新規システムの開発を、どこまでAIを主戦力にして進められるか」に踏み込みました。
しかし「AIを主戦力にする」とはAIに丸投げすることではありません。AIの力を引き出し、かつ人が品質を握り続けるための仕組みを整える必要があります。「何を・どう作るか」をAIに伝える仕組みと、AIの出力を検証・修正する仕組みです。
(1) 段階的なルールの明文化
AIに渡す指示を、粒度の異なる層で構造化しています。それぞれの粒度のルールはAGENTS.mdやskillsなどにまとめ、日々更新していきます。
- 全体方針: プロジェクト全体の設計思想。「関数型DDDを中心とする」「原則は
type+ 関数で書き、class/interfaceは明確な理由があるときだけ使う」など。 - 領域別のルール: 適用範囲を絞ったルール。レイヤーの責務、命名規約、ドメインルールなど。
- 写経できるテンプレート: AIがそのまま真似できる具体的なコード雛形。
(2) AIによる検証・修正ループの仕組み化
モデルやエージェントの進歩によってAIの能力は日々進化していますが、それでも1回で完璧なコードを出すことは難しいです。AIが自分自身で精度を上げていける仕組みを整えることで、品質を安定させます。
- 型チェック・Lint・テストを回し、失敗の種類ごとに修正手順を踏むループをAI側で完結させる
- スキルを整備して再現性のある検証・修正をAIに任せる
- プルリクエストに対してAIが自動でレビューコメントを付け、品質を保証しつつレビュー待ちを減らす

なお、テストに関しても複数の粒度を用意できるとより安定します。現在は関数などをテストするユニットテスト、APIレベルでテストするバックエンドE2Eテスト、Playwrightを利用して画面のテストをするフロントエンドE2Eテストの3階層構成としています。
AIが安定して品質を出せる設計思想
ルールや検証の仕組みが整えばAIの出力は安定していきます。しかし、ルールの質は「自分が何を作りたいか」の解像度に依存します。イメージが曖昧な状態でいくらルールを書いても芯のない指示にしかなりません。特にドメインの判断は、現場を見て自分で決めるしかありません。
一方で技術的な設計思想にはパターンがあり、意図を持って選べばAIに任せられる領域が大きく広がります。ここでは、実際に採用した設計の例をいくつか具体的にお見せします。
TypeScript × ゆるめの関数型DDD
Digitization部ではTypeScriptまたはRubyを使ったプロジェクトが多いですが、このシステムでは型の強さを生かすためにTypeScriptを選びました。基本的にはエヴァンスのDDDの4層レイヤー構造を採用し、オニオンアーキテクチャやクリーンアーキテクチャからもある程度の要素を取り入れています。
こうしたレイヤー構造はAIの有無にかかわらず有効な定番の設計ですが、AIを主戦力とする開発では特に効果を発揮します。「どの責務をどの層に書くか」が一意に決まるため、AIへの指示・テンプレート・自動レビューの基準が明確になります。また、広く知られたパターンの名前はそれ自体が凝縮されたコンテキストとして機能するため、少ないルールの記述でも意図どおりのコードが出やすくなります。
TypeScriptは構造的型付け(structural typing)を生かしたかなり表現力の高い型システムを持っており、マルチパラダイムな言語でもあります。関数型DDDを中心としながら、厳密なOOPやFPにこだわるのではなく必要に応じて手段を使い分けています。
| 構成要素 | 選んだ手段 | 理由 |
|---|---|---|
| エンティティ / 集約 | type + 関数 |
不変性を保ちやすく、純粋関数でロジックを表現できるため |
| 値オブジェクト | class(private constructor + create) |
不正値の防止とResult型との相性、型の区別のため |
| ドメインサービス | 関数 | 複数集約にまたがるロジックを副作用なしの純粋関数で表現できるため |
| ユースケース | クロージャで依存を受け取るファクトリ関数 | 状態を持たず、シンプルにDIするため |
| リポジトリ | interface(ドメイン層に定義)、class(インフラ層に定義) |
依存性逆転(DIP)のため |
| エラー処理 | Result型(neverthrow) |
エラーが型に現れ、呼び出し側での処理を強制できるため |
判別可能ユニオンで「ありえない状態」を作れなくする
状態を持つ型の設計では、「ある状態のときだけ利用するフィールド」をnullableで宣言してしまいがちです。これを型で封じます。
// A. フラットなフィールド + optional type Operation = OperationBase & { status: "Received" | "TemporarilyStored" | "PendingShipment"; location?: Location; // いつ必須? shipping?: Shipping; // いつ必須? }; // B. 判別可能ユニオン type ReceivedOperation = OperationBase & { status: "Received"; }; type TemporarilyStoredOperation = OperationBase & { status: "TemporarilyStored"; location: Location; // このステータスでは必ず存在する }; type PendingShipmentOperation = OperationBase & { status: "PendingShipment"; shipping: Shipping; // このステータスでは必ず存在する }; type Operation = | ReceivedOperation | TemporarilyStoredOperation | PendingShipmentOperation;
アプローチAでは「一時保管中のときlocationは必ず存在する」というルールがコード上で表現できません。実行時のnullチェックで守るしかなく、チェック漏れがバグになります。これは人間にとってもAIにとっても認知負荷の高いパターンです。
アプローチBでは、statusを書いた時点で、付随するフィールドの存在が型で保証されます。さらにts-patternのパターンマッチを網羅性チェック付きで使うことで、各ステータスに固有のフィールドに安全にアクセスできるだけでなく、ステータスを追加したときに対応が漏れている箇所をコンパイルエラーにすることもできます。
この設計を選んだことで、ステータスごとに異なるデータ構造を持つというドメインルールを、型の力だけで正しく表現することができます。
値オブジェクトのclassによる公称型付け(nominal typing)
TypeScriptは型の互換性を構造(プロパティの形)で判定する構造的型付け(structural typing)を採用しており、型の名前で区別する公称型付け(nominal typing)とは対照的です。そのため、別の種類のIDがどちらも中身はstringだと、取り違えてもコンパイル(型チェック)が通ってしまいます。コンパイル時点で区別できる仕組みを作っておくとエラーの検出が早くなります。
class PackageId { private constructor(private readonly _value: string) {} static create(value: string): Result<PackageId, DomainError> { // バリデーション処理は省略 return ok(new PackageId(value)); } get value(): string { return this._value; } equals(other: PackageId): boolean { return this._value === other._value; } }
private constructor/private fieldを持つclassにすることで、構造的型付けの中でも実質的に公称型付けに近い区別ができます。private constructorとファクトリ(createがResultを返す)の組み合わせで、不正値の混入も構造的に防ぎます。
制約がなくて単純に型の区別がしたいのであればBranded Typeという選択肢もあるので、適度に使い分けるのがおすすめです。
Result型で「例外を投げない」
ビジネスロジックの中ではthrowを使わず、すべてResult型(neverthrow)で表現します。
// ユースケースの例 export const createUpdateStatusUseCase = (packageRepository: IPackageRepository) => ({ execute: (input: UpdateStatusInput): ResultAsync<UpdateStatusOutput, AppError> => { return PackageId.create(input.id) .asyncAndThen((packageId) => packageRepository.findById(packageId)) .andThrough(validateStatus) .andThen((aggregate) => processStatus(aggregate, input)) .andThen((changed) => packageRepository.update(changed)) .andThen((aggregate) => toOutput(aggregate)); }, }); // コントローラーの例 export const createUpdateStatusController = (deps: Dependencies) => { const useCase = createUpdateStatusUseCase(deps.packageRepository); return { handle: async (c: Context) => { return parseJsonBody(c) .andThen((body) => adapt(c.req.param(), body)) .asyncAndThen(useCase.execute) .map(present) .match( (data) => c.json(data), (error) => toErrorResponse(c, error), ); }, }; }; // エラーの例 type AppError = | ValidationError // クライアントの入力が不正 | DomainError // ビジネスルールに違反 | NotFoundError // 対象が存在しない | ConflictError // 競合(同時更新・重複など) | InfraError; // インフラ層の障害
戻り値の型の時点でエラーが発生することがわかり、成功・失敗の両方を必ず処理する形を強制しているので、「エラーを握りつぶす」といった事故が構造的に起こりにくくなります。慣れるまでは少々書きづらいかもしれませんが、ユースケースはかなり宣言的でシンプルに書けます。
コントローラーまでこのパターンを適用するかは好みが分かれるところかもしれませんが、ルールを整備すればかなり定型的なコードになるのでAIに任せやすい領域です。ちなみにインフラ層では他の層との境界においてResult型を返せば、内部ではtry-catchを使っても良いというルールにもしています。
こうした設計の選択は、「何を選び、なぜそう選んだか」をルールとして書き残すところまでがセットです。理由まで明文化しておくと、AIによる自動レビューでも設計方針とのずれを指摘できるようになります。
まとめ
現場と地続きであるという難しさは、このドメインならではの面白さでもありました。既存の物理のオペレーションをシステムに落とし込むだけではなく、業務全体をシステムとして作っていく必要があります。
そして、限られたリソースでの開発にはAIの力が欠かせません。振り返ってみると、AIに任せられる領域の広さは、結局「自分がどう作りたいか」の解像度で決まると感じています。ドメイン層の設計で「このステータス遷移をどう表現するか」をAIに聞けば、もっともらしい答えは返ってきます。でも「なぜそう分けるのか」「どのように物理作業と整合性を取るのか」などは、現場を見て、関係者と話して、必要ならAIと壁打ちをして、辛くても自分で判断するしかありません。
今回紹介した設計の例は、このプロジェクトにおけるルールのごく一部に過ぎません。どれもそれほど珍しいパターンではなく、当たり前に使っている方も多いと思います。どんなプロジェクトにもフィットする設計思想はないかもしれませんが、「このプロジェクトではこれを重視する。だからこうする。」といった意図を持って設計思想を選び、それをルールやコード構造に落とし込めば、AIに任せられる領域は広がっていきます。
強力な相棒であるAIを生かすための仕組みを継続的に整備し、安定で効率的な業務運用を実現していきたいと思います。
Sansan技術本部では中途の方向けにカジュアル面談を実施しています。Sansan技術本部での働き方、仕事の魅力について、現役エンジニアの視点からお話しします。「実際に働く人の話を直接聞きたい」「どんな人が働いているのかを事前に知っておきたい」とお考えの方は、ぜひエントリーをご検討ください。
*1:QRコードは株式会社デンソーウェーブの登録商標です