SQLiteは「軽くて速いローカルDB」として優秀ですが、 アプリ側の設計がスパゲッティだと、数年後には誰も触れなくなります。 そこで効いてくるのが、クリーンアーキテクチャ × DDD(ドメイン駆動設計)です。
この記事では、SQLiteを使った業務アプリに クリーンアーキテクチャとDDDをどう落とし込むかを、 実務目線で整理します。
・SQLite × クリーンアーキテクチャのレイヤー構成
・ドメイン層・アプリケーション層・インフラ層の役割
・RepositoryパターンとSQLite実装の分離
・DTO / Entity / Domain Model の使い分け
・MVVMとの組み合わせ方
・テスト容易性を高めるポイント
1. SQLite × クリーンアーキテクチャの全体像
まずは、典型的なレイヤー構成から整理します。
■ レイヤー構成(論理)
- Presentation層(UI:WPF / WinUI / Web)
- Application層(ユースケース・サービス)
- Domain層(ドメインモデル・ルール)
- Infrastructure層(SQLite・API・外部サービス)
ポイントは、内側(Domain・Application)が外側(Infrastructure)に依存しないこと。 SQLiteはあくまで「インフラの1つ」であり、 ドメインモデルはSQLiteの存在を知らない構造にします。
2. ドメイン層:ビジネスルールの中心
ドメイン層は、業務の「意味」を表現する層です。 ここにエンティティ・値オブジェクト・ドメインサービスが置かれます。
■ 例:顧客エンティティ
public class Customer
{
public CustomerId Id { get; }
public CustomerName Name { get; private set; }
public EmailAddress? Email { get; private set; }
public Customer(CustomerId id, CustomerName name, EmailAddress? email)
{
Id = id;
Name = name;
Email = email;
}
public void ChangeName(CustomerName newName)
{
if (newName == null) throw new ArgumentNullException(nameof(newName));
Name = newName;
}
public void ChangeEmail(EmailAddress? newEmail)
{
Email = newEmail;
}
}
ここではSQLiteの型(TEXT / INTEGER)やテーブル構造を一切意識しないのが重要です。
■ 値オブジェクト例
public record CustomerName
{
public string Value { get; }
public CustomerName(string value)
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException("顧客名は必須です。", nameof(value));
if (value.Length > 100)
throw new ArgumentException("顧客名は100文字以内です。", nameof(value));
Value = value;
}
public override string ToString() => Value;
}
バリデーションをドメイン側に寄せることで、UIやDBに依存しないルールになります。
3. Application層:ユースケースを表現する
Application層は、画面やAPIから見た「やりたいこと」を表現する層です。 ユースケース単位のサービス(アプリケーションサービス)を置きます。
■ 例:顧客登録ユースケース
public interface IRegisterCustomerUseCase
{
Task ExecuteAsync(RegisterCustomerCommand command);
}
public class RegisterCustomerCommand
{
public string Name { get; init; } = "";
public string? Email { get; init; }
}
public class RegisterCustomerUseCase : IRegisterCustomerUseCase
{
private readonly ICustomerRepository _repository;
private readonly IUnitOfWork _uow;
public RegisterCustomerUseCase(ICustomerRepository repository, IUnitOfWork uow)
{
_repository = repository;
_uow = uow;
}
public async Task ExecuteAsync(RegisterCustomerCommand command)
{
var customer = new Customer(
id: CustomerId.New(),
name: new CustomerName(command.Name),
email: string.IsNullOrEmpty(command.Email)
? null
: new EmailAddress(command.Email)
);
await _repository.AddAsync(customer);
await _uow.CommitAsync();
}
}
Application層はドメインモデルを使ってユースケースを実現しますが、 ここでもSQLiteの存在は意識しません。
4. Infrastructure層:SQLite実装を閉じ込める
SQLiteへの具体的なアクセスは、Infrastructure層に閉じ込めます。 ここでRepositoryインターフェースの実装を行います。
■ Repositoryインターフェース(Domain or Application側)
public interface ICustomerRepository
{
Task<Customer?> FindByIdAsync(CustomerId id);
Task AddAsync(Customer customer);
Task UpdateAsync(Customer customer);
}
■ SQLite実装(Infrastructure側)
public class SqliteCustomerRepository : ICustomerRepository
{
private readonly Func<IDbConnection> _connectionFactory;
public SqliteCustomerRepository(Func<IDbConnection> connectionFactory)
=> _connectionFactory = connectionFactory;
public async Task<Customer?> FindByIdAsync(CustomerId id)
{
using var con = _connectionFactory();
var dto = await con.QuerySingleOrDefaultAsync<CustomerDto>(
"SELECT Id, Name, Email FROM Customers WHERE Id = @Id",
new { Id = id.Value });
if (dto == null) return null;
return new Customer(
new CustomerId(dto.Id),
new CustomerName(dto.Name),
string.IsNullOrEmpty(dto.Email) ? null : new EmailAddress(dto.Email)
);
}
public async Task AddAsync(Customer customer)
{
using var con = _connectionFactory();
await con.ExecuteAsync(
"INSERT INTO Customers (Id, Name, Email) VALUES (@Id, @Name, @Email)",
new
{
Id = customer.Id.Value,
Name = customer.Name.Value,
Email = customer.Email?.Value
});
}
public async Task UpdateAsync(Customer customer)
{
using var con = _connectionFactory();
await con.ExecuteAsync(
"UPDATE Customers SET Name = @Name, Email = @Email WHERE Id = @Id",
new
{
Id = customer.Id.Value,
Name = customer.Name.Value,
Email = customer.Email?.Value
});
}
}
ここで初めてSQLiteのテーブル構造・SQL・Dapper/EF Coreが登場します。 ドメイン層・Application層は、これらの詳細を知りません。
5. DTO / Entity / Domain Model の使い分け
SQLite × クリーンアーキテクチャでは、 「どこで何の型を使うか」が重要になります。
■ よくある3層の型
- DB DTO:テーブルに対応する素の型(CustomerDto)
- Domain Model:ビジネスルールを持つ型(Customer, CustomerName)
- ViewModel / Response DTO:画面やAPI向けの型
Repositoryは、DB DTO ⇔ Domain Model の変換を担当します。 Application層は、Domain Model ⇔ ViewModel/Response DTOの変換を担当します。
6. MVVMとの組み合わせ方(WPF想定)
WPF / WinUI でMVVMを使う場合、 Presentation層はViewModel → UseCase呼び出しに徹します。
■ ViewModel例
public class CustomerEditViewModel : INotifyPropertyChanged
{
private readonly IRegisterCustomerUseCase _registerUseCase;
public string Name { get; set; } = "";
public string? Email { get; set; }
public ICommand SaveCommand { get; }
public CustomerEditViewModel(IRegisterCustomerUseCase registerUseCase)
{
_registerUseCase = registerUseCase;
SaveCommand = new AsyncRelayCommand(SaveAsync);
}
private async Task SaveAsync()
{
var cmd = new RegisterCustomerCommand
{
Name = this.Name,
Email = this.Email
};
await _registerUseCase.ExecuteAsync(cmd);
}
}
ViewModelはユースケース(UseCase)を呼ぶだけにしておくと、 UI変更とドメインロジックが綺麗に分離されます。
7. テスト容易性を高めるポイント
クリーンアーキテクチャ × DDD の大きなメリットは、 テストがしやすくなることです。
- ドメイン層:純粋なC#クラスとして単体テスト
- Application層:Repositoryをモック化してユースケーステスト
- Infrastructure層:SQLiteを使った統合テスト
- ViewModel:UseCaseをモック化してUIロジックのテスト
特に、ドメイン層がSQLiteに依存していないことが、 テスト容易性に直結します。
8. 業務アプリ向けベストプラクティス
- SQLiteはInfrastructure層に閉じ込める(Domain/Applicationから見えないようにする)
- ドメイン層にビジネスルールを集約する(バリデーション・状態遷移)
- Application層は「ユースケース単位」のサービスとして設計する
- RepositoryはインターフェースをDomain/Application側に置き、実装をInfrastructure側に置く
- DTO / Domain Model / ViewModel を混ぜない(変換ポイントを明確にする)
- MVVMのViewModelはUseCaseを呼ぶだけにして、DBやドメインロジックを持たせない
まとめ:SQLiteでも“ちゃんとしたアーキテクチャ”は必要
- SQLiteは軽量だが、アプリの寿命は設計で決まる
- クリーンアーキテクチャ × DDDで、DB依存を内側から追い出せる
- レイヤー分離により、テスト・保守・機能追加が圧倒的に楽になる
「とりあえずSQLiteで動けばいい」 から一歩進んで、 「10年運用できる業務アプリ」 を目指すなら、 クリーンアーキテクチャとDDDは非常に強力な武器になります。 この記事をベースに、あなたのプロジェクトに合ったレイヤー構成を設計してみてください。