コナーセンスとは
聞きなれない概念だが、どうやら「一方の変更がなぜ他方に波及するのか」という関係性、のようです。 (関係性に名前を付けていくあたり、圏論っぽい)
学んだのは以下の書籍から
ソフトウェアアーキテクチャの基礎 第2版 3.3.5 コナーセンス
https://www.oreilly.co.jp/books/9784814401550/
参考動画
引用元は Youtube Jim Weirich 講演 2012
https://www.youtube.com/watch?v=HQXVKHoUQxY
英語の講演をAIで整理して動画にまとめたのは以下
コナーセンス Jim Weirich講演 と ソフトウェアアーキテクチャの基礎第2版で理解する
https://www.youtube.com/watch?v=ieGJYW7GIfs&feature=youtu.be
本記事のねらい
今回はこれがリファクタリングにどう役立つかを整理して
AIに「コナーセンスでリファクタリングして」と依頼すれば
何が良くなるのかを理解したい
こここまで自前の文章だが、以下はAI生成。
AIがどれだけコナーセンスを理解しているかがわかる。
つまり、以下の一連の修正を「コナーセンス」の一言でカバーできるのである。
※補足
名前のコナーセンスはCoN(名前)と表記
名前のコナーセンスは、最も弱いコナーセンス
(具体的には、IDEのリファクタリング機能で、比較的安全に一括修正できる)
コナーセンスを弱める方向のリファクタリングパターン
| 元の結合 | 何が揃っている必要があるか | 主な手法 | 変換後 |
|---|---|---|---|
| CoM(意味) | 値の解釈 | 列挙型、値オブジェクト | CoN / CoT |
| CoP(位置) | 順番 | 名前付き引数、パラメータオブジェクト | CoN / CoT |
| CoA(アルゴリズム) | 計算手順 | 手順を一か所に集めて名前で呼ぶ | CoN |
| CoE(実行順序) | 呼び出し順 | 順序を型やスコープで強制する | CoT / CoN |
| CoTiming(タイミング) | 時間的な間合い |
await で完了を明示的に待つ |
CoE → CoN |
| CoV(値) | 複数の値の整合 | 導出値にする、不変条件をカプセル化する | CoN |
| CoI(同一性) | 同じインスタンスの共有 | 共有物を所有するクラスに閉じ込める | CoN |
1. CoM → CoN / CoT:マジックナンバーに型や名前を与える
// Before: 「2 は管理者」「-1 は見つからない」を双方が知っている
if (user.Role == 2) { /* ... */ }
int index = FindUser(name); // -1 = not found
// After
public enum Role { Guest, Member, Admin }
if (user.Role == Role.Admin) { /* ... */ }
User? found = FindUser(name); // 「いない」を型で表す
public readonly record struct UserId(int Value); // int の取り違えも防ぐ
2. CoP → CoN / CoT:位置を名前に置き換える
// Before: 引数の順番を呼び出し側が覚えている
SendMail("a@example.com", "b@example.com", "件名", "本文", true, false);
(string, string, int) GetCustomer() => ("山田", "東京", 30);
// After: パラメータオブジェクト(record)
public sealed record MailRequest
{
public required string From { get; init; }
public required string To { get; init; }
public required string Subject { get; init; }
public string Body { get; init; } = "";
public bool IsHtml { get; init; }
}
SendMail(new MailRequest { From = "a@example.com", To = "b@example.com", Subject = "件名" });
public sealed record Customer(string Name, string City, int Age);
- 引数が少なければ、名前付き引数(
isHtml: true) - 同じ型の引数が並ぶ場合(
fromとto)は、EmailAddressのような値オブジェクトにすると CoT まで下がる - CSV の列番号や
object[]のインデックス参照も同じ問題で、型付きの DTO に写して解消する
3. CoA → CoN:手順を一か所に集める
// Before: 登録側と認証側が同じハッシュ手順を別々に実装している
// Registration
var hash = Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(pw + salt)));
// Login
var actual = Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(input + salt)));
// After: 手順を知っているのは PasswordHasher のみ
public interface IPasswordHasher
{
string Hash(string password);
bool Verify(string password, string hash);
}
// Registration
user.PasswordHash = hasher.Hash(pw);
// Login
if (!hasher.Verify(input, user.PasswordHash)) return Unauthorized();
- サービス間(クライアントとサーバーのチェックサム、署名、シリアライズ)では、共有ライブラリにするのが一つの方法
- もう一つは、片方だけが計算して結果を渡す設計に変えること。(たとえば金額の計算はサーバーで行い、クライアントは表示するだけにする)
4. CoE → CoT / CoN:順序を型とスコープで強制する
// Before: Connect → Send → Close の順を呼び出し側が守る
var client = new MailClient();
client.Server = "smtp.example.com";
client.Connect();
client.Send(message);
client.Close();
(a) 前段を済ませないと得られない型にする
public sealed class MailSession : IAsyncDisposable
{
private MailSession() { }
public static async Task<MailSession> ConnectAsync(string server) { /* ... */ return new(); }
public Task SendAsync(MailRequest message) { /* ... */ return Task.CompletedTask; }
public ValueTask DisposeAsync() { /* close */ return ValueTask.CompletedTask; }
}
await using var session = await MailSession.ConnectAsync("smtp.example.com");
await session.SendAsync(message); // 接続前には Send を書けない
(b) 前後の処理を関数で挟む(Execute Around)
public async Task<T> InTransactionAsync<T>(Func<DbTransaction, Task<T>> work)
{
await using var tx = await _conn.BeginTransactionAsync();
try { var r = await work(tx); await tx.CommitAsync(); return r; }
catch { await tx.RollbackAsync(); throw; }
}
(c) 段階付きビルダーで手順を型にする
public interface INeedsTo { INeedsSubject To(string to); }
public interface INeedsSubject { IBuildable Subject(string subject); }
public interface IBuildable { MailRequest Build(); }
// MailBuilder.From("a").To("b").Subject("c").Build() の順でしか書けない
Init() を呼んでから使うクラスは、コンストラクタかファクトリで完全な状態にして返すのが最も簡単
5. CoTiming → CoE → CoN:時間ではなく完了を待つ
// Before: 「500ms あれば終わるはず」という前提
worker.Start();
Thread.Sleep(500);
Assert.True(worker.IsDone);
// After: 完了を Task として公開する
Task done = worker.StartAsync();
await done;
Assert.True(worker.IsDone);
// イベント待ちなら TaskCompletionSource
var tcs = new TaskCompletionSource<Order>();
bus.Subscribe<OrderPlaced>(e => tcs.TrySetResult(e.Order));
var order = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
生産者と消費者の速度差は Channel<T> で受け渡すと、間合いへの依存が API の名前への依存に変わる
6. CoV → CoN:値を導出するか、不変条件を閉じ込める
// Before: Total と Lines の整合を、更新するすべての場所が守る
order.Lines.Add(line);
order.Total += line.Price * line.Quantity; // 忘れると壊れる
// After: 導出値にして、変更の入口を一つにする
public sealed class Order
{
private readonly List<OrderLine> _lines = new();
public IReadOnlyList<OrderLine> Lines => _lines;
public decimal Total => _lines.Sum(l => l.Price * l.Quantity);
public void AddLine(OrderLine line) => _lines.Add(line);
}
複数の値が揃って初めて正しい場合は、値オブジェクトにまとめる
public readonly record struct DateRange
{
public DateOnly Start { get; }
public DateOnly End { get; }
public DateRange(DateOnly start, DateOnly end)
{
if (end < start) throw new ArgumentException("End は Start 以降");
(Start, End) = (start, end);
}
}
テストと本体で同じリテラル(上限値 100 など)を別々に書くのも CoV 。Limits.MaxItems のような定数を両方から参照すると CoN
7. CoI → CoN:共有物の所有者を一つにする
// Before: 両方が同じロックオブジェクトと同じ辞書を使う必要がある
public static class Shared
{
public static readonly object StockLock = new();
public static readonly Dictionary<string, int> Stock = new();
}
class OrderService { void Ship(string sku) { lock (Shared.StockLock) Shared.Stock[sku]--; } }
class ReturnService { void Accept(string sku) { lock (Shared.StockLock) Shared.Stock[sku]++; } }
// After: ロックと状態を Inventory が所有する
public sealed class Inventory
{
private readonly object _gate = new();
private readonly Dictionary<string, int> _stock = new();
public void Decrease(string sku) { lock (_gate) _stock[sku]--; }
public void Increase(string sku) { lock (_gate) _stock[sku]++; }
}
// 利用側は Inventory のメソッド名にしか依存しない
- 「同じインスタンスを渡す」規約が残る場合は、DI コンテナのライフタイム登録(
AddSingleton/AddScoped)の一か所に集める - 可変オブジェクトの参照を共有する代わりに、ID を渡してリポジトリから引く形にすると、同一性への依存が消える。不変オブジェクト(
record)を渡す形でも同じ効果がある
適用の順番
- 動的なもの(CoI、CoV、CoTiming、CoE)から先に直す: コンパイラが検出できず、実行するまで壊れたことが分からないため
- 境界をまたぐ結合を優先する: 同じメソッド内の CoP は害が小さく、サービス間の CoA は害が大きくなる
- 弱められないものは近くに寄せる: 同じクラスへ閉じ込めれば、強い結合でも変更の影響は広がらない