ソフトウェア技術研究会
Riverpodを使用したレイヤードアーキテクチャ

Riverpodを使用したレイヤードアーキテクチャ

📅 2025-12-06

📛 Rerurate_514

はじめに

こんにちは。Rerurate_514(イニシャル KY)と申します。

今回は東北工業大学ソフトウェア技術研究会 Advent Calendar 2025 - Adventarの6日目ということで、Riverpodを使用したレイヤードアーキテクチャの記事を書いていこうと思います。

3年生なのですが、既にFlutterの開発の大手?で内定を頂いたので自分自身の勉強も兼ねての記事になります。

Flutterは特に簡単で構成が自由自在な反面、自在にアーキテクチャを決めることができるで、アプリを作る際にもアーキテクチャ決めで苦労するかもしれません。というかする。Web上にもオレオレ最強アーキテクチャなんてものが溢れかえっていますから、さぞ大変かと思います。

今回も例に漏れず、オレオレ最強アーキテクチャなので、あまり過信はしないでくださいね。結局のところ、プログラミングについて、銀の弾丸は存在しないので。。

概要

今回はレイヤードアーキテクチャということで、構成の概略はこんな感じです。

基本的にはよくあるクリーンアーキテクチャの層に沿っている感じです。ところどころにタイトルにあるようなRiverpodを活用した層が含まれている感じになります。(ModelsEntitiesもあります)

UI層から各ウィジェットもしくはPageごとにViewModelのメソッド呼び出しを行います。
さらにViewModelからNotifier(状態を持たないならService)のメソッドを呼び出します。といった具合にそれぞれの責務をなるべく分離するようにしています。

正直なところ、テストをmockitoでやる場合、dartにはbuild_runnerとかいう最強ツールがあるので、テスト容易性のために厳重にinterfaceを定義してがちがちクリーンアーキテクチャをやる必要があるのかなって感じています。

今回使用するライブラリは、

  • flutter_riverpod
  • riverpod_generator
  • freezed
  • SharedPreference
    です。
    上三つは必須になりますね。

Repository <--> ライブラリ

infrastructureと毎回書くのが面倒くさいので以下infra層で省略します。

ライブラリやAPI通信などを行う場合、infra層のRepositoryを作成します。

今回はSharedPreferenceの操作をするRepositoryを例にとって作成します。
ライブラリ固有でインスタンスを持つ場合、そのproviderから作成します。
ライブラリの固有のインスタンスもリポジトリの中で生成するのではなく、providerを通してそのインスタンスを取得するようにします。
私はinfrastructure/coreにライブラリ固有のproviderを入れています。

//infrastructure/core
part 'shared_prefs_provider.g.dart';

@Riverpod(keepAlive: true)
Future<SharedPreferences> sharedPreferences(Ref ref) async {
  final prefs = await SharedPreferences.getInstance();
  return prefs;
}

SharedPrefsが非同期でインスタンスを取得するので、FutureProviderを使用します。

次にRepositoryinterfaceを作成します。
ちなみにinterfaceは最初にIを付ける派の人間です。
PrefsKeysは単にキーを管理するsealedクラスになります。別にenumでもいいです。
個人的に拡張関数をいちいち書くのが面倒くさいです。。。

//domain/repositories
abstract class ISharedPrefsRepository {
  Future<void> save(PrefsKeys key, int data);
  Future<CountData?> load(PrefsKeys key);
}

sealed class PrefsKeys {
  final String value;
  const PrefsKeys(this.value);
}

class Counter extends PrefsKeys {
  Counter() : super("counter");
}

infra層で取得するデータはinfrastructure/modelsの中で定義します。
これはinfra層で使用するデータとほかの層で使用するデータを明確に区別するためです。今回の例だとわかりずらいかもしれませんか、API通信などをする際にはとても役に立ちます。[1]

もちろん、データ定義にはfreezedパッケージを使用します。

//infrastructure/models
part 'count_data.freezed.dart';

@freezed
sealed class CountData with _$CountData {
	const factory CountData({
		required int? data
	}) = _CountData;
}

そして、Repositoryを継承していきます。

//infrastructure/repositories
part 'shared_prefs_repository.g.dart';

@riverpod
ISharedPrefsRepository sharedPrefsRepository (Ref ref) {
  return SharedPrefsRepositoryImpl(
    prefs: ref.watch(sharedPreferencesProvider).requireValue
  );
}

class SharedPrefsRepositoryImpl implements ISharedPrefsRepository {
  late final SharedPreferences prefs;

  SharedPrefsRepositoryImpl({
    required this.prefs
  });
  
  @override
  Future<CountData?> load(PrefsKeys key) async {
    final value = prefs.getInt(key.value);
	return CountData(data: value);
  }

  @override
  Future<void> save(PrefsKeys key, int data) async {
    await prefs.setInt(key.value, data);
  }
}

RiverpodGeneratorを使用して、ライブラリのproviderを引数にとっています。
implementsextendsと違って、継承先のクラスのメソッドすべてのoverrideを強制するものです。

RepositoryのInterfaceを継承したRepositoryImplprovider化するのではなく、そのインスタンスを格納したproviderをRiverpodGeneratorで作成します。
Repositoryinterface を継承した具象クラス(RepositoryImpl)を直接 provider 化するのではなく、interface のインスタンスを返すプロバイダを作成します。
これは、高レベルなモジュール(domain層)が低レベルなモジュール(infra層)に直接依存させないためです。あとDI的にもいいです。[2]

ここで、Repository <--> ライブラリ は完了です。

Notifier|Service(usecase) <--> Repository

前項でinfra層のRepositoryを作成しましたが、ここからDomain/Application層の話になります。
Infra層では外部に依存するプログラムを書いていましたが、Domain/Application層ではビジネスロジック、つまりアプリなどの機能操作に関わるプログラムを書きます。

ここで、NotifierServiceの違いについてですが、個人的な考えで言うと

  • 状態(state)を持つならNotifier
  • 状態を持たないならService
    という風に分けています。

ただし、Notifierの場合はRiverpodGeneratorでClassNotifierを作成し、Serviceの場合はServiceインスタンスを格納するproviderを作成していきます。

ちなみにどんな時にNotifierになるのかというと、例えばAudioPlayerを管理するMusicPlayerNotifierを作成した場合、曲が再生中かどうかを持つisPlaying、プレイリストの管理をするdomain層のService[3]など、usecaseそのものが状態を持つときもあります。
その時はinstance変数を使用するのではなく、そのNotifierが持つ状態(state)として登録しておきます。
責務と特徴ごとで名前を変えています。

依存関係でいうとこんな感じになるかと思います。

今回は値を返すだけで状態を持たなそうなので、Serviceで作成していきます。

//domain/usecases
abstract class ICountService {
  Future<Count> getCurrentCount();
  Future<void> incrementCount(Count currentCount);
}

もちろん、扱う型はEntity(DomainModel)を使用します。DomainModelはinfra層のModelとは違います。[4]

//domain/entities
part 'count.freezed.dart';

@freezed
sealed class Count with _$Count {
  const factory Count({
    required int value,
  }) = _Count;

  factory Count.empty() {
	return Count(value: 0);
  }
  
  //こういった具合でビジネスロジックを書いていく。
  bool isAboveLimit(CountLimit limit) {
    return value > limit.value;
  }

  
  Count increment(CountLimit maxLimit) {
	  final nextValue = value + 1;
	  
	  if (nextValue > maxLimit.value) { 
		  return const Count(value: 0); 
	  }
	  
	  return Count(value: nextValue);
  }
}

そして、具象クラスを作成します。

//application/services
@riverpod
ICountService countService(CountServiceRef ref) {
  return CountServiceImpl(
    repo: ref.read(sharedPrefsRepositoryProvider),
    domainService: ref.read(countDomainServiceProvider),
  );
}

class CountServiceImpl implements ICountService {
  final ISharedPrefsRepository _repo;
  final CountDomainService _domainService;

  CountServiceImpl({
    required ISharedPrefsRepository repo,
    required CountDomainService domainService
  }) : _repo = repo, _domainService = domainService;

  @override
  Future<Count> getCurrentCount() async {
    final countData = await _repo.load(Counter()); 
    
    return Count(value: countData?.data ?? 0); 
  }

  @override
  Future<void> incrementCount(Count currentCount) async {
    final nextCount = _domainService.calculateNextCount(currentCount)
    await _repo.save(Counter(), nextCount);
  }
}

ビジネスロジックを詳細に含んだDomainServiceも記述します。しかし、今回はEntityのコードをそのまま実行しているだけなので必要ないですが、例として書いておきます。
本来、DomainServiceが必要になるのは、複数のEntityを必要とするビジネスロジックなどです。

// domain/services/count_domain_service.dart
part 'count_domain_service.g.dart';

@riverpod
CountDomainService countDomainService(Ref ref) {
	//今回はハードコードしましたが、基本的にはConfigProviderなどから取得するのがいいかと思います。
	final maxLimit = CountLimit(99);
	return CountDomainService(
		maxLimit: maxLimit
	);
}

class CountDomainService {
  final CountLimit _maxLimit;

  CountDomainService({required CountLimit maxLimit}) : _maxLimit = maxLimit;

  Count calculateNextCount(Count currentCount) {
	final nextCount = currentCount.increment(_maxLimit);
    return nextCount;
  }
}

CountLimitは値のみを持つバリューオブジェクトとして定義しています。

// domain/values/count_limit.dart (値オブジェクト)
class CountLimit {
  final int value;
  const CountLimit(this.value);
}

ここでコードの役割を整理してみると、

操作

責務

カウントの保存・ロード

Infrastructure層 (Repository)

データを永続化媒体(SharedPrefs)から読み書きする。Infra Model (CountData) を使用し、外部入出力に依存する。

カウントのインクリメント処理

Application層 (CountService)

現在のカウントを取得 -> ビジネスルールを適用し次の値を計算 -> Repositoryに保存を指示するという処理の流れ(ユースケース)を行う。

カウントの最大値チェック

Domain層 (DomainService)

カウントが999を超えたらリセットするなど、複数のドメインモデル間の複雑なビジネスロジックを定義・実行する。(シンプルな場合はモデルに内蔵される)

Domain層 (Value Object)

一意性を持たない値(例: CountCountLimit)をラップし、値に関する単純なビジネスルール(例: 限界値チェック)を内包する(場合もある)。

Domain層 (Entity)

一意なIDを持つもの(例: UserOrder)を表し、その状態とライフサイクルを制御する。ビジネスルールを保持する(場合もある)。

になります。

Application層では一連のロジックフローを書いていきます。
詳細なビジネスロジックはDomain層のDomainServiceに書くという分担をしています。

Notifierとして状態を複数持つ場合

ちなみに、カウントがアプリ内で共有する値として定義する場合は、Serviceではなく、Notifierを作成して状態を持つのが一番いいやり方かと思います。
その場合、状態の管理は少し難しくなります。
ViewModelに一つのusecaseのみを操作する場合はなんてことないですが、状態を持つusecaseが複数ある場合、usecase一つが変更された際に全体が再ビルドされるので、ref.watchselectで監視対象を制限する必要が出てくるかと思います。
以下はその例です、私が書いていたコードを抜粋しています。

@override
PlayPageViewState build() {
  final musicPlayerStaticInfo = ref.watch(musicPlayerProvider.select((state) => (
      state.pds.getCurrentMusic(),
      state.pds.playList.name,
      state.isMusicSelected,
  )));
  
  final isPlaying = ref.watch(musicPlayerProvider.select((state) => state.isPlaying));
  final currentSeconds = ref.watch(musicPlayerProvider.select((state) => state.currentSeconds));

  return PlayPageViewState(
    currentMusic: musicPlayerStaticInfo.$1,
    currentPlayListName: musicPlayerStaticInfo.$2,
    isMusicSelected: musicPlayerStaticInfo.$3,
    isPlaying: isPlaying,
    currentSeconds: currentSeconds
  );
}

このコードはusecase側が複数の状態を持っているので、再ビルドのスコープを制限しています。
監視対象を制限することで、適切な描画管理をすることができます。
ただ、UI側で少しだけコードが肥大化してしまうという問題もあります。

final state = ref.watch(playPageViewModelProvider);
final provValue = ref.watch(
  playPageViewModelProvider.select((asyncValue) {
	final state = asyncValue.value;
	if(state == null) return null;

	return (
	  state.currentMusic,
	  state.currentPlayListName,
	  state.isMusicSelected,
	);
  })
);

//---------------

child: state.when(
	data: (state) {
	  final currentMusic = provValue.$1;
	  final currentPlayListName = provValue.$2;
	  final isMusicSelected = provValue.$3;
	  
//---------------

provValueにはDart固有のRecord型が入っています。
selectでどのstateが変更されたかで再ビルドされるかを決めることができます。

ViewModel <--> Notifier|Service(usecase)

viewmodelnotifierを操作します。
Flutterはその特性上、UIでどんなコード、ビジネスロジックを含むことができるので、presentation層の責任が大きくなりがちです。
usecaseをUI側に置くとUIの状態にusecaseが依存しそうなので、ViewModelを介在させます。
UI層で状態の管理やpresentation層で詳細なビジネスロジックを含まないためです。

私が今作成しているアプリではPageごと(BottomNavigationBarで定義されているもの)にViewModelを作成しています。

HomePageがあるならHomeViewModelです。
ここで単にクラスとしてHomeViewModelを作成するのではなく、RiverpodGeneratorを使用してHomeViewModelNotifierを作成します。

//presentation/feature/home/viewmodel
@riverpod
class HomePageViewModel extends _$HomePageViewModel {
	@override
	Future<HomePageViewState> build() async {
		final cds = ref.watch(countServiceProvider);
		final currentCount = await cds.getCurrentCount();
		return HomePageViewState(count: currentCount);
	}
	
	Future<void> onTappedButton() async {
		final cds = ref.read(countServiceProvider);
		if (!state.hasValue) return;
		final currentViewState = state.requireValue; 
		
		try {
			await cds.incrementCount(currentViewState.count);
			final updatedCount = await cds.getCurrentCount();
			state = AsyncData(currentViewState.copyWith(count: updatedCount));
		} catch(e, s) {
			state = AsyncError(e, s);
		}
	}
}

ここで状態として、HomePageViewStateを持っています。
必ず、ViewModelには状態を持つStateモデルを持つようにします。

//presentation/feature/home/state

part 'home_page_view_state.freezed.dart';

@freezed
sealed class HomePageViewState with _$HomePageViewState {
  const factory HomePageViewState({
    required Count count
  }) = _HomePageViewState;

  factory HomePageViewState.createEmpty(){
    return HomePageViewState(count: Count.empty());
  }
}

このUI層とViewModelの分離を行うことで、UIでロジックを書くことを阻止します。

もし、Pageがたくさんの機能を持ち始めてstateViewModelの責務が肥大化してきたら、機能ごとにViewModelを分けるのも手です。もしかしたら、そっちのが最適かもしれません。私も今試しているところです。ただ、かなり体験は良くて、すごく書いてて楽です。

UI層 <--> ViewModel

ボタンをタップした際、最初にイベント通知が飛ばされるのはViewModelになります。
UI側はただViewmModelのメソッドを呼び出すだけなので、責任が明確に分離されていることがわかるかと思います。

UI側はこんな感じになります。

class HomePage extends HookConsumerWidget {
	const HomePage({super.key});
	
	@override
	Widget build(BuildContext context, WidgetRef ref) {
		final state = ref.watch(homePageViewModelProvider);
		final viewmodel = ref.watch(homePageViewModelProvider.notifier);
		
		return Scaffold(
			body: state.when(
				data: (viewState) {
					return TextButton(
						TextButton( 
							onPressed: viewmodel.onTappedButton, 
							child: Text("count: ${viewState.count.value}"
						),
					);
				},
				loading: //省略
				error: //省略
			) 
		);
	}
}

watchでstateの値が変更されたら、UIも再ビルドするようにします。
provider.notifierViewModelのメソッドにアクセスします。

UI側は本当にただViewModelのメソッドを呼び出すだけになります。

これで

UI -> ViewModel -> usecase -> Repository -> 外部入出力

のフローが完成しました。

最後に

Riverpodを使用した場合、個人的に責任の範囲が明確にできたクラスを作成できる気がしているので、かなり良い体験で作成できています。
ただ、providerというかRiverpodの潜在的な問題点として、値がGlobalになりがちであるのでそこは意識しないといけませんかね。

実はViewModelusecaseRepositoryで複数のproviderを介しているため、ストリームの処理が非常に面倒くさいという問題点があったりします。
そういう時はストリームが必要な値だけ分離して、StreamProviderを使用する手もありますが、もっと良い方法があるんでしょうか。。

それとこのアーキテクチャを実際に使用してアプリを開発しているのですが、今までと比べて圧倒的に書いてて楽なのを実感しています。今まではアーキテクチャなんて、ガン無視でリポジトリとかユースケースとかそういった層の概念がないまま書いていました。

そうすると、それぞれのコードが密につながって、一つ修正したら他もたくさん修正みたいなことが頻繁に起こっていました。(DBのテーブルの型を変えたら、UIで表示していた型も変わるみたいな)。それがなくなってとてもありがたいです。

自分は綺麗なコード(当社比)または構造化されたコードを見ると、本当にアドレナリンが出て気持ち良くなってしまいます。わかる人いますか?友達に言ったら変態扱いされましたかなしい

この記事に関してAIはテーブルの部分とコードの正確性チェックをお願いしました。
最近のAIまみれの記事にはしたくなかったので。。。

コメント、反応などサークルのテキストチャットに書いてください。よろこびます。


  1. API通信で帰ってくる値は実際にアプリで使用しないような値やアプリ内でデータを分割して使用したい場合もあるかと思います。この時、modelsとしてすべてを取得してdomain層のentitiesでアプリで使用する実際に値に変換するのが良いと思います。↩︎
  2. 今回は話しませんが、テストするときにほぼ必須の手法です。↩︎
  3. このserviceはnotifier/serviceの話のserviceとはまた別の用語になります。正直、同じ名前なのはどうかと思いましたが、何も思いつきませんでした。
    ここでのdomain/serviceはアプリのビジネスロジックの根幹部分のプログラムを書いています。ここで書いたserviceをapplicationで使用する場合もあるかと思います。↩︎
  4. 層によって扱う呼び名が変わります。↩︎
© 2025 ソフトウェア技術研究会