C#クラス設計入門|保守しやすいクラスの作り方と設計原則を実例で解説

はじめに

C#でアプリケーションを開発するとき、クラスはプログラムを構成する中心的な要素です。しかし、動作するコードを書けたとしても、クラス設計が適切でなければ、機能追加や修正を重ねるうちにコードが複雑になってしまいます。

たとえば、1つのクラスにデータ管理、入力チェック、計算、画面表示、データベース保存などの処理を詰め込むと、仕様変更の影響範囲が分かりにくくなります。その結果、小さな修正が思わぬ不具合につながり、テストや再利用も難しくなります。

保守しやすいC#のクラスを作るには、単に文法を覚えるだけでなく、クラスの役割や責務を意識して設計することが重要です。

本記事では、C#におけるクラス設計の基本から、フィールド、プロパティ、メソッド、コンストラクターの役割、クラスの責務を一つに絞る考え方まで、具体的なコード例を使って解説します。

1. C#のクラス設計とは

C#のクラス設計とは、プログラムに必要なデータと処理を整理し、それぞれを適切なクラスとして表現することです。

クラスを設計するときは、主に次の点を考えます。

  • クラスが何を表すのか

  • クラスがどのようなデータを持つのか

  • クラスがどのような処理を担当するのか

  • 外部からどのように利用されるのか

  • ほかのクラスとどのように連携するのか

たとえば、ECサイトを開発する場合は、商品を表すProductクラス、注文を表すOrderクラス、顧客を表すCustomerクラスなどが考えられます。

C#
public class Product
{
public string Name { get; }
public decimal Price { get; }

public Product(string name, decimal price)
{
Name = name;
Price = price;
}
}

このProductクラスは、商品の名前と価格を管理します。現実世界や業務上の概念をクラスとして表現することで、プログラムの構造を理解しやすくできます。

ただし、業務上の名詞をそのままクラスにすれば、必ず良い設計になるわけではありません。各クラスがどこまでの責任を持つのかを明確にし、変更しやすい単位に分けることが重要です。

1-1. クラス設計の目的と必要性

クラス設計の主な目的は、プログラムの複雑さを管理することです。

小規模なプログラムであれば、すべての処理を1つのクラスに記述しても動作するかもしれません。しかし、機能が増えるにつれてコード量が増加すると、次のような問題が発生します。

  • どこに何の処理があるのか分からない

  • 1つの変更が複数の機能に影響する

  • 同じ処理が各所に重複する

  • 単体テストを書きにくい

  • 複数人での開発が難しくなる

クラス設計を行うことで、関連するデータと処理をひとまとまりにできます。

たとえば、注文金額を計算する処理が必要な場合、注文に関する情報と計算処理をOrderクラスにまとめると、コードの意図が明確になります。

C#
public class Order
{
private readonly List<OrderItem> _items = new();

public void AddItem(OrderItem item)
{
ArgumentNullException.ThrowIfNull(item);
_items.Add(item);
}

public decimal CalculateTotal()
{
return _items.Sum(item => item.Subtotal);
}
}

public class OrderItem
{
public string ProductName { get; }
public decimal UnitPrice { get; }
public int Quantity { get; }

public decimal Subtotal => UnitPrice * Quantity;

public OrderItem(string productName, decimal unitPrice, int quantity)
{
if (string.IsNullOrWhiteSpace(productName))
{
throw new ArgumentException(
"商品名を入力してください。",
nameof(productName));
}

if (unitPrice < 0)
{
throw new ArgumentOutOfRangeException(
nameof(unitPrice),
"単価は0以上で指定してください。");
}

if (quantity <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(quantity),
"数量は1以上で指定してください。");
}

ProductName = productName;
UnitPrice = unitPrice;
Quantity = quantity;
}
}

注文の合計金額を知りたい利用側は、CalculateTotalメソッドを呼び出すだけです。

C#
var order = new Order();

order.AddItem(new OrderItem("キーボード", 8000m, 1));
order.AddItem(new OrderItem("マウス", 4000m, 2));

decimal total = order.CalculateTotal();

Console.WriteLine(total);

計算方法の詳細をクラス内部に隠すことで、利用側が内部実装を意識する必要がなくなります。このように、データと処理を適切にまとめ、外部に必要な操作だけを公開することをカプセル化と呼びます。

1-2. クラスとインスタンスの違い

クラス設計を理解するには、クラスとインスタンスの違いを把握しておく必要があります。

クラスは、オブジェクトが持つデータや処理を定義した設計図です。インスタンスは、その設計図をもとに実際に生成されたオブジェクトです。

次のコードでは、Userがクラスです。

C#
public class User
{
public string Name { get; }

public User(string name)
{
Name = name;
}

public void Introduce()
{
Console.WriteLine($"私の名前は{Name}です。");
}
}

newキーワードを使ってUserクラスからオブジェクトを生成すると、それぞれが独立したインスタンスになります。

C#
var user1 = new User("田中");
var user2 = new User("佐藤");

user1.Introduce();
user2.Introduce();

user1user2は同じUserクラスから生成されていますが、保持している名前は異なります。

クラスとインスタンスの関係は、住宅の設計図と、その設計図から建てられた住宅に例えられます。同じ設計図を使用しても、それぞれの住宅は別の実体です。

C#のクラス設計では、「どのインスタンスも守るべきルール」をクラスに持たせることが大切です。たとえば、名前が空のユーザーを作成できないようにする場合は、コンストラクターで検証します。

C#
public class User
{
public string Name { get; }

public User(string name)
{
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException(
"名前を入力してください。",
nameof(name));
}

Name = name;
}
}

これにより、Userのインスタンスは必ず有効な名前を持つことになります。無効な状態のインスタンスを作らせないことも、保守しやすいクラス設計の重要なポイントです。

1-3. クラスを構成するフィールド・プロパティ・メソッド・コンストラクター

C#のクラスは、主にフィールド、プロパティ、メソッド、コンストラクターによって構成されます。それぞれの役割を理解し、適切に使い分けることが大切です。

フィールド

フィールドは、クラス内部でデータを保持するための変数です。

C#
public class BankAccount
{
private decimal _balance;
}

_balanceフィールドは、口座残高を保持しています。privateを指定すると、クラスの外部から直接変更できません。

クラス内部の状態を外部から自由に書き換えられるようにすると、不正な値が設定される可能性があります。そのため、フィールドは原則としてprivateにし、必要な操作をメソッドやプロパティとして公開する方法が基本です。

プロパティ

プロパティは、クラスが持つ値を外部に公開するための仕組みです。

C#
public class BankAccount
{
private decimal _balance;

public decimal Balance => _balance;
}

この例では、外部から残高を取得できますが、直接変更することはできません。

自動実装プロパティを使うと、フィールドを明示せずに値を保持できます。

C#
public class Customer
{
public int Id { get; }
public string Name { get; private set; }

public Customer(int id, string name)
{
Id = id;
Name = name;
}
}

Idはコンストラクターで設定した後に変更できません。Nameはクラス内部からのみ変更できます。

すべてのプロパティに無条件でpublic setを付けると、クラス外部から自由に状態を変更できてしまいます。

C#
public decimal Balance { get; set; }

この設計では、次のように負の残高を設定できてしまいます。

C#
account.Balance = -1000000m;

クラスのルールを守るためには、変更方法を制限する必要があります。

メソッド

メソッドは、クラスが担当する処理や操作を表します。

C#
public class BankAccount
{
private decimal _balance;

public decimal Balance => _balance;

public void Deposit(decimal amount)
{
if (amount <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(amount),
"入金額は0より大きい値を指定してください。");
}

_balance += amount;
}

public void Withdraw(decimal amount)
{
if (amount <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(amount),
"出金額は0より大きい値を指定してください。");
}

if (amount > _balance)
{
throw new InvalidOperationException("残高が不足しています。");
}

_balance -= amount;
}
}

外部から残高を直接変更させず、DepositWithdrawを通して操作させています。そのため、「入金額は正の値」「残高を超えて出金できない」というルールを常に守れます。

メソッド名には、処理内容が伝わる動詞を使用するのが基本です。DoExecuteのような抽象的な名前よりも、DepositWithdrawCalculateTotalのような具体的な名前を付けると、コードの可読性が高まります。

コンストラクター

コンストラクターは、インスタンスを生成するときに実行される特別な処理です。主に、初期値の設定や引数の検証に使用します。

C#
public class BankAccount
{
private decimal _balance;

public string AccountNumber { get; }
public decimal Balance => _balance;

public BankAccount(string accountNumber, decimal initialBalance)
{
if (string.IsNullOrWhiteSpace(accountNumber))
{
throw new ArgumentException(
"口座番号を入力してください。",
nameof(accountNumber));
}

if (initialBalance < 0)
{
throw new ArgumentOutOfRangeException(
nameof(initialBalance),
"初期残高は0以上で指定してください。");
}

AccountNumber = accountNumber;
_balance = initialBalance;
}
}

コンストラクターで必要な値を受け取るようにすると、必要なデータが不足したインスタンスの生成を防げます。

次のような引数なしのコンストラクターと書き換え可能なプロパティだけを持つ設計では、初期化されていないオブジェクトが作られる可能性があります。

C#
public class BankAccount
{
public string? AccountNumber { get; set; }
public decimal Balance { get; set; }
}

一方、必要な値をコンストラクターで要求すれば、インスタンス生成時に有効な状態を保証しやすくなります。

1-4. 良いクラス設計が保守性や拡張性に与える効果

良いクラス設計には、保守性、拡張性、再利用性、テスト容易性を高める効果があります。

変更の影響範囲を限定できる

クラスの役割が明確であれば、仕様変更が発生したときに修正すべき場所を特定しやすくなります。

たとえば、送料計算を注文クラスのさまざまな場所に直接記述していると、計算ルールを変更するたびに複数箇所を修正しなければなりません。

送料計算を専用のクラスに分離すれば、変更箇所を集約できます。

C#
public class ShippingFeeCalculator
{
public decimal Calculate(decimal orderAmount)
{
return orderAmount >= 5000m ? 0m : 500m;
}
}

送料無料の基準額や送料が変更された場合も、基本的にはShippingFeeCalculatorだけを修正すれば対応できます。

機能を追加しやすくなる

クラス同士の依存関係が整理されていると、新しい機能を追加するときに既存コードへの影響を抑えられます。

送料の計算方法が複数ある場合は、インターフェースを利用して処理を差し替えられます。

C#
public interface IShippingFeeCalculator
{
decimal Calculate(decimal orderAmount);
}

public class StandardShippingFeeCalculator
: IShippingFeeCalculator
{
public decimal Calculate(decimal orderAmount)
{
return orderAmount >= 5000m ? 0m : 500m;
}
}

public class ExpressShippingFeeCalculator
: IShippingFeeCalculator
{
public decimal Calculate(decimal orderAmount)
{
return orderAmount >= 10000m ? 600m : 1000m;
}
}

利用側は具体的なクラスではなく、IShippingFeeCalculatorに依存します。

C#
public class OrderService
{
private readonly IShippingFeeCalculator _shippingFeeCalculator;

public OrderService(
IShippingFeeCalculator shippingFeeCalculator)
{
_shippingFeeCalculator = shippingFeeCalculator;
}

public decimal CalculatePayment(decimal orderAmount)
{
decimal shippingFee =
_shippingFeeCalculator.Calculate(orderAmount);

return orderAmount + shippingFee;
}
}

この設計なら、通常配送と速達配送を切り替えても、OrderServiceの計算処理を書き換える必要はありません。

単体テストを書きやすくなる

1つのクラスが小さく、責務が明確であるほど、テスト対象を限定できます。

たとえば、ShippingFeeCalculatorのテストでは、画面表示やデータベース接続を用意する必要がありません。注文金額を入力し、期待する送料が返されるかを確認するだけです。

C#
[Fact]
public void Calculate_注文金額が5000円以上の場合_送料は0円()
{
var calculator = new ShippingFeeCalculator();

decimal result = calculator.Calculate(5000m);

Assert.Equal(0m, result);
}

クラス設計は、コードの見た目を整えるためだけの作業ではありません。将来の変更コストや不具合の発生リスクを抑えるための重要な取り組みです。

2. 保守しやすいクラス設計の基本

保守しやすいC#クラスを設計するには、クラスの役割を明確にし、内部の状態を適切に保護する必要があります。

特に意識したい基本は、次のとおりです。

  • クラスの責務を一つに絞る

  • フィールドをむやみに公開しない

  • 無効な状態のインスタンスを作らせない

  • メソッド名やプロパティ名から意図が分かるようにする

  • クラス同士の依存関係を必要最小限にする

  • 変更される可能性がある処理を分離する

これらの中でも、最初に取り組みたいのが「クラスの責務を一つに絞る」ことです。

2-1. クラスの責務を一つに絞る

保守しやすいクラス設計では、1つのクラスに多くの役割を持たせないことが重要です。この考え方は、SOLID原則の一つである単一責任の原則にもつながります。

単一責任の原則は、単純に「1クラスにつき1メソッドにする」という意味ではありません。クラスが変更される理由を一つにする、という考え方です。

たとえば、次のUserServiceクラスを見てみましょう。

C#
public class UserService
{
public void Register(string name, string email)
{
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException("名前は必須です。");
}

if (!email.Contains('@'))
{
throw new ArgumentException(
"メールアドレスの形式が正しくありません。");
}

Console.WriteLine("データベースにユーザーを保存します。");
Console.WriteLine($"{email}に登録完了メールを送信します。");
}
}

このクラスは、少なくとも次の責務を持っています。

  • 入力値を検証する

  • ユーザーを保存する

  • 登録完了メールを送る

一見するとユーザー登録という1つの処理に見えますが、変更される理由は複数あります。

メールアドレスの入力ルールが変わるかもしれません。データベースの種類が変わる可能性もあります。メール配信サービスが変更されることも考えられます。

これらを1つのクラスにまとめると、どれか1つの仕様変更でもUserServiceを修正しなければなりません。また、データベース保存だけをテストしたい場合でも、入力検証やメール送信の影響を受けます。

責務ごとにクラスを分割すると、次のように整理できます。

C#
public class User
{
public string Name { get; }
public string Email { get; }

public User(string name, string email)
{
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException(
"名前は必須です。",
nameof(name));
}

if (string.IsNullOrWhiteSpace(email) ||
!email.Contains('@'))
{
throw new ArgumentException(
"メールアドレスの形式が正しくありません。",
nameof(email));
}

Name = name;
Email = email;
}
}

Userクラスは、ユーザー情報と、ユーザーが有効であるためのルールを担当します。

保存処理はリポジトリとして分離します。

C#
public interface IUserRepository
{
void Save(User user);
}

public class UserRepository : IUserRepository
{
public void Save(User user)
{
ArgumentNullException.ThrowIfNull(user);

Console.WriteLine(
$"{user.Name}をデータベースに保存しました。");
}
}

メール送信も専用のクラスに分離します。

C#
public interface IRegistrationMailSender
{
void Send(User user);
}

public class RegistrationMailSender
: IRegistrationMailSender
{
public void Send(User user)
{
ArgumentNullException.ThrowIfNull(user);

Console.WriteLine(
$"{user.Email}に登録完了メールを送信しました。");
}
}

UserRegistrationServiceは、各処理の流れを調整する役割だけを担当します。

C#
public class UserRegistrationService
{
private readonly IUserRepository _userRepository;
private readonly IRegistrationMailSender _mailSender;

public UserRegistrationService(
IUserRepository userRepository,
IRegistrationMailSender mailSender)
{
_userRepository = userRepository;
_mailSender = mailSender;
}

public void Register(string name, string email)
{
var user = new User(name, email);

_userRepository.Save(user);
_mailSender.Send(user);
}
}

利用側では、必要な実装をコンストラクターから渡します。

C#
IUserRepository repository = new UserRepository();
IRegistrationMailSender mailSender =
new RegistrationMailSender();

var service =
new UserRegistrationService(repository, mailSender);

service.Register("田中太郎", "taro@example.com");

このように分割すると、各クラスの役割が明確になります。

Userはユーザー情報とそのルール、UserRepositoryは保存処理、RegistrationMailSenderはメール送信、UserRegistrationServiceは登録手順を担当します。

ただし、クラスは細かく分ければよいわけではありません。過度に分割すると、処理の流れを追うために多くのファイルを行き来することになり、かえって理解しにくくなります。

クラスを分割すべきか迷ったときは、次の観点で判断するとよいでしょう。

  • 異なる理由で変更される処理が混在していないか

  • クラス名だけで役割を説明できるか

  • クラスの説明に「そして」「さらに」が何度も必要にならないか

  • 一部の処理だけを独立してテストしたいか

  • 別の場所でも再利用できる処理か

  • 外部サービスやデータベースへの依存が含まれているか

たとえば、「注文を管理し、請求書を作成し、メールを送信し、データベースにも保存するクラス」と説明する必要があるなら、責務が多すぎる可能性があります。

一方、「注文商品と合計金額を管理するクラス」であれば、役割が比較的明確です。

クラスの行数だけで責務の多さを判断することも避けましょう。短いクラスでも複数の責務を持つ場合があり、長いクラスでも1つの複雑な業務ルールだけを扱っている場合があります。重要なのは、コード量ではなく変更理由です。

まとめ

C#のクラス設計では、関連するデータと処理をまとめ、クラスごとの役割を明確にすることが重要です。

クラスは設計図であり、インスタンスはクラスから生成された実体です。フィールドは内部データの保持、プロパティは値の公開、メソッドは操作、コンストラクターは初期化と入力値の検証を担当します。

保守しやすいクラスを作るためには、フィールドを安易に公開せず、プロパティやメソッドを通じて状態を管理しましょう。また、必要な値をコンストラクターで受け取り、無効な状態のインスタンスを作らせない設計も効果的です。

特に重要なのが、クラスの責務を一つに絞ることです。入力検証、データ保存、メール送信など、異なる理由で変更される処理を分離すれば、変更の影響範囲を限定できます。単体テストも書きやすくなり、新しい機能を追加するときの負担も軽減されます。

C#のクラス設計に絶対的な正解はありません。最初から完璧な設計を目指すのではなく、クラスの役割と変更理由を確認しながら、必要に応じて責務を分割していくことが大切です。