🌏

世界の住所、全部違う。だから型で守る。

に公開

はじめに

「郵便番号って何桁?」

この質問に対して「7桁じゃん」と思うかもしれませんが、それは日本の常識であって、世界の常識ではありません。

5桁の国、6桁の国、そもそも郵便番号がない国。都道府県に相当するものが77もある国、逆に行政区分が存在しない都市国家。住所の記載順序も、国によってまったく異なります。

ECサービスをグローバル展開する際、避けて通れないのが「住所」の国際対応です。
ソフトウェアの国際化対応は一般的に i18n(Internationalization) と呼ばれますが、その中でも住所は単純な翻訳では対応できず、設計そのものに強い影響を与える領域です。

実際に向き合ってみると、国が違えば「同じ“住所”という名前のデータでも、構造そのものがまったく異なる」ことに気づかされます。
この違いをどのようにシステムの中に取り込み、将来の拡張にも耐えられる形で設計するかは、国際対応における重要な論点の一つです。

本記事では、こうした国際住所が持つ本質的な難しさを整理した上で、それをどのように安全な型や再現性のある設計へ落とし込んでいくかという「向き合い方」にフォーカスして解説します。
あわせて、私たちが実際に Recustomer で採用している実装を一例として紹介します。

第1章:住所の国際的な違い

住所の国際化対応が難しいのは、国ごとに住所の構成要素や記載順序が大きく異なるためです。いくつかの国の住所例を見てみましょう。

日本の住所

〒150-0001
東京都渋谷区神宮前1-2-3
サンプルビル 101号室

日本では「大きい単位から小さい単位へ」という順序で記載します。郵便番号は7桁で、都道府県→市区町村→町名・番地→建物名という階層構造が明確です。

アメリカの住所

123 Main Street, Apt 101
San Francisco, CA 94102
USA

アメリカでは日本とは逆に「小さい単位から大きい単位へ」という順序です。番地が先頭に来て、州は2文字の略称(CA = California)で表記します。郵便番号(ZIP Code)は5桁または5+4桁形式です。

タイの住所

123/45 ซอยสุขุมวิท 23
แขวงคลองเตยเหนือ เขตวัฒนา
กรุงเทพมหานคร 10110

タイでは「ソイ(路地)」という独自の概念があり、番地も「123/45」のように分数表記が一般的です。行政区分も日本と異なり、県→区→地区という構造になっています。

イギリスの住所

Flat 2, 10 Downing Street
London
SW1A 2AA
United Kingdom

イギリスの郵便番号(Postcode)は「SW1A 2AA」のように英数字混合で、地域を細かく特定できる形式です。また、番地の前に建物内の部屋番号(Flat 2)を記載します。

ドイツの住所

Musterstraße 123
10115 Berlin
Germany

ドイツでは通り名の後に番地を記載し、郵便番号は5桁です。日本のような「都道府県」に相当する州は住所にあまり記載しません。

ここから分かる本質

観点 国ごとの違い
記載順序 大 → 小、小 → 大
郵便番号 数字のみ、英数字混在
行政区分 州・県・郡など階層が違う
番地表記 数字、分数、英数字
必須項目 国ごとに異なる
文字種 漢字、アルファベット、現地語

これらは単なる「表記の違い」ではありません。
住所とは「国ごとに分解の仕方そのものが違うデータ構造」であり、無理に共通化しようとすると、必ずどこかで歪みが出ます。

特徴
シンガポール 都市国家のため、州/県(Province)の概念がない
香港 地区(District)で分類され、都市名(City)は通常不要
ドイツ・フランス等 州/県は存在するが、住所には通常記載しない
アイルランド(地方部) 郵便住所上、都市名が必須でない地域がある

また、上記のケースもあり「都道府県」「市区町村」という日本の住所構造を前提にすると、他国の住所をうまく表現できません。単純に「住所1」「住所2」といった汎用フィールドを用意するだけでは、適切なバリデーションができず、データの品質も保証できません。

なぜ「住所の国際対応」は設計が難しいのか

ここまで見てきた国ごとの違いを踏まえると、住所の国際対応で直面する課題は主に次の4点に整理できます。

  • 国ごとに異なる住所構造を、どのようにシステムで適切に表現・保存するか
  • 国ごとの違いをすべて吸収しようとすると、共通モデルが肥大化しやすい
  • 国別に分岐を入れていくと、新しい国の追加時に修正漏れが発生しやすい
  • 実装は一度できても、「将来の拡張時に安全かどうか」が保証しづらい

本記事では、これらの課題にどのように対応したかを紹介します。

第2章:設計方針を決めるまでのプロセス

第1章で挙げた課題に対して、私たちがどのように設計方針を決めていったかを説明します。

2.1 検討した3つのアプローチ

最初に、以下の3つのアプローチを検討しました。

アプローチ 概要 懸念点
A. 国ごとに別モデル JPAddress, THAddress のように国別にクラスを定義 DBスキーマが国ごとに必要になり、クエリが複雑化する
B. 汎用フィールドのみ address1, address2 のような自由入力フィールドだけで構成 バリデーションができず、データ品質が保証できない
C. 共通構造 + 国別制約 フィールドは共通だが、必須/任意の判断は国ごとに分離 実装は複雑になるが、スキーマ統一とバリデーション両立が可能

私たちは アプローチC を採用しました。

理由は、既存の日本向けデータとの後方互換性を維持しつつ、国ごとに適切なバリデーションを行いたかったからです。

2.2 方針:「構造は緩く、制約は外に出す」

アプローチCを具体化するにあたり、次の方針を立てました。

  • 構造は緩く持つ: フィールドは Optional にして、どの国でも同じ型で表現できるようにする
  • 制約は外に出す: 必須/任意の判断は、国別のバリデータに委ねる

この方針により、「共通モデルの肥大化」という第1章の課題を回避しています。

2.3 国コードを「型」で表現する

すべての設計の起点となるのが「国コード」です。

文字列("JP", "TH")のまま扱うと、タイポや無効な値が混入するリスクがあります。
そこで、ISO 3166-1 alpha-2 に準拠した StrEnum として定義しました。

class CountryCode(StrEnum):
    JP = "JP"
    TH = "TH"

StrEnum を選んだ理由は、JSON へのシリアライズがそのまま文字列として行われ、フロントエンドとの連携が容易になるためです。

2.4 住所モデルの設計

方針に従い、住所モデルは以下のように設計しました。

@dataclass(frozen=True, slots=True)
class Address:
    country_code: CountryCode          # 必須:どの国の住所か
    postal_code: NonEmptyStr | None    # 国によって必須/任意/存在しない
    province: NonEmptyStr | None       # 国によって必須/任意/存在しない
    city: NonEmptyStr | None           # 国によって必須/任意/存在しない
    address1: NonEmptyStr | None       # 国によって必須/任意/存在しない
    address2: NonEmptyStr | None       # 国によって必須/任意/存在しない

ポイントは country_code だけが必須で、他はすべて Optional という点です。

シンガポールのように province の概念がない国でも、このモデルで表現できます。
必須かどうかの判断は、次章で説明する国別バリデータが担います。

第3章:国別バリデーションをどう実現するか

第2章で「制約は国ごとに分離する」という方針を立てました。
本章では、その方針を どのパターンで実現するか の検討プロセスを説明します。

3.1 検討した実装パターン

国別にバリデーションを分岐させる方法として、以下の3つを検討しました。

パターン 実装イメージ 懸念点
if-else の羅列 if country == "JP": ... elif country == "TH": ... 国が増えるたびに分岐が増え、見通しが悪くなる
辞書によるマッピング validators = {"JP": jp_validate, "TH": th_validate} キーの追加忘れを型で検出できない
Strategy パターン + assert_never 国ごとにクラスを分離し、match-case で網羅性チェック 実装コストはやや高いが、型で安全性を担保できる

私たちは Strategy パターン + assert_never を採用しました。

理由は、第1章で挙げた「新しい国の追加時に修正漏れが発生しやすい」という課題を、型システムで防ぎたかったからです。

3.2 Strategy パターンの採用

国ごとにバリデータクラスを分離し、共通のインターフェースを通じて呼び出す設計です。

class AddressValidatorProtocol(Protocol):
    def validate(self, address: Address) -> Result[None, Error]: ...

class JPAddressValidator:
    def validate(self, address: Address) -> Result[None, Error]:
        # 日本のルール: 郵便番号7桁、都道府県必須など

class THAddressValidator:
    def validate(self, address: Address) -> Result[None, Error]:
        # タイのルール: 郵便番号5桁、県必須など

この設計のメリットは、国ごとのルールが完全に分離されることです。
日本のルールを変更してもタイのバリデータには影響しません。

3.3 assert_never による網羅性保証

Strategy パターンだけでは、新しい国を追加したときにバリデータの実装を忘れるリスクが残ります。

これを防ぐために、match-caseassert_never を組み合わせました。

def get_address_validator(country_code: CountryCode) -> AddressValidatorProtocol:
    match country_code:
        case CountryCode.JP:
            return JPAddressValidator()
        case CountryCode.TH:
            return THAddressValidator()
        case _:
            assert_never(country_code)

assert_never は Python 3.11 で追加された関数で、「ここに到達することはありえない」ことを型チェッカーに伝えます。

この設計の効果は以下の通りです。

  1. CountryCode に新しい値(例: US = "US")を追加する
  2. get_address_validatorcase _: に到達する可能性が生じる
  3. assert_never(country_code)型エラー が発生する(mypy や pyright で検出)
  4. 開発者はバリデータの実装が必要であることに気づく

つまり、実装漏れを実行時ではなくコンパイル時(型チェック時)に検出できるのです。

これが、第1章で挙げた「新しい国の追加時に修正漏れが発生しやすい」という課題への回答です。

3.4 値オブジェクト生成時のバリデーション

バリデーションは Address.new() というファクトリメソッド内で実行しています。

(補足: これは一般に「Always-Valid Domain Model」と呼ばれる手法です)

@dataclass(frozen=True, slots=True)
class Address:
    # ... フィールド定義 ...
    @classmethod
    def new(
        cls,
        country_code: CountryCode,
        postal_code: NonEmptyStr | None,
        province: NonEmptyStr | None,
        city: NonEmptyStr | None,
        address1: NonEmptyStr | None,
        address2: NonEmptyStr | None,
    ) -> Result[Self, AddressValidationError]:
        address = cls(
            country_code=country_code,
            postal_code=postal_code,
            province=province,
            city=city,
            address1=address1,
            address2=address2,
        )
        # 国別バリデータを取得して検証
        validator = get_address_validator(country_code)
        if (result := validator.validate(address)).is_err():
            return Result.err(result.err_value())
        return Result.ok(address)

これにより、不正な住所オブジェクトが生成されること自体を防げます
住所オブジェクトが存在する = バリデーション済み、という保証が得られます。

これが、第1章で挙げた「将来の拡張時に安全かどうかが保証しづらい」という課題への回答です。

第4章:まとめ

本記事では、住所の国際化対応における「向き合い方」を紹介しました。

課題と解決策の対応

第1章で挙げた課題に対して、以下のように解決しました。

課題 解決策 該当章
国ごとに異なる住所構造をどう表現するか 共通フィールド(Optional)+ 国別バリデータで柔軟に対応 第2章
共通モデルが肥大化しやすい 「構造は緩く、制約は外に出す」方針で分離 第2章
新しい国の追加時に修正漏れが発生しやすい assert_never で型チェック時に漏れを検出 第3章
将来の拡張時に安全かどうか保証しづらい 値オブジェクト生成時のバリデーションで不正データを防止 第3章

再現性のあるパターン

本記事で紹介したパターンは、住所以外の「国ごとに異なるルールを持つデータ」にも適用できます。

  • 税率の計算ルール
  • 通貨のフォーマット
  • 法的要件(個人情報の取り扱いなど)

「構造は緩く、制約は外に出す」「型で網羅性を保証する」という方針は、国際化対応全般に通じる考え方だと思っています。

注意点

繰り返しになりますが、本記事で紹介した設計は、現時点での Recustomer における 最適解の一つ であり、決して「国際住所対応の最終形」ではありません。

実務の視点で見ると、この設計には次のような 割り切りと前提条件 が存在します。

  • provincecity といった共通フィールドは、すべての国にとって意味論的に完全一致するわけではなく、
    「構造を完全に一般化すること」と「実務で扱いやすい粒度」のトレードオフ の上に成り立っている
  • 国別のバリデーションは コードで中央集権的に管理 しているため、
    対応国が 5〜10 カ国規模のうちは安全だが、数十カ国規模になると 運用コストがボトルネックになり得る

つまりこの設計は、

「型安全性・実装漏れ防止・後方互換性」を最優先した、第1フェーズの現実解

だと位置づけています。

今後、対応国がさらに増え、要件がさらに複雑になっていけば、 次のフェーズの設計 が必要になる可能性も高いと考えています。

本記事の設計は、そのための 土台となる“壊れにくい最初の一歩” ということだけ補足させてください。

さいごに

Recustomerは今、日本から世界へと歩みを進めています。

https://prtimes.jp/main/html/rd/p/000000095.000046039.html

本記事で紹介した国際化対応は、その挑戦のほんの一部に過ぎません。対応国はこれからも増え続け、解くべき課題は次々と現れます。

「世界中の EC を支えるプロダクトを、自分の手で作りたい」

そんな想いを持つエンジニアの方、ぜひ一度お話ししませんか?

カジュアル面談では、Startup CTO of the Year 受賞の CTO が必ず対応します。技術の話、キャリアの話、何でも気軽にどうぞ。

https://engineer-entrance-book.recustomer.me/

参考

https://shopify.engineering/handling-addresses-from-all-around-the-world

https://book.impress.co.jp/books/1117101057

Discussion