業務アプリの日時とタイムゾーン ── DateTimeの罠からUTC保存の原則、テスト設計まで

· · C#, .NET, .NET Framework, Windows, タイムゾーン, 日時処理, テスト, 運用, 技術相談

「サーバーをクラウドの VM に移設したら、帳票の時刻が全部 9 時間ずれた」「海外拠点に配った端末だけ、日報の日付が前日になる」「深夜の集計バッチが、ある日だけ 2 回動いた形跡がある」。日時とタイムゾーンにまつわる相談は、この手の「環境が変わった途端に」という形でやってきます。コードは 1 行も変えていないのに、です。

「うちは日本国内専用のアプリだから、タイムゾーンは関係ない」と思われがちですが、実際の相談の多くはまさにその国内専用アプリで起きています。クラウドの VM やコンテナーは UTC 設定で払い出されることが多く、海外製のライブラリや SaaS の Web API は UTC やオフセット付きのタイムスタンプを返してきます。アプリ自身は日本時間だけで生きているつもりでも、境界の向こう側はとっくに UTC の世界です。「この時刻はどこ基準か」を明示しないまま値をやり取りしていると、サーバー移設やクラウド化の日に 9 時間ずれとして一気に顕在化します。

この記事では .NET の業務アプリを前提に、DateTime の Kind と暗黙変換の罠、DateTimeOffset との使い分け、「保存と通信は UTC またはオフセット付き、表示だけローカル」という原則、TimeZoneInfo と夏時間、DB との境界、そして TimeProvider を使ったテスト設計までを一通り整理します。設計レビューで毎回確認している項目を、チェックリストの形に落とすところまでやります。

1. まず結論

  • 日時事故の根本原因はほぼ 1 つ、「この時刻はどこ基準か」という情報を持たない値が境界(DB・API・ファイル)を越えることです。コードではなく環境が変わった日に発症します。
  • DateTimeKind(Utc / Local / Unspecified) という属性を持ち、既定は Unspecified です。ToLocalTime は Unspecified を UTC とみなしToUniversalTimeローカルとみなすという非対称な暗黙解釈があり、これが「9 時間ずれ」の典型的な発生源です。1
  • 新規コードの既定は DateTimeOffset にします。公式ガイダンスも「アプリケーション開発の既定の日時型として検討せよ」と明記しています。2
  • 原則は「保存と通信は UTC またはオフセット付き、表示だけローカル」。文字列にするときは ISO 8601 準拠のラウンドトリップ書式 “o” で書き出し、DateTimeStyles.RoundtripKind で読み戻します。3
  • タイムゾーン変換は TimeZoneInfo で行います。.NET 6 以降は IANA ID(Asia/Tokyo)と Windows ID(Tokyo Standard Time)の両方が使え、相互変換 API もあります4 ただし Windows での IANA 解決は ICU 依存で、古い Windows Server や不変グローバリゼーション構成では失敗します(4 章)。5
  • 日本に夏時間はなくても、海外拠点の端末・海外 SaaS との連携・UTC 設定のサーバーを経由した瞬間に、DST の「存在しない時刻・曖昧な時刻」を踏みます。6
  • DateTime.Now の直書きをやめ、TimeProvider(.NET 8 標準。旧環境は Microsoft.Bcl.TimeProvider)を注入して、時刻をテストで自由に動かせる設計にします。7

2. DateTimeのKind ── 3値の意味と暗黙変換の事故

DateTime は日時の値(Ticks)に加えて、Kind という属性を 1 つだけ持っています。値は 3 つで、既定は Unspecified です。1

Kind 意味 主な発生源
Utc UTC 基準の時刻 DateTime.UtcNowToUniversalTime() の結果
Local 実行マシンのローカルタイムゾーン基準 DateTime.NowToLocalTime() の結果
Unspecified どこ基準か不明(既定) new DateTime(...)DateTime.Parse(多くの場合)、DB からの読み出し

重要なのは、普通にコードを書くと Unspecified だらけになることです。コンストラクターで作った値も、文字列からパースした値も、DB から読み出した値も、既定では「どこ基準か不明」です。それ自体は問題ではありません。問題は、この Unspecified な値に対して変換メソッドが黙って基準を仮定することです。1

呼び出し Kind=Utc Kind=Local Kind=Unspecified
ToUniversalTime() そのまま返す UTC へ変換 ローカルとみなして UTC へ変換
ToLocalTime() ローカルへ変換 そのまま返す UTC とみなして ローカルへ変換

同じ Unspecified が、呼ぶメソッドによって「ローカル」とも「UTC」とも解釈される、という非対称がポイントです。コードで見るとこうなります。

// DB から読み出した値。多くの経路で Kind = Unspecified になる
var fromDb = new DateTime(2026, 7, 3, 9, 0, 0);

// 実行マシンが JST (UTC+9) の場合:
Console.WriteLine(fromDb.ToLocalTime());      // 18:00 ── UTC とみなされ +9 時間
Console.WriteLine(fromDb.ToUniversalTime());  // 00:00 ── ローカルとみなされ -9 時間

事故はこの形で起きます。DB に日本時間で保存された 09:00(Unspecified)を、表示前の「念のための変換」のつもりで ToLocalTime() すると、UTC とみなされて 18:00 になります。逆に「保存前に UTC へ揃えよう」という変換が経路のどこかに二重に入っていると、9 時間が二度引かれます。さらに厄介なのは、この挙動が実行マシンのタイムゾーン設定に依存することです。開発機(JST)では 9 時間、UTC 設定のサーバーでは 0 時間ずれるので、「手元では再現しません」という問い合わせになります。冒頭の「サーバー移設で 9 時間ずれた」は、たいていこの構図です。

2.1 DateTimeOffsetとの違いと使い分け

DateTimeOffset は日時に加えて UTC からのオフセット+09:00 など)を常に持つため、値単体で世界のどの瞬間かが一意に決まります。ログの記録、取引時刻、システムイベントの記録といった「時点の記録」用途では、公式ガイダンスが DateTimeOffset を既定の日時型として検討するよう明記しています。2 Kind の暗黙解釈に頼る余地がそもそもないので、この記事で扱う事故の大半が構造的に起きなくなります。

ただし万能ではありません。DateTimeOffset が持つのはオフセットであってタイムゾーンではない点に注意してください。+09:00 が日本なのか韓国なのかは分かりませんし、夏時間の調整規則も持ちません。2 「その土地の壁掛け時計の動き」を再現したい場合は、後述の TimeZoneInfo を組み合わせる必要があります。使い分けを表にします。

持っている情報 向いている用途 補足
DateTimeOffset 日時+UTC オフセット 発生時刻の記録、ログ、API 境界 新規コードの既定2
DateTime(Kind=Utc 運用) 日時のみ 内部計算、既存資産との互換 Kind の管理は全部自前
DateOnly / TimeOnly 日付のみ/時刻のみ 業務日付、営業時間、締め時刻 .NET Framework では使えない2
TimeSpan 時間間隔 経過時間、2 時点の差  
TimeZoneInfo タイムゾーンの定義(調整規則含む) 変換、夏時間の判定 4 章

既存の DateTime 資産を全部 DateTimeOffset に書き換えるのは現実的でないことが多いので、当社が改修案件でよく採る折衷案は「内部と保存は Kind=Utc の DateTime で統一し、境界(API・シリアライズ)は DateTimeOffset か “o” 書式文字列」です。どちらにしても、次章の原則が土台になります。

3. 原則 ── 保存と通信はUTCかオフセット付き、表示だけローカル

日時処理の設計原則は 3 行にまとまります。

  1. 発生時刻は DateTime.UtcNow または DateTimeOffset.UtcNow で取得し、UTC(またはオフセット付き)のまま持ち回る
  2. 境界(DB・API・ファイル・レジストリ)を越えるときは、書式と基準を仕様として明示する
  3. ローカル時刻への変換は、画面・帳票に出す直前の 1 回だけ

ローカル時刻で保存する設計は、「値の意味」をサーバーの OS 設定という外部状態に依存させる設計です。オンプレの日本設定サーバーで動いている間は問題が見えませんが、クラウド VM への移設、海外リージョンでの DR 構成、開発環境と本番の設定差──どれか 1 つで意味が変わります。UTC 保存なら、値の意味はどの環境でも同じです。表示用の変換は利用者側の設定(またはユーザーマスタの拠点タイムゾーン)で行うので、同じデータを東京とベルリンで見ても、それぞれ正しい壁時計時刻になります。

3.1 境界の文字列表現はISO 8601 / “o”書式

文字列で境界を越えるとき(JSON、CSV、ログ、設定ファイル)は、ISO 8601 準拠のラウンドトリップ書式 “o” を使います。”o” は DateTime の Kind、DateTimeOffset のオフセットを文字列に残し、DateTimeStyles.RoundtripKind を指定してパースすれば元の値に戻せます。3

using System.Globalization;

// 書き出し: 2026-07-03T13:30:00.0000000+09:00
DateTimeOffset now = DateTimeOffset.Now;
string s = now.ToString("o", CultureInfo.InvariantCulture);

// 読み戻し: オフセットを保ったまま復元される
var restored = DateTimeOffset.Parse(
    s, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);

CultureInfo.InvariantCulture を必ず併用してください。カルチャ既定のままだと、和暦などグレゴリオ暦以外のカルチャで動く端末で年の表記が変わります。逆にやってはいけないのが、"yyyy/MM/dd HH:mm" のような基準情報を持たない書式での保存・通信です。この文字列を受け取った側は「どこ基準か」を推測するしかなく、推測は環境が変わると外れます。なお System.Text.Json の既定の日時表現も ISO 8601 系なので、JSON 境界では素直に既定へ乗るのが安全です。

「境界を越えるデータは形式を仕様に明示する」という考え方は、日時に限りません。文字コードと改行コードでまったく同じ議論を「Windowsの文字コードと改行コード」でしています。境界の暗黙は、種類が違っても同じ形で壊れます。

3.2 タイムスタンプと「業務日付」を区別する

もう 1 つ、冒頭の「海外拠点だけ日報の日付が前日になる」を解くための区別です。タイムスタンプ(世界で一意の時点)と業務日付(「7 月 3 日の日報」というラベル)は別物です。業務日付を「深夜 0 時の DateTime」で持って UTC 変換を通すと、UTC+9 の 7 月 3 日 00:00 は UTC の 7 月 2 日 15:00 になり、日付部分を切り出した瞬間に前日へずれます。これが典型的な発生機序です。

業務日付は DateOnly(.NET Framework なら yyyy-MM-dd 形式の文字列や年月日の値)で持ち、「どのタイムゾーンで日付を切るか」を仕様で決めます。「日報の日付は拠点の現地時刻基準」「締めは本社 JST 基準」のように書いてあれば、実装は UtcNow を該当タイムゾーンへ変換してから日付を切るだけです。仕様に書いていないなら、それは実装者のマシン設定が仕様になっているということです。

4. タイムゾーン変換 ── TimeZoneInfoとID体系

タイムゾーン変換は TimeZoneInfoConvertTimeFromUtc / ConvertTimeToUtc / ConvertTime で行います。注意すべきは、これらの API が DateTime の Kind と変換元タイムゾーンの整合を検査することです。たとえば Kind=Utc の値を「変換元は東京」として渡すと ArgumentException になります。8 つまり Kind の管理がいい加減なコードは、タイムゾーン変換 API すらまともに呼べません。2 章の話はここに直結します。

4.1 WindowsのタイムゾーンIDとIANA ID

タイムゾーンを指定する ID には 2 つの体系があります。

  Windows ID IANA ID
例(日本) Tokyo Standard Time Asia/Tokyo
例(ドイツ) W. Europe Standard Time Europe/Berlin
管理元 Windows(レジストリ) IANA tz database
主な利用先 Windows API、.NET (Framework) Linux、海外 SaaS、Web API、他言語

.NET Framework 時代は Windows ID しか使えず、「Web API から Asia/Tokyo が飛んでくるが FindSystemTimeZoneById に渡せない」という変換表問題がありました。.NET 6 以降は TimeZoneInfo.FindSystemTimeZoneById が両方の ID を受け付け、システムに無い側の ID は自動で変換して解決します。明示的に変換したい場合の TryConvertIanaIdToWindowsId / TryConvertWindowsIdToIanaId も追加されています。4

// .NET 6+ : IANA ID がそのまま通る(Windows ID "Tokyo Standard Time" でも同じ結果)
var tokyo  = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo");
var berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");

DateTime utc = DateTime.UtcNow;
Console.WriteLine(TimeZoneInfo.ConvertTimeFromUtc(utc, tokyo));   // 東京の壁時計時刻
Console.WriteLine(TimeZoneInfo.ConvertTimeFromUtc(utc, berlin));  // ベルリンの壁時計時刻

// ID 体系の相互変換 (.NET 6+)
if (TimeZoneInfo.TryConvertWindowsIdToIanaId("Tokyo Standard Time", out var ianaId))
    Console.WriteLine(ianaId);  // Asia/Tokyo

実務の指針としては、拠点マスタや設定ファイルに保持するタイムゾーン ID は IANA ID に統一するのがおすすめです。海外 SaaS・Linux コンテナー・他言語との連携で共通に通じるのは IANA ID であり、.NET 側は 6 以降なら原則そのまま受けられます。.NET Framework で動く部分が残っている場合は、その境界でだけ Windows ID へ変換します。なお FindSystemTimeZoneById は ID が見つからないと TimeZoneNotFoundException を投げるので、マスタに ID を登録する画面や起動時の検証で早期に弾いてください。

ひとつ重要な前提条件があります。Windows 上での IANA ID の解決は ICU ライブラリに依存します。NLS モードやグローバリゼーション不変モード(InvariantGlobalization=true)で動いているアプリでは IANA ID は解決できず、TryConvertIanaIdToWindowsId も失敗します。5 また、OS に ICU が同梱されていない古い環境(Windows Server 2019、Windows 10 の 1809 以前など)で .NET 6 を動かす場合も、アプリローカル ICU を同梱しない限り同じ制約を踏みます(.NET 7 以降はこれらの OS でも ICU が使われるよう変更されています9)。つまり「開発機(Windows 11)では Asia/Tokyo が通るのに、客先の Server 2019 だけ TimeZoneNotFoundException」が現実に起きます。IANA ID をマスタに採用する場合は、(1) 実行環境の OS と .NET バージョンで IANA 解決が動くことを確認する、(2) コンテナー等でサイズ目的の InvariantGlobalization を安易に有効化しない、(3) 保険として TryConvertIanaIdToWindowsId で Windows ID に落として再試行するフォールバックを起動時検証に入れておく──の 3 点をセットにしてください。

5. 夏時間(DST)── 日本国内専用でも踏む場面

日本には現在夏時間がないため、「DST はうちには関係ない」と思われがちです。しかし次のどれかに当てはまると、確実に踏みます。

  • 海外拠点・海外出張者の端末でアプリが動く(端末のローカルタイムゾーンが DST を持つ)
  • 海外製 SaaS / Web API と連携し、現地時刻ベースのタイムスタンプやスケジュールを受け取る
  • 海外リージョンのサーバー・VM で集計やバッチが動く
  • 海外拠点の現地時刻で締める集計(「各拠点の 0 時締め」など)がある

DST を持つタイムゾーンでは、切り替え日に 2 種類の異常な時刻が生まれます。存在しない時刻(春、時計が進む瞬間にスキップされる帯。ドイツなら 3 月末の 02:00〜03:00)と、曖昧な時刻(秋、時計が戻ることで 2 回現れる帯)です。.NET では TimeZoneInfo.IsInvalidTime / IsAmbiguousTime で判定できます。6 そして ConvertTimeToUtc などの変換 API は、存在しない時刻を渡すと ArgumentException を投げ、曖昧な時刻は標準時側として解釈します8

var berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");

// 2026-03-29 はドイツの DST 開始日。02:00〜03:00 の現地時刻は存在しない
var t = new DateTime(2026, 3, 29, 2, 30, 0);   // Kind = Unspecified

Console.WriteLine(berlin.IsInvalidTime(t));    // True
// TimeZoneInfo.ConvertTimeToUtc(t, berlin) は ArgumentException になる

「現地時刻の文字列を受け取って UTC に変換して保存する」という入力経路があるなら、この例外は年に 1 日だけ発生するバグとして潜伏します。入力検証の段階で IsInvalidTime を確認し、曖昧な時刻は「標準時として扱う」ことを仕様に明記しておくのが現実的な落としどころです。

5.1 定期実行とDST ── 2回動く・動かない問題

定期実行はもう 1 つの定番の被弾ポイントです。現地時刻の 02:30 に毎日動くジョブは、DST 開始日にはその時刻が存在せず、終了日には 2 回存在します。スケジューラーの実装によって「スキップされる」「2 回起動する」「1 時間ずれて起動する」と挙動が分かれるため、「毎日 1 回だけ動く」前提で書かれた集計処理が、二重集計または欠測を起こします。対策は 3 つの組み合わせです。

  • スケジュール基準を UTC(または DST の無いタイムゾーン)にする。現地時刻での起動が要件でないバッチは、これだけで問題が消えます
  • 処理を冪等にする。「対象日の集計が既に存在すればスキップ」という実行済みマーカーを入れれば、2 回起動しても壊れません
  • 集計キーは業務日付で持つ(3.2 節)。起動時刻から日付を逆算しない

タスクスケジューラでの定期実行の設計(多重起動防止、失敗時の切り分け)は「タスクスケジューラのタスクが実行されない・0x1で終わる」で、常駐サービス側でタイマーを持つ設計は「Windowsサービスの作り方と運用」で詳しく書いています。どちらの方式でも、DST とスケジュールの関係は同じように仕様へ書いておく必要があります。

6. DBとの境界 ── SQL Server / SQLite / ORM

境界の中でいちばん事故が多いのが DB です。DB の日時型は「どこ基準か」を保持しないものが多く、保存した瞬間に Kind やオフセットの情報が失われるからです。

6.1 SQL Serverの日時型

範囲・精度 基準情報 新規採用の目安
datetime 1753 年〜、約 1/300 秒精度 なし 避ける(公式が新規利用を非推奨と明記)10
datetime2 0001 年〜、最大 100 ナノ秒精度 なし ◎ UTC で保存する列の本命10
datetimeoffset datetime2 相当+オフセット オフセットを保持 ○ 現地時刻の再現が要件の列10

datetime は丸め粒度が粗く範囲も狭い旧世代の型で、公式ドキュメントが「新規の作業では避け、datetime2 / datetimeoffset などを使うこと」と明記しています。10 既存スキーマの datetime を無理に移行する必要はありませんが、新しいテーブルで選ぶ理由はありません。

datetime2 に UTC で保存するか、datetimeoffset にするかは、「入力時点のオフセットを後から再現する必要があるか」で決めます。監査やコンプライアンスで「利用者の現地時刻で何時だったか」を残す要件があるなら datetimeoffset、時点さえ特定できればよいなら UTC の datetime2 で十分です。ただし datetimeoffset が持つのもオフセットだけで、タイムゾーン(調整規則)そのものではない点は DateTimeOffset と同じです。ゾーンまで要るなら IANA ID を別列で持ちます。

6.2 SQLiteには日時型がない

SQLite にはそもそも日時のストレージ型がなく、Microsoft.Data.SqliteDateTime / DateTimeOffsetTEXT として保存します。11 TEXT の ISO 8601 系書式は「書式とタイムゾーンが統一されていれば」文字列ソート=時刻ソートになりますが、逆に言えば UTC とローカルが 1 列に混ざった瞬間、ソートも範囲検索も静かに壊れます。SQLite を使う場合は「この列は UTC、書式はこれ」をアプリ側の規約として固定するしかありません。接続やトランザクションも含めた SQLite の実務は「C#でSQLiteを業務アプリに使う」にまとめています。

6.3 EF Core / Dapperでの注意 ── 読み出すとKindが消える

基準情報を持たない型(datetime2、SQLite の TEXT など)から DateTime を読み出すと、当然ながら Kind は Unspecified になります。「保存時は UTC で統一したのに、読み出した値を ToUniversalTime() している箇所があって二重変換になった」という事故はここから生まれます。対策は境界で Kind を復元することです。EF Core なら値コンバーターで一括して宣言できます。

// EF Core: 「この列は UTC」をモデル側で一括宣言する
modelBuilder.Entity<Order>()
    .Property(o => o.CreatedAtUtc)
    .HasConversion(
        // 書き込み: UTC 以外(DateTime.Now の混入など)も境界で UTC に正規化する。
        // Unspecified はローカルとみなして変換される点に注意
        v => v.Kind == DateTimeKind.Utc ? v : v.ToUniversalTime(),
        // 読み出し: Kind を復元
        v => DateTime.SpecifyKind(v, DateTimeKind.Utc));

書き込み側の正規化はあくまで最後の保険です。Unspecified の変換は実行マシンのタイムゾーン設定に依存するので、DateTime.Now や Unspecified な値を保存経路に流し込むコード自体は見つけ次第直します。保険と規約の二段構えにする、という理解でいてください。Dapper や素の ADO.NET なら、マッピング直後に DateTime.SpecifyKind を通す詰め替え層を 1 か所に集めます。あわせて効くのが命名規約で、列名・プロパティ名に基準を焼き込むCreatedAtUtcupdated_at_utc)だけで、レビューで「この値に ToUniversalTime は変だ」と気づける確率が大きく上がります。ドキュメントより名前のほうが読まれるからです。

7. 時刻の同期とテスト ── w32timeとTimeProvider

7.1 マシンの時計は合っている前提を疑う

ここまでの話は「マシンの時計自体は正しい」前提でしたが、その時計を合わせているのは Windows Time サービス(w32time)です。w32time は NTP でネットワーク上の時刻源と同期し、Active Directory 環境ではドメイン階層に沿って同期します。Kerberos 認証をはじめ、時刻ずれに敏感な仕組みの土台です。12

アプリ設計への含意は 2 つです。第一に、クライアント PC の時計を業務ロジックの根拠にしないこと。同期が止まった端末は平気で数分ずれるので、イベントの順序や締め時刻の判定はサーバー側の時刻で行い、クライアント時刻は参考情報に留めます。第二に、「時刻がおかしい」という問い合わせでは、アプリより先に w32tm /query /status で端末の同期状態を確認すること。アプリのバグ調査を始める前に 1 分で切り分けられます。

7.2 DateTime.Now直書きをやめる ── TimeProvider

日時処理のテストを阻む最大の要因は、コードのあちこちに直書きされた DateTime.Now です。「月末締めの判定」「年跨ぎの連番リセット」「DST 切替日のスケジュール」をテストしたくても、現在時刻が固定できなければ、その日が来るまで検証できません。

.NET 8 からは標準の時刻抽象 TimeProvider が入りました。GetUtcNow() / GetLocalNow() / LocalTimeZone / タイマー生成までを 1 つの抽象で差し替えられます。.NET Framework 4.6.2 以降と .NET Standard 2.0 でも NuGet の Microsoft.Bcl.TimeProvider で同じ型が使えるので、古い資産でも導入できます。テスト用の実装 FakeTimeProvider は Microsoft.Extensions.TimeProvider.Testing パッケージで提供されます。7

public sealed class DailyReportService
{
    private readonly TimeProvider _clock;
    private readonly TimeZoneInfo _siteTimeZone;

    public DailyReportService(TimeProvider clock, TimeZoneInfo siteTimeZone)
    {
        _clock = clock;
        _siteTimeZone = siteTimeZone;
    }

    // 業務日付は「拠点のタイムゾーンで日付を切る」と明示した実装 (3.2 節)
    public string GetReportDateKey()
    {
        var localNow = TimeZoneInfo.ConvertTime(_clock.GetUtcNow(), _siteTimeZone);
        return localNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
    }
}

本番では TimeProvider.System を渡し、テストでは FakeTimeProvider で時刻を自由に固定・前進させます。

[Fact]
public void 年跨ぎでも業務日付が正しく切り替わる()
{
    // JST の大晦日 23:30 に固定して開始
    var clock = new FakeTimeProvider(
        new DateTimeOffset(2026, 12, 31, 23, 30, 0, TimeSpan.FromHours(9)));
    var tokyo = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo");
    var svc = new DailyReportService(clock, tokyo);

    Assert.Equal("2026-12-31", svc.GetReportDateKey());

    clock.Advance(TimeSpan.FromHours(1));   // 年跨ぎを一瞬で再現
    Assert.Equal("2027-01-01", svc.GetReportDateKey());
}

FakeTimeProvider は時刻の固定・手動前進に加えてローカルタイムゾーンの差し替え(SetLocalTimeZone)もできるので、「ドイツ設定の端末で日付が前日になる」ようなバグを CI 上の日本のマシンで再現できます。.NET Framework の古い案件で NuGet 追加すら難しい場合は、DateTimeOffset UtcNow { get; } だけの自前 IClock インターフェイスでも効果は同じです。重要なのは抽象の豪華さではなく、「現在時刻」を注入可能な依存として扱うことです。

テストケースとして最低限入れておきたい時刻は、経験上この 5 つです: 年末年始(年跨ぎ)、月末(31 日・30 日・2 月)、うるう年の 2 月 29 日、対象タイムゾーンの DST 切替日(春・秋)、深夜 0 時前後(業務日付の境界)。どれも「その日にだけ」動くバグの定番生息地で、FakeTimeProvider があれば全部を数ミリ秒でテストできます。

8. チェックリストと判断表

日時処理は、UUID と同じく「なんとなく動いているから」で放置されがちな基盤要素です(「UUIDは衝突しないのか」で書いた構図とよく似ています)。新規設計とレビューで見る項目を固定しておくと、属人性が消えます。

新規設計時のチェックリスト:

  • 発生時刻の取得は DateTimeOffset.UtcNow / TimeProvider.GetUtcNow() に統一されているか
  • 保存・通信の基準(UTC かオフセット付きか)と書式(”o” / ISO 8601)が仕様書に明記されているか
  • DB の列型と基準が決まっているか(SQL Server なら datetime2(UTC) か datetimeoffset。列名に Utc を焼き込む)
  • タイムスタンプと業務日付を区別し、日付を切るタイムゾーンを決めたか
  • タイムゾーン ID の管理体系(IANA ID 推奨)と、不正 ID 時のエラー処理を決めたか
  • 定期実行の DST 方針(UTC 基準スケジュール+冪等化)を決めたか
  • TimeProvider / IClock が注入可能で、時刻境界のテストケースがあるか

レビュー時に grep で拾うコード:

見つけたら疑うコード 何が起きうるか 直し方
DateTime.Now サーバー移設でずれる。テスト不能 UtcNow +表示時変換。TimeProvider 注入
ToLocalTime() / ToUniversalTime() Unspecified の暗黙解釈(2 章) 境界で Kind を確定させ、表示直前だけ変換
DateTime.Parse(s)(styles 指定なし) 実行環境のカルチャ・TZ 依存 ParseExact + InvariantCulture + RoundtripKind
ToString("yyyy/MM/dd HH:mm") での保存・通信 基準情報が消える “o” 書式+ InvariantCulture
new DateTime(...) の比較・保存への直接使用 Unspecified 混入 SpecifyKind するか DateTimeOffset
SQL Server の新規 datetime 精度・範囲・将来性 datetime2 / datetimeoffset10

このチェックリストの大半は、新規開発なら最初の 1 日で決められる内容です。逆に稼働後の修正は、保存済みデータの基準を推定してマイグレーションする作業(どの期間のデータがどの基準で入ったかの考古学)になり、コストが 1 桁変わります。

9. まとめ

日時とタイムゾーンの事故は、症状こそ「9 時間ずれる」「日付が前日になる」「バッチが 2 回動く」とばらばらですが、原因は一貫して基準情報を持たない値が境界を越えることです。対策も一貫しています。取得は UTC、保存と通信は UTC またはオフセット付き+ ISO 8601(”o”)、表示だけローカル。境界では書式と基準を仕様に明示し、DB の列名に基準を焼き込む。タイムゾーンは TimeZoneInfo と IANA ID で扱い、DST の存在しない時刻・曖昧な時刻は入力検証と冪等な定期実行で受け止める。そして TimeProvider を注入して、年跨ぎも DST 切替日もテストで再現できるようにしておく──ここまでやれば、環境が変わった日に呼び出される側から、変わる前に指摘する側に回れます。

当社では、サーバー移設・クラウド化に伴う時刻ずれの原因調査、日時処理まわりの設計レビュー、海外拠点対応(タイムゾーン・DST 対応)の改修支援を扱っています。保存済みデータの基準がすでに混在してしまったケースの棚卸しと移行計画も含めて、判断に迷う場合はお手伝いできます。

関連記事

関連する相談領域

合同会社小村ソフトでは、業務アプリの日時・タイムゾーン処理の設計レビュー、サーバー移設やクラウド化に伴う時刻ずれ・日付ずれの原因調査、海外拠点対応やレガシー資産の日時処理改修を扱っています。

参考リンク

  1. Microsoft Learn, DateTime.Kind Property. Kind の既定値が Unspecified であること、Kind の値が ToLocalTime / ToUniversalTime の変換結果に与える影響(Unspecified が ToLocalTime では UTC、ToUniversalTime ではローカルとみなされること)について。  2 3

  2. Microsoft Learn, Choose between DateTime, DateOnly, DateTimeOffset, TimeSpan, TimeOnly, and TimeZoneInfo. DateTimeOffset をアプリケーション開発の既定の日時型として検討すべきこと、DateTimeOffset がオフセットのみを持ちタイムゾーンには紐づかないこと、DateOnly / TimeOnly が .NET Framework では利用できないことについて。  2 3 4 5

  3. Microsoft Learn, Standard date and time format strings. ラウンドトリップ書式 “o” が ISO 8601 準拠で DateTime の Kind・DateTimeOffset のオフセットを文字列に保持すること、DateTimeStyles.RoundtripKind でパースし直すことで往復できることについて。  2

  4. Microsoft Learn, What’s new in .NET 6. .NET 6 で TimeZoneInfo.FindSystemTimeZoneById が IANA / Windows 両方のタイムゾーン ID を受け付けて自動変換すること、TryConvertIanaIdToWindowsId / TryConvertWindowsIdToIanaId が追加されたことについて。  2

  5. Microsoft Learn, .NET globalization and ICU. Windows での IANA タイムゾーン ID の解決と相互変換 API が ICU に依存すること、NLS モード・グローバリゼーション不変モードでは利用できないこと、ICU を持たない OS ではアプリローカル ICU で対応できることについて。  2

  6. Microsoft Learn, TimeZoneInfo.IsInvalidTime(DateTime) Method. 夏時間への切り替えで生じる「存在しない時刻」の定義と判定方法、対になる IsAmbiguousTime(曖昧な時刻の判定)について。  2

  7. Microsoft Learn, What is TimeProvider?. TimeProvider が .NET 8 以降に標準搭載され、.NET Framework 4.6.2+ / .NET Standard 2.0 では Microsoft.Bcl.TimeProvider パッケージで利用できること、GetUtcNow / GetLocalNow / LocalTimeZone / タイマー生成の抽象、テスト用の FakeTimeProvider が Microsoft.Extensions.TimeProvider.Testing パッケージで提供されることについて。  2

  8. Microsoft Learn, TimeZoneInfo.ConvertTime Method. DateTime.Kind と変換元タイムゾーンの整合が要求され不一致で ArgumentException になること、曖昧な時刻が標準時として解釈されること、存在しない時刻を渡すと ArgumentException になることについて。  2

  9. Microsoft Learn, Globalization APIs use ICU libraries on Windows Server 2019. .NET 7 以降、ICU を同梱しない Windows Server 2019 等でも ICU ライブラリが使われるよう変更されたこと、それ以前はアプリローカル ICU の手動配置が必要だったことについて。 

  10. Microsoft Learn, datetime (Transact-SQL). 新規の作業では datetime を避け、time / date / datetime2 / datetimeoffset を使うべきこと、datetime2 / datetimeoffset の精度が高く datetimeoffset がタイムゾーンオフセットをサポートすることについて。  2 3 4 5

  11. Microsoft Learn, Data types (Microsoft.Data.Sqlite). SQLite のプリミティブ型が 4 つしかなく、Microsoft.Data.Sqlite が DateTime / DateTimeOffset を TEXT として保存することについて。 

  12. Microsoft Learn, Windows Time Service (W32Time). Windows Time サービスが NTP でネットワーク上のコンピューターの時刻を同期すること、Active Directory ドメインでの同期階層、Kerberos 認証が時刻同期に依存することについて。 

同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。

このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。

この記事は次のサービスページにつながります。近い入口からご覧ください。

著者プロフィール

記事の著者プロフィールページです。

小村 豪

合同会社小村ソフト 代表

Windows ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。

ブログ一覧に戻る