数百万行のHaskell:Mercuryのプロダクションエンジニアリング
(blog.haskell.org)- Mercuryは、コメントなどを除いて約200万行のHaskellコードベースで30万社以上の企業にバンキングサービスを提供しており、2025年には2,480億ドルの取引高と年換算売上6億5,000万ドルを処理している
- MercuryにおけるHaskell活用の価値は、純粋性そのものよりも、運用知識をAPIと型に埋め込み、危険な動作を狭い境界の背後に置き、安全な経路を簡単な経路にすることにある
- 信頼性は、失敗をすべて防ぐことではなく、システムが変動を吸収する能力として捉えられており、型システムはエラーのクラスを排除し、制度的知識をコンパイラが強制するドキュメントのように残してくれる
- Mercuryは、金融ワークフローのリトライ、タイムアウト、キャンセル、クラッシュ復旧のために、Temporalをdurable executionフレームワークとして使っており、Haskell SDK
hs-temporal-sdkをオープンソースとして公開している - Haskellの本番運用における価値は、すべてを型に入れることではなく、データ損失・金融エラー・規制問題につながる不変条件は型で保護しつつ、複雑性はカプセル化し、テスト・ドキュメント・コードレビューとあわせて運用することにある
MercuryのHaskell運用規模と信頼性の視点
- Mercuryは、コメントなどを除いて約200万行規模のHaskellコードベースを運用している
- Mercuryは30万社以上の企業にバンキングサービスを提供するフィンテック企業で、2025年には2,480億ドルの取引高と年換算売上6億5,000万ドルを処理している
- 従業員は約1,500人で、エンジニアリング組織は主に汎用的な開発者を採用しており、その大半は入社前にHaskellを使った経験がない
- このシステムは、急成長、SVB危機で5日間のうちに20億ドルの新規預金が流入した状況、規制審査、大規模金融システムにおける一般的・非一般的な状況を経ながら、数年にわたって動作してきた
信頼性は失敗防止ではなく変動吸収能力
- 従来の信頼性アプローチは、失敗を列挙し、検査とテストを追加し、バグを見つけることに集中するが、それだけでは十分ではない
- Mercuryは信頼性を、システムが変動を吸収する能力として扱っている
- システムは優雅に性能低下できなければならない
- 運用担当者がシステムを理解し、調整できなければならない
- アーキテクチャは、正しいことを簡単にし、間違ったことを難しくしなければならない
- 急速に成長する組織では、新しく加わったエンジニアがモジュールを読んで理解できるか、データベースが遅いときにサービスも一緒に崩れるか、インターフェースの誤用をコンパイラが検出できるかが、実際の運用上の問いになる
- 型システムは、単純な正しさの証明というより、運用支援装置に近い
- 特定のエラークラスを排除する
- 書き手が去ったあとでも、制度的知識をコンパイラが読める形で残す
- Wikiよりも一貫して強制されるドキュメントの役割を果たす
- Mercuryの安定性エンジニアリングは、製品開発を遅らせる品質警察ではなく、機能が壊れたときの影響を設計初期から扱う協業のやり方である
- 失敗時のブラストラディウス
- 冪等性が必要な作業とその方法
- ロールバックの形
- 進行中の作業の扱い
- 失敗を吸収するシステムと増幅するシステムをあらかじめ見極める
純粋性は言語の性質ではなくインターフェース境界
- Haskellの純粋性は、内部に副作用がまったくないことを意味するのではなく、インターフェースが副作用の漏出を防ぐ境界を作るという意味に近い
bytestring、text、vectorのようなライブラリの純粋関数の背後には、可変アロケーション、バッファ書き込み、unsafe coercionのような内部実装が存在するSTモナドは、計算の中で観測可能なインプレース変更や副作用を使うが、runSTのrank-2型が内部で作られた可変参照の脱出を防ぐrunST :: (forall s. ST s a) -> a- 内部では命令的な動作が可能でも、外部には結果だけが出てきて、変更可能な状態は境界の外へ漏れない
- この原則は運用システム全体に適用される
- データベース層は内部的に接続プーリング、リトライ、可変状態を使える
- キャッシュは並行可変マップを使える
- HTTPクライアントはサーキットブレーカー、接続プール、多くの簿記的管理を持ちうる
- 重要なのは、危険な動作を狭いインターフェースで包み、誤用を難しくすることだ
- 実システムでの目標は、変更を完全に避けることではなく、変更がどこにあるかを明確にし、コードベースのうち誰がそれを知る必要があるかを制限することである
正しいことを簡単なことにする
- 大規模コードベースでは、正しさが特定の順序や見えない追加手順に依存するパターンがよく生まれる
- トランザクション後に監査ログをflushしなければならない
- エンドポイント呼び出し前にfeature flagを確認しなければならない
- 通知のenqueueをデータベーストランザクション内で行わなければならない
- こうした運用知識がWiki、オンボーディング文書、過去のデザインレビュー、Slackスレッド、一部のシニアエンジニアの記憶にしか存在しないと、すぐに失われる
- Haskellは、こうした手続きを型としてエンコードし、忘れられないようにできる
- 悪い方法は、正しい関数を使うよう頼みつつ、迂回経路を残しておくことだ
-- Please use this one, not the other one writeWithEvents :: Transaction -> [Event] -> IO () -- Don't use this directly (but we can't stop you) writeTransaction :: Transaction -> IO () publishEvents :: [Event] -> IO ()- より良い方法は、作業を実行する唯一の経路にイベント発行が含まれるよう、型を再構成することだ
data Transact a -- opaque; cannot be run directly record :: Transaction -> Transact () emit :: Event -> Transact () -- The *only* way to execute a Transact: commit and publish atomically commit :: Transact a -> IO a - ここで型システムは、イベントに関する深い定理を証明するというより、正しい運用手順を最も簡単な経路にする
- 新しいエンジニアがトランザクションの書き方を尋ねたとき、型シグネチャと公開APIが答えを与え、シニアエンジニアが去っても知識は残る
永続実行とTemporal
- 金融システムのワークフローは、単一のトランザクション内に収まらない
- 支払い送信
- パートナー承認待ち
- 台帳更新
- 顧客通知
- キャンセルとタイムアウト処理
- パートナー側は成功したが、ワーカーが記録前に停止した場合
- ネットワーク障害で応答がない場合
- このようなフローには、状態、再試行、タイムアウト、冪等性、プロセスクラッシュやデプロイをまたいで継続する実行が必要になる
- Mercuryは過去に、データベースベースの状態マシン、cronジョブ、バックグラウンドワーカー、コードのあちこちにある再試行とタイムアウト処理で、こうしたプロセスを調整していた
- 動作はしていたが、脆弱で理解しづらく、運用インシデントの偏った原因になっていた
- TemporalはMercuryのdurable executionフレームワークであり、ワークフローを通常の逐次コードのように記述でき、プラットフォームが各ステップをイベント履歴に記録する
- ワーカーがワークフローの途中でクラッシュすると、別のワーカーが決定的なprefixをreplayして状態を再構築し、中断地点から継続する
- 再試行、タイムアウト、キャンセル、エラー処理は、各チームが個別に再実装するのではなく、プラットフォームが提供する
- Temporal workflowはイベント履歴に対する純粋関数に近い性質を持つ
- replayされたworkflowは、元と同じコマンドシーケンスを生成しなければならない
- この決定性の要件は、純粋コードにおける「同じ入力・同じ出力」という制約に似ている
- 副作用は、workflowの
IOに相当するactivityとして分離される
- Mercuryは、Temporalの公式Core SDKをRust FFIでラップしたHaskell SDK
hs-temporal-sdkを作成し、オープンソースとして公開した - Temporal導入のパターンは Temporal Replay conference発表 でも扱われており、Mercuryは脆弱なcron・状態マシンの連鎖をdurable workflowに置き換えることで、運用改善を得た
ドメインは転送層ではなくビジネス言語で設計する
- 成長したシステムでよくある失敗は、呼び出し元システムの概念がドメインモデルに漏れ出すことだ
- HTTPリクエストハンドラー向けに書かれたコードが、後にcronジョブ、キューベースのバックグラウンドワーカー、Temporal workflowで再利用されると、
StatusCodeException 409 "Conflict"のようなHTTP例外が非HTTPコンテキストに伝播する可能性がある - cronジョブには409応答を待つ呼び出し元はおらず、ステータスコードはビジネス上の意味を誤った層へ持ち込んでしまう
- 解決策は、ドメインエラーをドメイン型としてモデル化することだ
- 残高不足は
InsufficientFundsであるべき - 重複リクエストは
DuplicateRequestであるべき - パートナーのタイムアウトは
PartnerTimeoutであるべき
- 残高不足は
- 各境界には薄い変換層を置く
data PaymentError = InsufficientFunds | DuplicateRequest RequestId | PartnerTimeout Partner toHttpError :: PaymentError -> HttpResponse toHttpError InsufficientFunds = err402 "Insufficient funds" toHttpError (DuplicateRequest _) = err409 "Duplicate request" toHttpError (PartnerTimeout _) = err502 "Partner unavailable" toWorkerStrategy :: PaymentError -> WorkerAction toWorkerStrategy InsufficientFunds = Fail "Insufficient funds" toWorkerStrategy (DuplicateRequest _) = Skip toWorkerStrategy (PartnerTimeout _) = RetryWithBackoff - 転送層の関心事はエッジに置くべきであり、ドメインモデルは、Webハンドラー、CLI、cronジョブ、バックグラウンドワーカー、workflowエンジンのどこから呼び出されても、HTTPステータスコードを引きずるべきではない
型エンコーディングのコストと適切な線引き
- 不変条件を型に埋め込むのは強力だが、認知コスト、硬直性、要件変更時の難しさというコストがある
- 違反がデータ損失、金融エラー、規制上の問題、呼び出し待機のインシデントにつながるなら、型エンコーディングのコストは正当化される
- 単に今そうなっているとか、型レベルの手法を試してみたいという理由だけなら、コードベースを変更しづらくする可能性が高い
-
エンコードしすぎる側
- 不正な状態が表現不可能になり、ドメインが型で忠実にモデル化される
- ビジネスルールの変更が50個のモジュールを横断する型変更につながり、リファクタリングが長引く
- 新しいエンジニアが型シグネチャを理解しにくくなる
-
何もエンコードしない側
- 型は
String、IO ()、最悪の場合Dynamicに近づく - コードは変えやすいが契約がなく、意味は既存の作者の記憶に依存する
- 作者が去ると、システムがなぜ動いているのか分かりにくくなる
- 型は
-
有用な基準
- 静かな破損を防ぐ不変条件は、型に入れるほうがよい
- イベントなしでコミットされたトランザクション
- 監査ログなしで処理された支払い
- 見かけ上は可能でも、意味的には不可能な状態遷移
- 大きく失敗する不変条件は、分かりやすいエラーメッセージを伴うランタイム検査で十分な場合がある
- 500応答
- assertion失敗
- JSON境界での型不一致
- ドメイン全体を型でモデル化したいという欲求は抑えるべきだ
- ドメインには例外、後方互換ルール、相互に衝突するルール、特定顧客向けの特殊動作が存在する
- 型はコンパイラのためだけでなく、チームのための道具でもある
- テスト、ドキュメント、コードレビュー、例、プレイブックとともに防御層を構成すべきだ
- Mercury内部には、GADT、type family、状態遷移を追跡するphantom typeのような複雑な型レベル機構を使うライブラリもある
- 間違えると資金が誤って移動したり、規制上の不変条件が破られたりするメカニズムでは、このような複雑さが必要になる
- 重要なのは、複雑さをカプセル化することだ
- 型レベル状態マシンを実装するモジュールには、深く理解した少数の作者と十分なテストが必要だ
- 利用側のAPIは、一般的な型を持ついくつかの関数のように見えるべきだ
- product engineerが内部の型レベル証明機構を知らなくても、安全に呼び出せるようにすべきだ
- コードレビューで、他モジュールに触れるPRが、コンパイラをなだめるためにコピーした型注釈で埋め尽くされているなら、それは抽象化が境界を越えて漏れているサインだ
- 静かな破損を防ぐ不変条件は、型に入れるほうがよい
内省可能性のための設計
- 信頼性が適応能力であるなら、内省可能性はその能力を得るための方法の1つである
- オペレーターは見えないものを運用できず、チームは内部が不透明なシステムに適応しにくい
- Haskell には monkey patching がないため、ランタイム時にライブラリ内部の HTTP クライアントを差し替えたり、データベース呼び出しを OpenTelemetry span を出す関数に置き換えたりすることが難しい
- Rust も同じ制約を持つが、Rust エコシステムが
towerミドルウェアパターンへ収束した一方で、Haskell エコシステムは複数のアプローチに分かれている - ライブラリが具体的なトップレベル関数の束だけを公開している場合、計測を行うには新しいモジュールでラップし、人々が元のモジュールではなくそのモジュールを import することを期待しなければならない
-
関数レコード
- 最もよく使われる解決策は、具体関数の代わりに関数レコードを公開することである
-- A concrete module gives you no leverage: sendRequest :: Request -> IO Response -- A record of functions gives you all of it: data HttpClient = HttpClient { sendRequest :: Request -> IO Response , getManager :: IO Manager } - この方式なら、
sendRequestをタイミング計測でラップした新しいHttpClientを返せる - テスト用の fault injection、mock の差し替え、リトライ、tracing、リクエストの rewrite、テナントごとの動作といった横断的関心事をランタイム時に追加できる
- WAI の
type Middleware = Application -> Applicationのように、動作変換を合成可能にするパターンは運用上きわめて有用である
- 最もよく使われる解決策は、具体関数の代わりに関数レコードを公開することである
-
Monoidで合成されるインターセプター- ミドルウェアと interceptor の型は、たいてい
SemigroupとMonoidのインスタンスを持てる - WAI の
Middlewareは endomorphism であり、endomorphism は合成とidのもとで monoid を形成する - interceptor hook レコードはフィールドごとに合成できるため、tracing、timeout、task queue rewrite のような関心事を、別個の配管なしで
mconcatでまとめられるappTemporalInterceptors = mconcat [ retargetingInterceptor , otelInterceptor , sentryInterceptor , sqlApplicationNameInterceptor , loggingContextInterceptor , statementTimeoutInterceptor , teamNameInterceptor , clientExceptionInterceptor , workflowTypeNameInterceptor ] - 各 interceptor は独立したモジュールで1つの関心事だけを扱い、
memptyから必要なフィールドだけを override し、順序はリスト内に明示される
- ミドルウェアと interceptor の型は、たいてい
-
エフェクトシステム
effectful、polysemy、fused-effects、cleffのような effect system も別の道筋を提供する- 利用可能な演算を effect 型として定義し、production、testing、tracing 用の interpreter を呼び出し地点で差し替えられる
- effect を横取りしてメトリクス記録や遅延注入を行った後、実際の handler に送り直すこともできる
- 欠点は、型レベルの effect list、handler stack、扱いにくい型エラーといった仕組みが追加されることである
- 関数レコードは、新しいエンジニアでも半日あれば理解できるほど単純である
-
persistentの良い例persistentのSqlBackendは、connPrepare、connInsertSql、connBegin、connCommit、connRollbackのような関数レコードである- OpenTelemetry 計測を追加する際、関連フィールドをラップすることで、すべてのデータベース操作に tracing span を付けられた
- fork することなく、ほぼソース変更なしでデータベース層の可視性を確保できた
-
運用しにくいライブラリ
- Mercury は Hackage に公開されている Web API クライアントバインディングをほとんど使っていない
- サードパーティ製バインディングが具体関数で HTTP 呼び出しを行うと、tracing、SLO に合わせた timeout、パートナー障害のシミュレーション、trace にある 400ms の空白の説明が難しくなる
- そのため、クライアントを自前で書き、最初から可観測性を持たせている
-
小さなエコシステムのコスト
- 一部の Haskell ライブラリは見捨てられているわけではないが、明確に責任を持って素早く改善する主体のいない公共インフラのような状態にある
- 古いインターフェースが維持され、可観測性・境界設計・運用性に関する新しい設計を取り込む速度が遅いことがある
http-clientは直接的には HTTP/1.1 のみをサポートしており、十分実用的ではあるが、状況によっては回避策が必要になることもある
パッケージ作者のための運用上の要件
- ライブラリ作者は、ユーザーがソース修正なしで動作を注入できるように、関数レコード、effect 型、callback といった脱出口を提供すべきである
hs-opentelemetry-apiを依存関係に追加し、中核となるIO処理の周囲に span を置くだけでも、production でそのライブラリを運用するユーザーの助けになる- API パッケージは breaking change に対して保守的であり、アプリケーションが OpenTelemetry SDK を初期化していなければ inert に動作するよう設計されている
- パフォーマンスオーバーヘッドは最小限に抑えられており、ユーザーアプリケーションで予期しない例外や logging を発生させない
- 依存関係の footprint はまだ理想ほど小さくはなく、改善作業が進められている
- ライブラリコードから直接ログを書いてはならない
- logging framework を import して
stdoutやstderrに直接書く代わりに、callback、logger parameter、呼び出し側がルーティングできるログメッセージのデータ型を提供すべきである - ログの行き先は、アプリケーションの運用環境に属する判断である
- Mercury は構造化ログのパイプラインを observability stack へ送っており、ライブラリが
stderrに直接書くと JSON lines ストリームとは別の配管が必要になる
- logging framework を import して
.Internalモジュールの公開も検討できる- ユーザーが内部 API に依存し、refactor が難しくなる可能性への懸念は妥当である
- しかし、公開 API がすでにあらゆるユースケースを満たしていると確信できることはめったにない
- 安定性に関する明示的な警告がある
.Internalモジュールは、ユーザーがパッケージを fork して vendoring するより良い場合がある containers、text、unordered-containersは、Haskell エコシステムでこのやり方を採っている良い例である- ただし、ユーザーが静かに内部モジュールを使って必要なものを解決してしまうと、公開 API の欠陥に対する feedback が減る可能性がある
型に入れないもの
- プロダクションのHaskellにも、美しくない部分は存在する
unsafePerformIOは、日常的に依存しているライブラリの内部で使われているbytestringとtextは内部的に可変バッファを確保し、書き込み、freezeして結果を作る- 型は、生成中に何が起きたかを語らない
- 境界は慣習、慎重な reasoning、コードレビューによって保たれている
- 型安全な代替手段が性能や複雑さのコストを過度に大きくするなら、自分でこうした妥協を書くこともある
- 型が検証しない不変条件を文書化しなければならない
- 不便さを維持し、型安全な代替手段が実用的になったかを定期的に見直すべきである
- production Haskell とは妥協がないことではなく、妥協を規律をもって隔離することである
- Hackage 上の多くの Haskell ライブラリには、テストがほとんどないか、まったくない
- 「コンパイルできれば動く」という考えは、小さな純粋コードと強い型では時に正しいかもしれない
- IO-heavy なコード、外部システム連携、構造ではなく意味にバグがあるコードには、ほとんど当てはまらない
- 型は
Either ParseError Transactionを返すことは表現できても、次のことは表現できないamountフィールドをセントとしてパースするのか、ドルとしてパースするのか- パートナーAPIが省略されたフィールドと null フィールドを異なるものとして解釈するか
- リトライロジックが、うるう年の特定のタイミングの窓で二重課金を引き起こすか
- プロダクションではこうしたライブラリの上にシステムを構築し、検証されていない前提を受け継ぐため、自分たちの層の integration test で補わなければならない
- orphan instance、文脈上 total だと信じている partial function、到達不能だと約束した
error、ぎこちない FFI ラッパー、手作業の exception hierarchy のような妥協も蓄積していく - 目標は道徳的な純粋性ではなく、あらゆる妥協について、それがどこにあり、なぜ作られ、取り除くと何が壊れるのかを、コードレビュー、ドキュメント、例、テストを通じて分かるようにすることだ
Haskell をプロダクションで使う価値
- Haskell は初日から速い選択肢ではない
- 現在のエコシステムは、Next.js や Rails のような batteries-included な hot-reloading 開発環境をすぐには提供できない
- 必要なライブラリがないこともあるし、あっても一人が spare time で保守していることがある
- エラーメッセージが非常に難解なことがある
- 採用問題は誇張されている
- Mercury の CTO である Max Tagher は、backend Haskell engineer が Mercury 全体で最も採用しやすい役割だと公に述べている
- Haskell の仕事に対する需要が供給を上回っており、一般的な採用の力学が逆転している
- Mercury は Haskell の経験が深い人も、まったくない人も採用しており、後者は 6〜8週間 の研修プログラムで生産性を発揮できるようになる
- 明日 Haskell の専門家100人が必要なら採用母集団の問題は現実的だが、優れた汎用開発者を採用して育てる意思があるなら、それほど現実的ではない
- より大きな採用リスクは、母集団の大きさではなく気質にある
- Haskell は、正確さと抽象化を重視し、論文を読むのを楽しみ、既存の前提を疑う理想主義者を引き寄せる
- この強みが制御されなければ、プロダクション上の責任になりうる
- データベース層を新しい型レベルの関係代数エンコーディングで書き直そうとしたり、使い捨てスクリプトで
StringではなくTextを使っていないという理由でマージを拒否したり、あらゆる設計を最新論文風の total rewrite に引きずっていく態度は、チームを遅くする
- プロダクションの Haskell には実用主義の文化が必要だ
- 型システムは電動工具であって、宗教ではない
- すでに良い解法がある問題を、新しいメカニズムを発明する機会にしてしまうのは、プロダクションには向かない
- リターンは時間とともに現れる
- 動的型付けのコードベースでは数週間かかるリファクタリングが、型変更のあとコンパイラがすべての call site を教えてくれるため、数時間で終わることがある
- 新しいエンジニアは型シグネチャを読んで、モジュールの契約を理解できる
- ありえない状態が本当に表現不可能であるために、プロダクションインシデントが起きないこともある
- Mercury は、投資回収が数年ではなく数か月単位で現れると見ている
- 特に金融サービスでは、データ整合性バグのコストはユーザーの不満ではなく、規制上の指摘や他人のお金で測られる
- 型システムはリスクを取り除くわけではないが、急成長するコードベースに誤ってリスクを持ち込むことを難しくするツールを提供する
- Haskell のプロダクションでの価値は、銀の弾丸でも道徳運動でもなく、Haskell の習熟度がさまざまなチームでも危険な装置を境界内に閉じ込め、運用知識を保持し、安全な道を簡単な道にできる強力なツール群にある
1件のコメント
Hacker News のコメント
Haskell がこうしたことを型で強制するうえで最も強力な部類の言語であるのは確かだが、同じパターンは Rust や TypeScript でもかなりうまく機能する。
User -> LoggedInUser -> AccessControlledLoggedInUser のような流れで、Web アプリで繰り返される典型的な 認可バグ を防ぐやり方も気に入っている。
業界ではこのパターンはあまりにも使われていないと思う。
セキュリティ上、エスケープ前後の文字列を区別する必要があるなら、動的型付け言語でも Escaped クラスで包み、
escape(str)->Escaped、dangerouslyAssumeEscaped(str)->Escapedのような関数を置ける。性能コストがあるので折衷は必要だが、可能ではある。
もう 1 つの方法は Application Hungarian だが、こちらはコンパイラよりもプログラマの規律に強く依存する: https://www.joelonsoftware.com/2005/05/11/making-wrong-code-...
たとえば C# でも十分できるが、実際の型定義より視覚的ノイズのほうが大きくなりがちだ。
ただし「モナドは怖いからチュートリアルを書こう」みたいな反応を避けるために、それをあえて言わず名前も変えて呼ぶことが多い。
モナドよりは型クラスのような方向の影響のほうが大きい。
公称型 がないので、プリミティブ型を包む newtype 的なものを作るにはかなりハッキーなおまじないを覚える必要がある。
私の経験では、この種の型安全性を強制するうえでは OCaml のほうが Rust より強力だった。
GADT による高い表現力があり、多相バリアントやオブジェクト型 / レコード行型による利便性もあり、モジュールシステムとファンクタもある。
ガベージコレクションで十分な領域では、Rust の借用検査器がもたらす抽象化上の制約や難しさも避けられる。
数年間 Haskell で働くのは本当に好きだった。
意図的に探していたわけではないが、たまたま機会が訪れて、面白く知的刺激があった。
ただ残念ながら、Haskell だけを 3 年使ったあとでも、Rust での生産性のほうが Haskell のゆうに 2 倍 は高い。
Haskell には事前に知って避けるべき落とし穴が多く、書き手によってはほとんど読み取り専用言語のように消化しづらいことがある。
ツールチェーンはしばしば Nix と組み合わされるが、その Nix 自体も複雑な怪物であり、言語拡張もあちこちに散らばっている感じだ。
Cabal ファイルもいまひとつだし、コンパイラエラーに慣れるのにも時間がかかる。
最後のプロダクトではバックエンドを Typescript から Rust に移し始めたのだが、クラッシュにうんざりしていたからだ。
今ではそれを自分が犯した最大級の技術的失敗の 1 つだと見ている。生産性がものすごく落ちたからだ。
Rust 特有の時間の浪費の例として、データベース接続を開いて何かして閉じる、という 高階関数 を書くことがある。Haskell、TypeScript、JavaScript、C++、PHP なら些細なことなのに、Rust では Rust に詳しい友人たちに聞いても事実上不可能で、結局あきらめることになった。
また、リファクタリングを試みて丸 1 日型エラーを直し続け、最上位ファイルでエラーにぶつかって、結局は設計の根本部分のせいでそのリファクタリング全体が不可能だと判断し、全部巻き戻したことが何度もある。
しかも Rust は、具体型の代わりに インターフェースとして値を使う ことが、状況によって高度な技法と不可能のあいだのどこかにある、私の知る限り唯一の現代言語だ。
そのため、アプリケーションコード、つまりシステムコードやライブラリコードではないものは、おおむね Rust で書くべきではないという結論に至った。
それと「読み取り専用」というのがどういう意味なのかも知りたい。
一般的な認識とは違って、Mercury が Haskell を選び、初期リーダーたちが Haskell に豊富な経験を持っていたことは、成功に少なからず寄与した可能性があると思う。
Mercury の顧客の立場からすると、この会社は私のツールボックスの中核を占める企業の 1 つであり、Haskell の選択 が彼らの進展、開発、全体の歩みをよりよいものにしたという感覚を拭えない。
もちろん、ほとんどの言語について同じ主張はできるし、Haskell のような関数型言語が成功の方程式だという意味ではない。
ただ、「vibe coding」や LLM の時代以前にこうした意図的な決定をしたのは、とりわけ先見の明があったように見えるし、記事で詳しく語られているエンジニアリング文化と組み合わさった結果だと思う。
私も良い技術文化は好きだが、優れた技術文化を持つ会社が事業上の焦点の悪さのせいで死ぬのを見てきた。
さらに言えば、スタートアップ的なフィンテック文化が良い技術文化を生んだ可能性もある。
銀行として出発したわけではないので、たとえば SVB と違って、そこまで保守的である必要もなかったし、ひどい古代の技術スタックと統合する必要もなかった。
Haskell で成功したのは嬉しいが、Jane Street と OCaml の関係と同じく、会社が信じさせたがることとは違って、言語選択はビジネス面ではほとんど偶然に近いと思う。
ただしフロントエンドには何を使っているのか気になる。たぶんこの Haskell は全部バックエンドだろう。
新しく来た人たちに文化とスタイルを最初から染み込ませることができたからだ。
vibe coding 以前なら、そういう人たちの大半は、何の指示もなくいきなり飛び込んでハックしようとはしなかっただろう。
他のサービスから移ってくると本当に満足感がある。
親友がこの会社で働いているが、外から見ていても エンジニアリング文化 は良さそうに見える。
Haskell はこの仕事に合った道具で、その強みをうまく活かしていると思うが、成功のかなりの部分は単に会社全体の運営がうまいからかもしれない、とも感じる。
この著者なら、実際どんな言語を使っていたとしても、成功するエンジニアリング組織を運営していただろうと思う。
今 Real-World OCaml を読んでいて、すでに知っていたこともいくつかあるが、関数型プログラミングをさらに学んでいるところだ。
関数型プログラミングでは驚くほど堅牢なソフトウェア片を作れそうに見える。
ただ、悩みもある。
現在のプロダクトのバックエンドは NiceGUI で動いていて、その役目をうまく果たしている。
コードは妥当で MVVM になっており、最も重要なのは顧客ごとに WebSocket に接続してデータを消費し、分析結果を表示することだ。
顧客数は多くならないだろうし、Web サイトの訪問者も数十人、多くても数百人程度だと思う。
REPL やホットリロードも欲しいが、機能が増えていけば、ユーザー管理パネルや追加の分析などにおいて、関数型プログラミングがデータパイプライン変換にうまく合うかもしれないことも分かっている。
ただ、Haskell や OCaml は静的言語だ。
将来さらに大きくなって拡張しつつも動的なものを望むなら、Clojure や Elixir がよい選択になりそうだ。
同時に、いつかリファクタリングが必要になったとき壊れてしまうのではないかと不安でもある。
今は Python と Mypy を使っていて、フロントエンドは NiceGUI がバックエンド側で生成している。
cabal replにより、開発中の Web アプリを非常に素早く再読み込みできる。正直なところ、多くの Haskell ユーザーはこれを十分活用していないと思う。
Scheme、後には Racket という比較的非主流の言語で似たシステムに取り組んだことがあるが、規模は大きくなったものの、小さなチームが長期間にわたって保守可能で高速なペースを維持できた。
バグはあまり作らず、たいてい機能を非常に速く追加できた。
たとえば、機密データを AWS にホスティングするためのある認証を最初に達成した。
ときには、人気のあるプラットフォームなら既製コンポーネントで済むことをゼロから作らねばならず、機能追加が遅くなることもあった。
しかし、一度作ってしまえばうまく動き、また以前の速度に戻り、何十もの既製フレームワークの肥大化や複雑さに足を引っ張られることもなかった。
管理可能なプラットフォームを自分たちで制御していたので、必要が生じたときに AWS へ素早く移行することもできた。
システムには最初から、複雑なデータと Web 相互作用のための アーキテクチャ上の秘訣 もあり、これが多くの機能を素早く開発させ、その後も賢い方向へ勢いを与えてくれた。
Haskell のフィンテックと違うのは、チーム規模が非常に小さかったことだ。
一度にソフトウェアエンジニアは 2〜3 人だけで、運用をすべて担当する人がいた。
そのため、何百人もの人が調整しながら一貫したシステムを維持する難しさはなかった。
通常は 1 人がより技術的でアーキテクチャ寄りのコード変更を担い、もう 1 人が複雑なプロセスに関する膨大なビジネスロジック機能を素早く追加していた。
現在または近い将来の LLM 系 AI ツールを慎重に使えば、ソフトウェア開発でも、非常に小さくて非常に効果的なチームの効率の一部を得られるかもしれない。
思い浮かぶモデルは、ストーリーポイントを消化するために巨大な肥大化を量産し、持続可能性を他人任せにするのではなく、少数の非常に鋭い思考者たちがシステムに力を与えつつ、それを管理可能な道に乗せ続けるやり方だ。
諸刃の剣だ。
200 万行 は大きな達成だが、同時に相当な保守負担でもある。
Haskell の利点は理論上は明快だが、欠点は直感的につかみにくい。
誘惑はあらゆるものを型でモデル化することにある。
コードベースそのものがアプリケーションではなく ビジネス仕様 になってしまう。
ポリシー変更のたびに大規模なリファクタリングになり、Haskell の安全性ゆえに驚くほど手間がかかる場合もある。
結局のところ両方を同時には得られず、いつかは型に閉じ込められる。
Haskell は、とくにこの規模では本当に印象的で強力だが、固有の問題も持ち込む。
ビジネスロジックを型でモデル化したくなる誘惑は硬直した構造を生み、その構造が与える安全性は別種のリスクを見えにくくすることがある。
すべてを手に入れることはできないが、多くは得られる。
数年前に Jane Street でインターンをしたが、Haskell ではなく OCaml だったものの、そのバランスを本当にうまく取っているように見えた。
内在的複雑性が高く、信頼性と正確性が事業の存続と直結する領域であるにもかかわらず、驚くほど速く動いていた。
振り返ると、Jane Street の中核は、Stephen Weeks のように優れたセンスを持つ経験豊富な OCaml プログラマ を採用し、彼らに最初から中核ライブラリを作らせ、コードベース全体を導かせたことにあった。
残念ながら Mercury はこの点をそれほどはうまくやれていなかった。
正直、チューリング完全な型システムの最大の欠点は、理論上はコンパイルすると塵になるようなアプリケーションまで実装できてしまうことだ。
Bellroy の似た Haskell 成功事例が、まもなく開かれる Melbourne Compose ミートアップのテーマになっている: https://luma.com/uhdgct1v
関数型プログラミングで私が感じる問題は デバッグ だ。
より正確には、命令型プログラミング、とくに手続き型の強みだと思う。
関数型 / 宣言型スタイルでは、普通は何かがどう作られるかではなく、どのような状態であるべきかを記述し、言語がすべてを組み立てて最終結果を出してくれる。
すべてがうまくいけば素晴らしいし、より良いことさえあるが、そうならず期待した結果にならないとき、どうやってバグを見つけるのかが問題になる。
C のような言語では比較的単純だ。
1 行ずつ追い、各段階のあいだの実行状態、実質的には RAM を見て、期待と違えばその行で何かがおかしいのだから、そこに入って同じように進めばよい。
関数型プログラミングのように言語が状態を隠そうとするほど、これは難しくなる。
記事で最も長い節がこの問題、つまり “design for introspection” だったのも興味深い。
著者はコードをデバッグ可能にするために、意図的にかなりの努力をしなければならず、Haskell のしばしば見落とされる実用的な使い方について良い洞察を与えている。
取るに足らないコードでも同じだ。
他の主流言語はこれに近づけていない。
共有メモリ並行性のようにそう書けない状況ではトランザクションを使う。
これも他の主流言語は近づけていない。
しかも、null がないとか、暗黙の整数キャストがないといった分かりやすい利点はまだ数えていない。
Haskell のコードのデバッグが他言語より難しい、というのは完全に正しい。
しかし下位 90% のつまずきを取り除けば、当然そうならざるを得ない。
もちろん関数型だけに固有というわけではなく、Python や JavaScript のような、主として命令型の言語でも、Python シェル、ブラウザコンソール、Node / Deno / Bun シェル、ノートブックなどを最初のデバッグ層として使うことは多い。
REPL 中心のデバッグには興味深いトレードオフがある。
C のような言語では、プログラム全体のデバッグとブレークポイントから始めて、問題がありそうな正確な地点を当てようとすることが多い。
REPL 中心の世界では、プログラムの構成要素を REPL から直接もっとテストできるようにしようとする。
そのため、モジュール / API / 型の境界がデバッグ容易性に似てくる。
C / C++ のような命令型言語より、こうした境界をきちんと、しかも使いやすく作る圧力が強いことがある。
逆に、プログラム全体優先のデバッグと比べると、現実の奇妙なシナリオで単位間の複雑な統合問題を切り分けにくくなることもある。
しかし REPL 優先のアプローチは、統合 表面積 を最小化する方向へ導くことが多く、そのため関数型言語では命令型言語で見られるような統合効果が出にくいこともある。
関数型言語が状態を隠す、という表現は正確ではない。
これらの言語も命令型ハードウェア上で動き、実際のハードウェア状態を扱っている。
どこかの時点で両世界のあいだに翻訳があり、たぶん思っているほど違いはない。
必要なら依然として命令型のブレークポイントや命令型デバッガに戻れる。
だから私はこれを「REPL 主導」デバッグと呼んでいる。
REPL を使えば、問題のある単位、つまり驚くような出力を返す入力と、その正確なモジュール / API / 関数まで絞り込める。
ソースを見てもバグが分からなければ、命令型デバッガに送って、ほぼ同じ 1 行ずつ実行する体験を得られるし、追加の文脈も手に入る。
その時点では、すでに REPL で十分に絞り込めているので、単位自体が小さく狭く、良いブレークポイントを選ぶ必要もあまりない可能性が高い。
記事の “design for introspection” 節から受け取ったメッセージは、少し取り違えている気がする。
その節はデバッグ可能性ではなく 観測可能性 の話だ。
ロギング / テレメトリシステムを正しく接続し、テスト中にフェイクをモックし、個々のライブラリに任せるのではなく、リトライ / サーキットブレーカーをシステム全体レベルで追加する、という話だった。
命令型の世界でも、これはデバッグ問題ではなく、依存性注入、ミドルウェアの設置、公開 API 境界で具体クラスより抽象インターフェースを使う、といった分解の問題だ。
こうした設計提案はリファクタリングであり、デバッグ可能性というより、他人の公開 API に観測可能性ミドルウェアをどれだけ容易に差し込めるかに影響する。
Haskell 200 万行 がいったい何をしているのか想像しにくい。
コード量が本当に多いし、Haskell は少ないコードで多くをこなせる「密度の高い」言語という印象がある。
JSON のシリアライズ / デシリアライズ、REST API フレームワーク、ロギングなどのためのライブラリが多いからなのだろうか、と思う。
サードパーティのバインディングが具体関数で HTTP 呼び出しをしてしまうと、トレースを追加する方法もなく、SLO に合わせたタイムアウトを注入する方法もなく、テストでパートナー障害をシミュレートする方法もなく、トレース上の 400ms の空白を当て推量以外で説明する方法もない。
だから自分たちで書いた。
初期には作業が増えるが、自前のクライアントは最初からそのように作られているので 観測可能になるよう構成 されている。
相対的に非常に抽象的な考えを少ない文字数で表現できる、という意味だ。
これを「高水準」と呼ぶ人もいる。
ただし 200 万行というのは、最初に聞いたほど多いコード量ではないと思う。
とくに金融のような規制の多い領域の会社で、しかも何年もかけて蓄積されたコードならなおさらだ。
行数はある程度少なくできるかもしれないが、単語数はより命令的なオブジェクト指向言語とおおむね同じだ。
そういう世界では
St M -> C Tのような表現でも構わないが、実際のソフトウェアではTransactionState Debit -> Verified Transactionのように書くほうがはるかに有用だ。もう 1 つの部分は、LISP にまでさかのぼる文化的要因だ。
人々は、理解しづらい小技やマクロで行数を節約するために、過度に賢く振る舞いがちだ。
Mercury のような金融会社では、そうしたやり方よりも明快さと可読性が奨励されるのではないかと思う。
たとえばリンタが、
>>や>>=で 1 行に書く代わりに、モナドコードを丁寧な複数行のdo式に分けるよう促すかもしれない。